diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..509c092d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "app/packages/redis-oplog"] + path = app/packages/redis-oplog + url = https://github.com/ramezrafla/redis-oplog.git diff --git a/README.md b/README.md index 3a50ea99..447d08b4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ DiceCloud ======== -This is the repo for [DiceCloud](dicecloud.com). +This is the repo for [DiceCloud](https://dicecloud.com). DiceCloud is a free, auditable, real-time character sheet for D&D 5e. diff --git a/app/.meteor/packages b/app/.meteor/packages index ff36c66b..06788bcc 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -3,29 +3,30 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -accounts-password@2.3.1 +zegenie:redis-oplog +accounts-password@2.3.4 random@1.2.1 -underscore@1.0.11 +underscore@1.0.13 dburles:mongo-collection-instances accounts-google@1.4.0 -email@2.2.2 +email@2.2.5 meteor-base@1.5.1 mobile-experience@1.1.0 -mongo@1.16.1 +mongo@1.16.6 session@1.2.1 -tracker@1.2.1 -logging@1.3.1 +tracker@1.3.2 +logging@1.3.2 reload@1.3.1 ejson@1.1.3 check@1.3.2 standard-minifier-js@2.8.1 shell-server@0.5.0 -ecmascript@0.16.3 +ecmascript@0.16.7 es5-shim@4.8.0 service-configuration@1.3.1 -dynamic-import@0.7.2 -ddp-rate-limiter@1.1.1 -rate-limit@1.0.9 +dynamic-import@0.7.3 +ddp-rate-limiter@1.2.0 +rate-limit@1.1.1 mdg:validated-method static-html@1.3.2 aldeed:collection2 @@ -38,7 +39,7 @@ simple:rest-method-mixin mikowals:batch-insert peerlibrary:subscription-data zer0th:meteor-vuetify-loader -akryum:vue-component +akryum:vue-component@0.15.2 akryum:vue-router2 percolate:migrations meteortesting:mocha @@ -46,6 +47,7 @@ ostrio:files simple:rest-bearer-token-parser simple:rest-json-error-handler littledata:synced-cron -mdg:meteor-apm-agent -typescript@4.5.4 +#mdg:meteor-apm-agent +typescript@4.9.4 seba:minifiers-autoprefixer +mixmax:smart-disconnect diff --git a/app/.meteor/release b/app/.meteor/release index 42890181..e8cfc7ec 100644 --- a/app/.meteor/release +++ b/app/.meteor/release @@ -1 +1 @@ -METEOR@2.8.1 +METEOR@2.12 diff --git a/app/.meteor/versions b/app/.meteor/versions index 3f632f0b..578fce81 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -1,7 +1,7 @@ -accounts-base@2.2.5 +accounts-base@2.2.8 accounts-google@1.4.0 -accounts-oauth@1.4.1 -accounts-password@2.3.1 +accounts-oauth@1.4.2 +accounts-password@2.3.4 accounts-patreon@0.1.0 akryum:npm-check@0.1.2 akryum:vue-component@0.15.2 @@ -12,7 +12,7 @@ aldeed:collection2@3.5.0 aldeed:schema-index@3.0.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.9.2 +babel-compiler@7.10.4 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 @@ -21,7 +21,7 @@ boilerplate-generator@1.7.1 bozhao:link-accounts@2.6.1 caching-compiler@1.2.2 caching-html-compiler@1.2.1 -callback-hook@1.4.0 +callback-hook@1.5.1 check@1.3.2 coffeescript@2.4.1 coffeescript-compiler@2.4.1 @@ -29,20 +29,20 @@ dburles:mongo-collection-instances@0.3.6 ddp@1.4.1 ddp-client@2.6.1 ddp-common@1.4.0 -ddp-rate-limiter@1.1.1 -ddp-server@2.6.0 +ddp-rate-limiter@1.2.0 +ddp-server@2.6.1 diff-sequence@1.1.2 -dynamic-import@0.7.2 -ecmascript@0.16.3 -ecmascript-runtime@0.8.0 +dynamic-import@0.7.3 +ecmascript@0.16.7 +ecmascript-runtime@0.8.1 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 ejson@1.1.3 -email@2.2.2 +email@2.2.5 es5-shim@4.8.0 -fetch@0.1.2 +fetch@0.1.3 geojson-utils@1.0.11 -google-oauth@1.4.2 +google-oauth@1.4.3 hot-code-push@1.0.4 html-tools@1.1.3 htmljs@1.1.1 @@ -52,36 +52,34 @@ inter-process-messaging@0.1.1 lai:collection-extensions@0.3.0 launch-screen@1.3.0 littledata:synced-cron@1.5.1 -livedata@1.0.18 localstorage@1.2.0 -logging@1.3.1 -mdg:meteor-apm-agent@3.5.1 -mdg:validated-method@1.2.0 -meteor@1.10.2 +logging@1.3.2 +mdg:validated-method@1.3.0 +meteor@1.11.2 meteor-base@1.5.1 -meteortesting:browser-tests@1.3.5 -meteortesting:mocha@2.0.3 +meteortesting:browser-tests@1.4.2 +meteortesting:mocha@2.1.0 meteortesting:mocha-core@8.1.2 mikowals:batch-insert@1.3.0 -minifier-css@1.6.1 +minifier-css@1.6.4 minifier-js@2.7.5 -minimongo@1.9.0 +minimongo@1.9.3 +mixmax:smart-disconnect@0.0.5 mobile-experience@1.1.0 mobile-status-bar@1.1.0 modern-browsers@0.1.9 modules@0.19.0 modules-runtime@0.13.1 -mongo@1.16.1 +mongo@1.16.6 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -mongo-livedata@1.0.12 -npm-mongo@4.11.0 -oauth@2.1.2 -oauth2@1.3.1 +npm-mongo@4.16.0 +oauth@2.2.0 +oauth2@1.3.2 ordered-dict@1.1.0 ostrio:cookies@2.7.2 -ostrio:files@2.3.2 +ostrio:files@2.3.3 patreon-oauth@0.1.0 peerlibrary:assert@0.3.0 peerlibrary:check-extension@0.7.0 @@ -94,11 +92,11 @@ peerlibrary:reactive-publish@0.10.0 peerlibrary:server-autorun@0.8.0 peerlibrary:subscription-data@0.8.0 percolate:migrations@1.1.0 -promise@0.12.1 +promise@0.12.2 raix:eventemitter@1.0.0 random@1.2.1 -rate-limit@1.0.9 -react-fast-refresh@0.2.3 +rate-limit@1.1.1 +react-fast-refresh@0.2.7 reactive-dict@1.3.1 reactive-var@1.0.12 reload@1.3.1 @@ -114,16 +112,17 @@ simple:rest@1.2.1 simple:rest-bearer-token-parser@1.1.1 simple:rest-json-error-handler@1.1.1 simple:rest-method-mixin@1.1.0 -socket-stream-client@0.5.0 +socket-stream-client@0.5.1 spacebars-compiler@1.3.1 standard-minifier-js@2.8.1 static-html@1.3.2 templating-tools@1.2.2 tmeasday:check-npm-versions@1.0.2 -tracker@1.2.1 -typescript@4.5.4 -underscore@1.0.11 +tracker@1.3.2 +typescript@4.9.4 +underscore@1.0.13 url@1.3.2 -webapp@1.13.2 +webapp@1.13.5 webapp-hashing@1.1.1 +zegenie:redis-oplog@2.0.16 zer0th:meteor-vuetify-loader@0.1.41 diff --git a/app/imports/api/creature/actions/Actions.js b/app/imports/api/creature/actions/Actions.js new file mode 100644 index 00000000..947c779b --- /dev/null +++ b/app/imports/api/creature/actions/Actions.js @@ -0,0 +1,27 @@ +import SimpleSchema from 'simpl-schema'; + +// Actions are creature actions that have been partially executed and not yet resolved +// They require some user input to progress +let Actions = new Mongo.Collection('actions'); + +let CreaturePropertySchema = new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + // Which creature is taking the action + _creatureId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + // The user who began taking the action + user: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + // The property that is about to be applied + property: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, +}); diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js index 9479b0c7..112ea06d 100644 --- a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js +++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js @@ -13,12 +13,12 @@ import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileS import verifyArchiveSafety from '/imports/api/creature/archive/methods/verifyArchiveSafety.js'; let migrateArchive; -if (Meteor.isServer){ - migrateArchive = require('/imports/migrations/server/migrateArchive.js').default; +if (Meteor.isServer) { + migrateArchive = require('/imports/migrations/archive/migrateArchive.js').default; } -function restoreCreature(archive, userId){ - if (SCHEMA_VERSION < archive.meta.schemaVersion){ +function restoreCreature(archive, userId) { + if (SCHEMA_VERSION < archive.meta.schemaVersion) { throw new Meteor.Error('Incompatible', 'The archive file is from a newer version. Update required to read.') } @@ -35,7 +35,7 @@ function restoreCreature(archive, userId){ }); if (existingCreature) throw new Meteor.Error('Already exists', 'The creature you are trying to restore already exists.') - + // Ensure the user owns the restored creature archive.creature.owner = userId; @@ -44,13 +44,13 @@ function restoreCreature(archive, userId){ Creatures.insert(archive.creature); try { // Add all the properties - if (archive.properties && archive.properties.length){ + if (archive.properties && archive.properties.length) { CreatureProperties.batchInsert(archive.properties); } - if (archive.experiences && archive.experiences.length){ + if (archive.experiences && archive.experiences.length) { Experiences.batchInsert(archive.experiences); } - if (archive.logs && archive.logs.length){ + if (archive.logs && archive.logs.length) { CreatureLogs.batchInsert(archive.logs); } } catch (e) { @@ -73,23 +73,23 @@ const restoreCreaturefromFile = new ValidatedMethod({ numRequests: 10, timeInterval: 5000, }, - async run({fileId}) { + async run({ fileId }) { // fetch the file - const file = ArchiveCreatureFiles.findOne({_id: fileId}).get(); - if (!file){ + const file = ArchiveCreatureFiles.findOne({ _id: fileId }).get(); + if (!file) { throw new Meteor.Error('File not found', - 'The requested creature archive does not exist'); + 'The requested creature archive does not exist'); } // Assert ownership const userId = file?.userId; - if (!userId || userId !== this.userId){ + if (!userId || userId !== this.userId) { throw new Meteor.Error('Permission denied', - 'You can only restore creatures you own'); + 'You can only restore creatures you own'); } assertHasCharactersSlots(this.userId); - if (Meteor.isServer){ + if (Meteor.isServer) { // Read the file data const archive = await ArchiveCreatureFiles.readJSONFile(file); restoreCreature(archive, this.userId); diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.js b/app/imports/api/creature/creatureProperties/CreatureProperties.js index ff301a79..b451dd1d 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.js +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.js @@ -46,6 +46,12 @@ let CreaturePropertySchema = new SimpleSchema({ regEx: SimpleSchema.RegEx.Id, optional: true, }, + // Fill more than one quantity in a slot, like feats and ability score + // improvements, filtered out of UI if there isn't space in quantityExpected + slotQuantityFilled: { + type: SimpleSchema.Integer, + optional: true, // Undefined implies 1 + }, }); const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ @@ -82,6 +88,12 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ index: 1, removeBeforeCompute: true, }, + deactivatingToggleId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + optional: true, + removeBeforeCompute: true, + }, // When this is true on any property, the creature needs to be recomputed dirty: { type: Boolean, diff --git a/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js b/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js new file mode 100644 index 00000000..46e7d024 --- /dev/null +++ b/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js @@ -0,0 +1,189 @@ +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; +import { + assertEditPermission, + assertDocEditPermission, + assertCopyPermission +} from '/imports/api/sharing/sharingPermissions.js'; +import { + setLineageOfDocs, + getAncestry, + renewDocIds +} from '/imports/api/parenting/parenting.js'; +import { reorderDocs } from '/imports/api/parenting/order.js'; +import { setDocToLastOrder } from '/imports/api/parenting/order.js'; +import Libraries from '/imports/api/library/Libraries.js'; +const DUPLICATE_CHILDREN_LIMIT = 500; + +const copyPropertyToLibrary = new ValidatedMethod({ + name: 'creatureProperties.copyPropertyToLibrary', + validate: new SimpleSchema({ + propId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + parentRef: { + type: RefSchema, + }, + order: { + type: Number, + optional: true, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 5000, + }, + run({ propId, parentRef, order }) { + // get the new ancestry for the properties + let { parentDoc, ancestors } = getAncestry({ parentRef }); + + // Check permission to edit the destination + let rootLibrary; + if (parentRef.collection === 'libraries') { + rootLibrary = parentDoc; + } else if (parentRef.collection === 'libraryNodes') { + rootLibrary = Libraries.findOne(parentDoc.ancestors[0].id) + } else { + throw `${parentRef.collection} is not a valid parent collection` + } + assertEditPermission(rootLibrary, this.userId); + + const insertedRootNode = insertNodeFromProperty(propId, ancestors, order, this); + + // Tree structure changed by inserts, reorder the tree + reorderDocs({ + collection: LibraryNodes, + ancestorId: rootLibrary._id, + }); + + // Return the docId of the inserted root property + return insertedRootNode?._id; + }, +}); + +function insertNodeFromProperty(propId, ancestors, order, method) { + // Fetch the property and its descendants, provided they have not been + // removed + let prop = CreatureProperties.findOne({ + _id: propId, + removed: { $ne: true }, + }); + if (!prop) { + if (Meteor.isClient) return; + else { + throw new Meteor.Error( + 'Insert property from library failed', + `No property with id '${propId}' was found` + ); + } + } + + // Make sure we can edit this property + assertDocEditPermission(prop, method.userId); + + let oldParent = prop.parent; + const propCursor = CreatureProperties.find({ + 'ancestors.id': propId, + removed: { $ne: true }, + }); + + // Make sure there aren't too many descendants + if (propCursor.count() > DUPLICATE_CHILDREN_LIMIT) { + throw new Meteor.Error('Copy children limit', + `The property has over ${DUPLICATE_CHILDREN_LIMIT} descendants and cannot be copied`); + } + + let props = propCursor.fetch(); + + // The root prop is first in the array of props + // It must get the first generated ID to prevent flickering + props = [prop, ...props]; + + // If the docs came from a library, that library must consent to this user copying their + // properties + assertSourceLibraryCopyPermission(props, method); + + // re-map all the ancestors + setLineageOfDocs({ + docArray: props, + newAncestry: ancestors, + oldParent, + }); + + // Give the docs new IDs without breaking internal references + renewDocIds({ + docArray: props, + collectionMap: { 'creatureProperties': 'libraryNodes' } + }); + + // Order the root node + if (order === undefined) { + setDocToLastOrder({ + collection: LibraryNodes, + doc: prop, + }); + } else { + prop.order = order; + } + + // Insert the props as library nodes + LibraryNodes.batchInsert(props); + return prop; +} + +/** + * + * @param {[Property]} props The properties to check + * @param {String} userId The userId trying to copy these properties to a library + * Checks that every property can be copied out of the library that originated it by this user + */ +function assertSourceLibraryCopyPermission(props, method) { + // Skip on the client + if (method.isSimulation) return; + + // Get all the library node ids that are sources for these properties + const libraryNodeIds = []; + props.forEach(prop => { + if (prop.libraryNodeId) libraryNodeIds.push(prop.libraryNodeId); + }); + if (!libraryNodeIds.length) return; + + // Get the actual library Ids that each of these source nodes came from + const sourceLibIds = new Set(); + LibraryNodes.find({ + _id: { $in: libraryNodeIds } + }, { + fields: { ancestors: 1 } + }).forEach(node => { + sourceLibIds.add(node.ancestors?.[0]?.id); + }); + + // Assert copy permission on each of those libraries + Libraries.find({ + _id: { $in: Array.from(sourceLibIds) } + }, { + fields: { + name: 1, + owner: 1, + readers: 1, + writers: 1, + public: 1, + readersCanCopy: 1, + } + }).forEach(lib => { + try { + assertCopyPermission(lib, method.userId); + } catch (e) { + throw new Meteor.Error('Copy permission denied', + `One of the properties you are copying comes from ${lib.name}, which you do not have permission to copy from`); + } + }); +} + +export default copyPropertyToLibrary; diff --git a/app/imports/api/creature/creatureProperties/methods/damageProperty.js b/app/imports/api/creature/creatureProperties/methods/damageProperty.js index f129a562..a8cd0696 100644 --- a/app/imports/api/creature/creatureProperties/methods/damageProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/damageProperty.js @@ -64,12 +64,12 @@ export function damagePropertyWork({ prop, operation, value, actionContext, logF // Save the value to the scope before applying the before triggers if (operation === 'increment') { if (value >= 0) { - actionContext.scope['$damage'] = value; + actionContext.scope['~damage'] = { value }; } else { - actionContext.scope['$healing'] = -value; + actionContext.scope['~healing'] = { value: -value }; } } else { - actionContext.scope['$set'] = value; + actionContext.scope['~set'] = { value }; } applyTriggers(actionContext.triggers?.damageProperty?.before, prop, actionContext); @@ -77,12 +77,12 @@ export function damagePropertyWork({ prop, operation, value, actionContext, logF // fetch the value from the scope after the before triggers, in case they changed them if (operation === 'increment') { if (value >= 0) { - value = actionContext.scope['$damage']; + value = actionContext.scope['~damage']?.value; } else { - value = -actionContext.scope['$healing']; + value = -actionContext.scope['~healing']?.value; } } else { - value = actionContext.scope['$set']; + value = actionContext.scope['~set']?.value; } let damage, newValue, increment; diff --git a/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js b/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js index 9bb40898..25044a7d 100644 --- a/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js @@ -42,6 +42,11 @@ const duplicateProperty = new ValidatedMethod({ let propertyId = randomSrc.id(); property._id = propertyId; + // Change the variableName so it isn't immediately overridden + if (property.variableName) { + property.variableName += 'Copy' + } + // Get all the descendants let nodes = CreatureProperties.find({ 'ancestors.id': _id, diff --git a/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js index d4670c95..9558ff55 100644 --- a/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js +++ b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js @@ -1,17 +1,19 @@ -export default function getSlotFillFilter({slot, libraryIds}){ +export default function getSlotFillFilter({ slot, libraryIds }) { + + if (!slot) throw 'Slot is required for getSlotFillFilter'; + if (!libraryIds) throw 'LibraryIds is required for getSlotFillFilter'; + let filter = { - removed: {$ne: true}, + fillSlots: true, + removed: { $ne: true }, $and: [] }; - if (libraryIds){ - filter['ancestors.id'] = {$in: libraryIds}; - } - if (slot.slotType){ + filter['ancestors.id'] = { $in: libraryIds }; + if (slot.slotType) { filter.$and.push({ $or: [{ type: slot.slotType - },{ - type: 'slotFiller', + }, { slotFillerType: slot.slotType, }] }); @@ -19,44 +21,43 @@ export default function getSlotFillFilter({slot, libraryIds}){ filter.$and.push({ $or: [{ type: 'classLevel', - },{ - type: 'slotFiller', + }, { slotFillerType: 'classLevel', }] }); - if (slot.variableName) { + if (slot.variableName) { filter.variableName = slot.variableName; } // Only search for levels the class needs if (slot.missingLevels && slot.missingLevels.length) { - filter.level = {$in: slot.missingLevels}; + filter.level = { $in: slot.missingLevels }; } else { - filter.level = (slot.level || 0) + 1; + filter.level = { $gt: slot.level || 0 }; } } let tagsOr = []; let tagsNin = []; - if (slot.slotTags && slot.slotTags.length){ - tagsOr.push({tags: {$all: slot.slotTags}}); + if (slot.slotTags && slot.slotTags.length) { + tagsOr.push({ libraryTags: { $all: slot.slotTags } }); } - if (slot.extraTags && slot.extraTags.length){ + if (slot.extraTags && slot.extraTags.length) { slot.extraTags.forEach(extra => { if (!extra.tags || !extra.tags.length) return; - if (extra.operation === 'OR'){ - tagsOr.push({tags: {$all: extra.tags}}); - } else if (extra.operation === 'NOT'){ + if (extra.operation === 'OR') { + tagsOr.push({ libraryTags: { $all: extra.tags } }); + } else if (extra.operation === 'NOT') { tagsNin.push(...extra.tags); } }); } - if (tagsOr.length){ + if (tagsOr.length) { filter.$or = tagsOr; } - if (tagsNin.length){ - filter.$and.push({tags: {$nin: tagsNin}}); + if (tagsNin.length) { + filter.$and.push({ libraryTags: { $nin: tagsNin } }); } - if (!filter.$and.length){ + if (!filter.$and.length) { delete filter.$and; } return filter; diff --git a/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.test.js b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.test.js new file mode 100644 index 00000000..d7b33cc0 --- /dev/null +++ b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.test.js @@ -0,0 +1,85 @@ +import { assert } from 'chai'; +import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js'; + +describe('Slot fill filter', function () { + + it('Gives error if arguments aren\'t provided', function () { + assert.throws( + () => getSlotFillFilter(undefined), + null, null, 'Passing undefined should give an error' + ); + assert.throws( + () => getSlotFillFilter({ + slot: { slotTags: ['tag1'] }, + }), + null, null, 'Passing no libraryIds should give an error' + ); + assert.throws( + () => getSlotFillFilter({ + libraryIds: ['libraryId1'], + }), + null, null, 'Passing no slot should give an error' + ); + }); + + it('filters using basic slot tags', function () { + const filter = getSlotFillFilter({ + slot: { + slotTags: ['tag1', 'tag2'] + }, + libraryIds: ['libraryId1', 'libraryId2'], + }); + assert.deepStrictEqual(filter, { + $or: [{ + libraryTags: { $all: ['tag1', 'tag2'] } + }], + 'ancestors.id': { $in: ['libraryId1', 'libraryId2'] }, + removed: { $ne: true }, + fillSlots: true, + }); + }); + + it('filters using slot type', function () { + const filter = getSlotFillFilter({ + slot: { + slotTags: ['tag1', 'tag2'], + slotType: 'feature', + }, + libraryIds: ['libraryId1', 'libraryId2'] + }); + assert.deepStrictEqual(filter.$and, [{ + $or: [{ + type: 'feature' + }, { + slotFillerType: 'feature', + }], + }]); + }); + + it('filters using extra tags', function () { + const filter = getSlotFillFilter({ + slot: { + slotTags: ['tag1', 'tag2'], + extraTags: [ + { operation: 'OR', tags: ['tag3', 'tag4'] }, + { operation: 'NOT', tags: ['tag5', 'tag6'] }, + { operation: 'NOT', tags: ['tag7', 'tag8'] }, + ], + }, + libraryIds: ['libraryId1', 'libraryId2'], + }); + assert.deepStrictEqual(filter, { + $or: [ + { libraryTags: { $all: ['tag1', 'tag2'] } }, + { libraryTags: { $all: ['tag3', 'tag4'] } }, + ], + $and: [ + { libraryTags: { $nin: ['tag5', 'tag6', 'tag7', 'tag8'] } }, + ], + 'ancestors.id': { $in: ['libraryId1', 'libraryId2'] }, + removed: { $ne: true }, + fillSlots: true, + }); + }); + +}); \ No newline at end of file diff --git a/app/imports/api/creature/creatureProperties/methods/index.js b/app/imports/api/creature/creatureProperties/methods/index.js index b71515e9..94bedae3 100644 --- a/app/imports/api/creature/creatureProperties/methods/index.js +++ b/app/imports/api/creature/creatureProperties/methods/index.js @@ -1,4 +1,5 @@ import '/imports/api/creature/creatureProperties/methods/adjustQuantity.js'; +import '/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js'; import '/imports/api/creature/creatureProperties/methods/damageProperty.js'; import '/imports/api/creature/creatureProperties/methods/duplicateProperty.js'; import '/imports/api/creature/creatureProperties/methods/equipItem.js'; diff --git a/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js b/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js index 641a9cfb..deb55bc7 100644 --- a/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js +++ b/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js @@ -98,13 +98,14 @@ function insertPropertyFromNode(nodeId, ancestors, order) { removed: { $ne: true }, }).fetch(); - // Convert all references into actual nodes - nodes = reifyNodeReferences(nodes); // The root node is first in the array of nodes // It must get the first generated ID to prevent flickering nodes = [node, ...nodes]; + // Convert all references into actual nodes + nodes = reifyNodeReferences(nodes); + // set libraryNodeIds storeLibraryNodeReferences(nodes); diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js index 5b858b29..62fbf798 100644 --- a/app/imports/api/creature/log/CreatureLogs.js +++ b/app/imports/api/creature/log/CreatureLogs.js @@ -7,9 +7,10 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import { parse, prettifyParseError } from '/imports/parser/parser.js'; import resolve, { toString } from '/imports/parser/resolve.js'; -const PER_CREATURE_LOG_LIMIT = 100; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +const PER_CREATURE_LOG_LIMIT = 100; + if (Meteor.isServer) { var sendWebhookAsCreature = require('/imports/server/discord/sendWebhook.js').sendWebhookAsCreature; } @@ -70,10 +71,21 @@ function logToMessageData(log) { let embed = { fields: [], }; - log.content.forEach(field => { + log.content.forEach((field, index) => { + // Empty character for blank names if (!field.name) field.name = '\u200b'; if (!field.value) field.value = '\u200b'; - embed.fields.push(field); + // Enforce Discord field character limits + if (field.name?.length > 256) { + field.name = field.name.substring(0, 255); + } + if (field.value?.length > 1024) { + field.value = field.value.substring(0, 1024 - 3) + '...'; + } + // Enforce Discord 25 field limit + if (index < 25) { + embed.fields.push(field); + } }); return { embeds: [embed] }; } @@ -122,7 +134,15 @@ export function insertCreatureLogWork({ log, creature, method }) { log = { content: [{ value: log }] }; } if (!log.content?.length) return; + + // Truncate the string lengths to fit the log content schema + log.content.forEach((logItem) => { + if (logItem.value?.length > STORAGE_LIMITS.summary) { + logItem.value = logItem.value.substring(0, STORAGE_LIMITS.summary - 3) + '...'; + } + }); log.date = new Date(); + // Insert it let id = CreatureLogs.insert(log); if (Meteor.isServer) { diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index 021370d4..3551bf0b 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js @@ -8,6 +8,8 @@ import { damagePropertyWork } from '/imports/api/creature/creatureProperties/met import numberToSignedString from '/imports/api/utility/numberToSignedString.js'; import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature.js'; +import { getPropertyDecendants } from '/imports/api/engine/loadCreatures.js'; +import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js'; export default function applyAction(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); @@ -51,11 +53,11 @@ export default function applyAction(node, actionContext) { } function applyAttackWithoutTarget({ attack, actionContext }) { - delete actionContext.scope['$attackHit']; - delete actionContext.scope['$attackMiss']; - delete actionContext.scope['$criticalHit']; - delete actionContext.scope['$criticalMiss']; - delete actionContext.scope['$attackRoll']; + delete actionContext.scope['~attackHit']; + delete actionContext.scope['~attackMiss']; + delete actionContext.scope['~criticalHit']; + delete actionContext.scope['~criticalMiss']; + delete actionContext.scope['~attackRoll']; recalculateCalculation(attack, actionContext); const scope = actionContext.scope; @@ -66,16 +68,16 @@ function applyAttackWithoutTarget({ attack, actionContext }) { criticalMiss, } = rollAttack(attack, scope); let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit'; - if (scope['$attackAdvantage'] === 1) { + if (scope['~attackAdvantage']?.value === 1) { name += ' (Advantage)'; - } else if (scope['$attackAdvantage'] === -1) { + } else if (scope['~attackAdvantage']?.value === -1) { name += ' (Disadvantage)'; } if (!criticalMiss) { - scope['$attackHit'] = { value: true } + scope['~attackHit'] = { value: true } } if (!criticalHit) { - scope['$attackMiss'] = { value: true }; + scope['~attackMiss'] = { value: true }; } actionContext.addLog({ @@ -87,12 +89,12 @@ function applyAttackWithoutTarget({ attack, actionContext }) { function applyAttackToTarget({ attack, target, actionContext }) { const scope = actionContext.scope; - delete scope['$attackHit']; - delete scope['$attackMiss']; - delete scope['$criticalHit']; - delete scope['$criticalMiss']; - delete scope['$attackDiceRoll']; - delete scope['$attackRoll']; + delete scope['~attackHit']; + delete scope['~attackMiss']; + delete scope['~criticalHit']; + delete scope['~criticalMiss']; + delete scope['~attackDiceRoll']; + delete scope['~attackRoll']; recalculateCalculation(attack, actionContext); @@ -109,9 +111,9 @@ function applyAttackToTarget({ attack, target, actionContext }) { let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : result > armor ? 'Hit!' : 'Miss!'; - if (scope['$attackAdvantage'] === 1) { + if (scope['~attackAdvantage']?.value === 1) { name += ' (Advantage)'; - } else if (scope['$attackAdvantage'] === -1) { + } else if (scope['~attackAdvantage']?.value === -1) { name += ' (Disadvantage)'; } @@ -121,9 +123,9 @@ function applyAttackToTarget({ attack, target, actionContext }) { inline: true, }); if (criticalMiss || result < armor) { - scope['$attackMiss'] = { value: true }; + scope['~attackMiss'] = { value: true }; } else { - scope['$attackHit'] = { value: true }; + scope['~attackHit'] = { value: true }; } } else { actionContext.addLog({ @@ -141,7 +143,7 @@ function applyAttackToTarget({ attack, target, actionContext }) { function rollAttack(attack, scope) { const rollModifierText = numberToSignedString(attack.value, true); let value, resultPrefix; - if (scope['$attackAdvantage'] === 1) { + if (scope['~attackAdvantage']?.value === 1) { const [a, b] = rollDice(2, 20); if (a >= b) { value = a; @@ -150,7 +152,7 @@ function rollAttack(attack, scope) { value = b; resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; } - } else if (scope['$attackAdvantage'] === -1) { + } else if (scope['~attackAdvantage']?.value === -1) { const [a, b] = rollDice(2, 20); if (a <= b) { value = a; @@ -163,23 +165,23 @@ function rollAttack(attack, scope) { value = rollDice(1, 20)[0]; resultPrefix = `1d20 [${value}] ${rollModifierText}` } - scope['$attackDiceRoll'] = { value }; + scope['~attackDiceRoll'] = { value }; const result = value + attack.value; - scope['$attackRoll'] = { value: result }; + scope['~attackRoll'] = { value: result }; const { criticalHit, criticalMiss } = applyCrits(value, scope); return { resultPrefix, result, value, criticalHit, criticalMiss }; } function applyCrits(value, scope) { - let criticalHitTarget = scope.criticalHitTarget?.value || 20; + const criticalHitTarget = scope['~criticalHitTarget']?.value || 20; let criticalHit = value >= criticalHitTarget; let criticalMiss; if (criticalHit) { - scope['$criticalHit'] = { value: true }; + scope['~criticalHit'] = { value: true }; } else { criticalMiss = value === 1; if (criticalMiss) { - scope['$criticalMiss'] = { value: true }; + scope['~criticalMiss'] = { value: true }; } } return { criticalHit, criticalMiss }; @@ -211,6 +213,7 @@ function spendResources(prop, actionContext) { let itemQuantityAdjustments = []; let spendLog = []; let gainLog = []; + let ammoChildren = []; try { prop.resources.itemsConsumed.forEach(itemConsumed => { recalculateCalculation(itemConsumed.quantity, actionContext); @@ -221,9 +224,6 @@ function spendResources(prop, actionContext) { if (!item || item.ancestors[0].id !== prop.ancestors[0].id) { throw 'The prop\'s ammo was not found on the creature'; } - if (!item.equipped) { - throw 'The selected ammo is not equipped'; - } if ( !itemConsumed.quantity.value || !isFinite(itemConsumed.quantity.value) @@ -242,6 +242,7 @@ function spendResources(prop, actionContext) { } else if (itemConsumed.quantity.value < 0) { gainLog.push(logName + ': ' + -itemConsumed.quantity.value); } + ammoChildren.push(...getItemChildren(item, actionContext, prop)); }); } catch (e) { actionContext.addLog({ @@ -303,4 +304,36 @@ function spendResources(prop, actionContext) { value: spendLog.join('\n'), inline: true, }); + + // Apply the ammo children + ammoChildren.forEach(prop => { + applyProperty(prop, actionContext); + }); +} + +function getItemChildren(item, actionContext, prop) { + // Skip if the prop or the item are ancestors of one another, otherwise infinite loop + if (hasAncestorRelationship(item, prop)) return []; + // Get the item children + const itemProperties = getPropertyDecendants(actionContext.creature._id, item._id); + // Tree them up + const propertyForest = nodeArrayToTree(itemProperties); + return propertyForest +} + +function hasAncestorRelationship(a, b) { + let top, bottom; + if (a.ancestors.length === b.ancestors.length) { + // Can't be ancestors of one another if they have the same number of ancestors + return false; + } else if (a.ancestors.length > b.ancestors.length) { + // longer ancestor list goes on the bottom + top = b; + bottom = a; + } else { + top = a; + bottom = b; + } + const expectedAncestorPosition = top.ancestors.length; + return bottom.ancestors[expectedAncestorPosition]?.id === top._id; } diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js index ce918c4a..d48ebc01 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js @@ -3,22 +3,22 @@ import recalculateCalculation from './shared/recalculateCalculation.js'; import rollDice from '/imports/parser/rollDice.js'; import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyBranch(node, actionContext){ +export default function applyBranch(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); - const applyChildren = function(){ + const applyChildren = function () { applyNodeTriggers(node, 'after', actionContext); node.children.forEach(child => applyProperty(child, actionContext)); }; const scope = actionContext.scope; const targets = actionContext.targets; const prop = node.node; - switch(prop.branchType){ + switch (prop.branchType) { case 'if': recalculateCalculation(prop.condition, actionContext); if (prop.condition?.value) applyChildren(); break; case 'index': - if (node.children.length){ + if (node.children.length) { recalculateCalculation(prop.condition, actionContext); if (!isFinite(prop.condition?.value)) { actionContext.addLog({ @@ -35,31 +35,31 @@ export default function applyBranch(node, actionContext){ } break; case 'hit': - if (scope['$attackHit']?.value){ - if (!targets.length && !prop.silent) actionContext.addLog({value: '**On hit**'}); + if (scope['~attackHit']?.value) { + if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On hit**' }); applyChildren(); } break; case 'miss': - if (scope['$attackMiss']?.value){ - if (!targets.length && !prop.silent) actionContext.addLog({value: '**On miss**'}); + if (scope['~attackMiss']?.value) { + if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On miss**' }); applyChildren(); } break; case 'failedSave': - if (scope['$saveFailed']?.value){ - if (!targets.length && !prop.silent) actionContext.addLog({value: '**On failed save**'}); + if (scope['~saveFailed']?.value) { + if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On failed save**' }); applyChildren(); } break; case 'successfulSave': - if (scope['$saveSucceeded']?.value){ - if (!targets.length && !prop.silent) actionContext.addLog({value: '**On save**',}); + if (scope['~saveSucceeded']?.value) { + if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On save**', }); applyChildren(); } break; case 'random': - if (node.children.length){ + if (node.children.length) { let index = rollDice(1, node.children.length)[0] - 1; applyNodeTriggers(node, 'after', actionContext); applyProperty(node.children[index], actionContext); diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js index 4ee2cd92..3e48f057 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js @@ -21,7 +21,10 @@ export default function applyBuff(node, actionContext) { const prop = node.node; let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; - // Then copy the decendants of the buff to the targets + // Mark the buff as dirty for recalculation + prop.dirty = true; + + // Then copy the descendants of the buff to the targets let propList = [prop]; function addChildrenToPropList(children, { skipCrystalize } = {}) { children.forEach(child => { diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index 83ad5074..b08e807c 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -27,7 +27,7 @@ export default function applyDamage(node, actionContext) { // Choose target let damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; // Determine if the hit is critical - let criticalHit = scope['$criticalHit']?.value && + let criticalHit = scope['~criticalHit']?.value && prop.damageType !== 'healing' // Can't critically heal ; // Double the damage rolls if the hit is critical @@ -73,12 +73,12 @@ export default function applyDamage(node, actionContext) { damage = Math.floor(damage); // Convert extra damage into the stored type - if (prop.damageType === 'extra' && scope['$lastDamageType']) { - prop.damageType = scope['$lastDamageType']; + if (prop.damageType === 'extra' && scope['~lastDamageType']?.value) { + prop.damageType = scope['~lastDamageType']?.value; } // Store current damage type if (prop.damageType !== 'healing') { - scope['$lastDamageType'] = prop.damageType; + scope['~lastDamageType'] = { value: prop.damageType }; } // Memoise the damage suffix for the log @@ -194,14 +194,18 @@ function dealDamage({ target, damageType, amount, actionContext }) { let healthBars = getPropertiesOfType(target._id, 'attribute'); // Keep only the healthbars that can take damage/healing - remove(healthBars, (bar) => - bar.attributeType !== 'healthBar' || - bar.inactive || - bar.removed || - bar.overridden || - (amount >= 0 && bar.healthBarNoDamage) || - (amount < 0 && bar.healthBarNoHealing) - ); + healthBars = healthBars.filter((bar) => { + if (bar.attributeType !== 'healthBar' || bar.inactive || bar.removed || bar.overridden) { + return false; + } + if (damageType === 'healing' && bar.healthBarNoHealing) { + return false; + } + if (damageType !== 'healing' && amount >= 0 && bar.healthBarNoDamage) { + return false; + } + return true; + }); // Sort healthbars by damage/healing order or tree order as a fallback healthBars.sort((a, b) => { diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js index 7d860948..68892231 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js @@ -4,22 +4,22 @@ import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/appl import resolve, { toString } from '/imports/parser/resolve.js'; import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyRoll(node, actionContext){ +export default function applyRoll(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - const applyChildren = function(){ + const applyChildren = function () { applyNodeTriggers(node, 'after', actionContext); node.children.forEach(child => applyProperty(child, actionContext)); }; - if (prop.roll?.calculation){ + if (prop.roll?.calculation) { const logValue = []; // roll the dice only and store that string applyEffectsToCalculationParseNode(prop.roll, actionContext); - const {result: rolled, context} = resolve('roll', prop.roll.parseNode, actionContext.scope); - if (rolled.parseType !== 'constant'){ + const { result: rolled, context } = resolve('roll', prop.roll.parseNode, actionContext.scope); + if (rolled.parseType !== 'constant') { logValue.push(toString(rolled)); } logErrors(context.errors, actionContext); @@ -28,28 +28,28 @@ export default function applyRoll(node, actionContext){ context.errors = []; // Resolve the roll to a final value - const {result: reduced} = resolve('reduce', rolled, actionContext.scope, context); + const { result: reduced } = resolve('reduce', rolled, actionContext.scope, context); logErrors(context.errors, actionContext); // Store the result - if (reduced.parseType === 'constant'){ + if (reduced.parseType === 'constant') { prop.roll.value = reduced.value; - } else if (reduced.parseType === 'error'){ + } else if (reduced.parseType === 'error') { prop.roll.value = null; } else { prop.roll.value = toString(reduced); } - // If we didn't end up with a constant of finite amount, give up - if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)){ + // If we didn't end up with a constant or a number of finite value, give up + if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) { return applyChildren(); } const value = reduced.value; - actionContext.scope[prop.variableName] = value; + actionContext.scope[prop.variableName] = { value }; logValue.push(`**${value}**`); - if (!prop.silent){ + if (!prop.silent) { actionContext.addLog({ name: prop.name, value: logValue.join('\n'), diff --git a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js index ca760d7a..e0803109 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js @@ -8,6 +8,7 @@ import { applyUnresolvedEffects } from '/imports/api/engine/actions/doCheck.js'; export default function applySavingThrow(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); const prop = node.node; + const originalTargets = actionContext.targets; let saveTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; @@ -31,22 +32,22 @@ export default function applySavingThrow(node, actionContext) { // If there are no save targets, apply all children as if the save both // succeeeded and failed if (!saveTargets?.length) { - scope['$saveFailed'] = { value: true }; - scope['$saveSucceeded'] = { value: true }; + scope['~saveFailed'] = { value: true }; + scope['~saveSucceeded'] = { value: true }; applyNodeTriggers(node, 'after', actionContext); return node.children.forEach(child => applyProperty(child, actionContext)); } // Each target makes the saving throw saveTargets.forEach(target => { - delete scope['$saveFailed']; - delete scope['$saveSucceeded']; - delete scope['$saveDiceRoll']; - delete scope['$saveRoll']; + delete scope['~saveFailed']; + delete scope['~saveSucceeded']; + delete scope['~saveDiceRoll']; + delete scope['~saveRoll']; const applyChildren = function () { - applyNodeTriggers(node, 'after', actionContext); actionContext.targets = [target] + applyNodeTriggers(node, 'after', actionContext); node.children.forEach(child => applyProperty(child, actionContext)); }; @@ -90,14 +91,14 @@ export default function applySavingThrow(node, actionContext) { value = values[0]; resultPrefix = `1d20 [ ${value} ] ${rollModifierText}` } - scope['$saveDiceRoll'] = { value }; + scope['~saveDiceRoll'] = { value }; const result = value + rollModifier || 0; - scope['$saveRoll'] = { value: result }; + scope['~saveRoll'] = { value: result }; const saveSuccess = result >= dc; if (saveSuccess) { - scope['$saveSucceeded'] = { value: true }; + scope['~saveSucceeded'] = { value: true }; } else { - scope['$saveFailed'] = { value: true }; + scope['~saveFailed'] = { value: true }; } if (!prop.silent) actionContext.addLog({ name: saveSuccess ? 'Successful save' : 'Failed save', @@ -106,4 +107,6 @@ export default function applySavingThrow(node, actionContext) { }); return applyChildren(); }); + // reset the targets after the save to each child + actionContext.targets = originalTargets; } diff --git a/app/imports/api/engine/actions/applyTriggers.js b/app/imports/api/engine/actions/applyTriggers.js index 8fd6a536..cbaf2108 100644 --- a/app/imports/api/engine/actions/applyTriggers.js +++ b/app/imports/api/engine/actions/applyTriggers.js @@ -35,7 +35,7 @@ export function applyTrigger(trigger, prop, actionContext) { if (trigger.inactive) { return; } - + // Prevent triggers from firing if their condition is false if (trigger.condition?.parseNode) { recalculateCalculation(trigger.condition, actionContext); @@ -61,11 +61,11 @@ export function applyTrigger(trigger, prop, actionContext) { value: trigger.description, inline: false, } - if (trigger.description?.text){ + if (trigger.description?.text) { recalculateInlineCalculations(trigger.description, actionContext); content.value = trigger.description.value; } - if(!trigger.silent) actionContext.addLog(content); + if (!trigger.silent) actionContext.addLog(content); // Get all the trigger's properties and apply them const properties = getPropertyDecendants(actionContext.creature._id, trigger._id); @@ -78,7 +78,7 @@ export function applyTrigger(trigger, prop, actionContext) { trigger.firing = false; } -function triggerMatchTags(trigger, prop) { +export function triggerMatchTags(trigger, prop) { let matched = false; const propTags = getEffectivePropTags(prop); // Check the target tags @@ -89,23 +89,26 @@ function triggerMatchTags(trigger, prop) { matched = true; } // Check the extra tags - trigger.extraTags?.forEach(extra => { - if (extra.operation === 'OR') { - if (matched) return; - if ( - !extra.tags.length || - difference(extra.tags, propTags).length === 0 - ) { - matched = true; - } - } else if (extra.operation === 'NOT') { - if ( - extra.tags.length && - intersection(extra.tags, propTags) - ) { - return false; + if (trigger.extraTags) { + for (const extra of trigger.extraTags) { + if (extra.operation === 'OR') { + if (matched) break; + if ( + !extra.tags.length || + difference(extra.tags, propTags).length === 0 + ) { + matched = true; + } + } else if (extra.operation === 'NOT') { + if ( + extra.tags.length && + intersection(extra.tags, propTags).length > 0 + ) { + matched = false; + break; + } } } - }); + } return matched; } diff --git a/app/imports/api/engine/actions/applyTriggers.testFn.js b/app/imports/api/engine/actions/applyTriggers.testFn.js new file mode 100644 index 00000000..f2955afb --- /dev/null +++ b/app/imports/api/engine/actions/applyTriggers.testFn.js @@ -0,0 +1,67 @@ +import { triggerMatchTags } from '/imports/api/engine/actions/applyTriggers.js'; +import clean from '/imports/api/engine/computation/utility/cleanProp.testFn.js'; +import { assert } from 'chai'; + +export default function () { + const prop = clean({ + id: 'propWithTags', + type: 'action', + tags: ['yes1', 'notUsed', 'no1', 'yes2', 'no2', 'or1', 'or2'], + }); + const positiveProp = clean({ + id: 'propWithTags', + type: 'action', + tags: ['yes1', 'notUsed', 'yes2', 'or1', 'or2'], + }); + assert.isTrue( + triggerMatchTags(clean({ + type: 'trigger', + targetTags: ['yes1'], + }), prop), + 'Trigger matches on a single target tag' + ); + assert.isTrue( + triggerMatchTags(clean({ + type: 'trigger', + targetTags: ['yes1', 'yes2'], + }), prop), + 'Trigger matches on a multiple target tags' + ); + assert.isFalse( + triggerMatchTags(clean({ + type: 'trigger', + targetTags: ['yes1'], + extraTags: [{ operation: 'NOT', tags: ['no1'] }] + }), prop), + 'Trigger correctly fails to match when not tags are present' + ); + assert.isFalse( + triggerMatchTags(clean({ + type: 'trigger', + extraTags: [{ operation: 'NOT', tags: ['no1'] }] + }), prop), + 'Trigger correctly fails to match when only not tags are present' + ); + assert.isTrue( + triggerMatchTags(clean({ + type: 'trigger', + extraTags: [{ operation: 'NOT', tags: ['no1'] }] + }), positiveProp), + 'Trigger matches when only not tags are present' + ); + assert.isTrue( + triggerMatchTags(clean({ + type: 'trigger', + extraTags: [{ operation: 'OR', tags: ['or1'] }] + }), positiveProp), + 'Trigger matches when OR tags are present' + ); + assert.isTrue( + triggerMatchTags(clean({ + type: 'trigger', + targetTags: ['missing1'], + extraTags: [{ operation: 'OR', tags: ['or1'] }] + }), positiveProp), + 'Trigger matches when only OR tags are present' + ); +} \ No newline at end of file diff --git a/app/imports/api/engine/actions/doAction.test.js b/app/imports/api/engine/actions/doAction.test.js index 56c365cb..b9fdfa23 100644 --- a/app/imports/api/engine/actions/doAction.test.js +++ b/app/imports/api/engine/actions/doAction.test.js @@ -1,16 +1,17 @@ import '/imports/api/simpleSchemaConfig.js'; //import testTypes from './testTypes/index.js'; +import applyTriggers from '/imports/api/engine/actions/applyTriggers.testFn.js'; import { doActionWork } from './doAction.js'; import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; -function cleanProp(prop){ +function cleanProp(prop) { let schema = CreatureProperties.simpleSchema(prop); return schema.clean(prop); } -function cleanCreature(creature){ +function cleanCreature(creature) { let schema = Creatures.simpleSchema(creature); return schema.clean(creature); } @@ -28,7 +29,7 @@ const testActionContext = { }), scope: {}, addLog(content) { - if (content.name || content.value){ + if (content.name || content.value) { this.log.content.push(content); } }, @@ -40,8 +41,8 @@ const action = cleanProp({ }); const actionAncestors = []; -describe('Do Action', function(){ - it('Does an empty action', function(){ +describe('Do Action', function () { + it('Does an empty action', function () { doActionWork({ properties: [action], ancestors: actionAncestors, @@ -51,3 +52,7 @@ describe('Do Action', function(){ }); //testTypes.forEach(test => it(test.text, test.fn)); }); + +describe('Action utility functions', function () { + it('Triggers match tags', applyTriggers); +}) diff --git a/app/imports/api/engine/actions/doCastSpell.js b/app/imports/api/engine/actions/doCastSpell.js index f512e4ed..4ea03630 100644 --- a/app/imports/api/engine/actions/doCastSpell.js +++ b/app/imports/api/engine/actions/doCastSpell.js @@ -117,7 +117,8 @@ const doAction = new ValidatedMethod({ } } - actionContext.scope['slotLevel'] = slotLevel; + actionContext.scope['slotLevel'] = { value: slotLevel }; + actionContext.scope['~slotLevel'] = { value: slotLevel }; // Do the action doActionWork({ diff --git a/app/imports/api/engine/actions/doCheck.js b/app/imports/api/engine/actions/doCheck.js index 5d9b4164..452c32eb 100644 --- a/app/imports/api/engine/actions/doCheck.js +++ b/app/imports/api/engine/actions/doCheck.js @@ -81,7 +81,7 @@ function rollCheck(prop, actionContext) { rollModifier += effectBonus; let value, values, resultPrefix; - if (scope['$checkAdvantage'] === 1) { + if (scope['~checkAdvantage']?.value === 1) { logName += ' (Advantage)'; const [a, b] = rollDice(2, 20); if (a >= b) { @@ -91,7 +91,7 @@ function rollCheck(prop, actionContext) { value = b; resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `; } - } else if (scope['$checkAdvantage'] === -1) { + } else if (scope['~checkAdvantage']?.value === -1) { logName += ' (Disadvantage)'; const [a, b] = rollDice(2, 20); if (a <= b) { @@ -107,9 +107,9 @@ function rollCheck(prop, actionContext) { resultPrefix = `1d20 [ ${value} ] ${rollModifierText} = ` } const result = (value + rollModifier) || 0; - scope['$checkDiceRoll'] = value; - scope['$checkRoll'] = result; - scope['$checkModifier'] = rollModifier; + scope['~checkDiceRoll'] = { value }; + scope['~checkRoll'] = { value: result }; + scope['~checkModifier'] = { value: rollModifier }; actionContext.addLog({ name: logName, value: `${resultPrefix} **${result}**`, diff --git a/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js b/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js index e3adabc8..e9e4a287 100644 --- a/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js +++ b/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js @@ -2,7 +2,7 @@ * Only computes `totalFilled`, need to compute `quantityExpected.value` * before `spacesLeft` can be computed */ -export default function computeSlotQuantityFilled(node, dependencyGraph){ +export default function computeSlotQuantityFilled(node, dependencyGraph) { let slot = node.node; if (slot.type !== 'propertySlot') return; slot.totalFilled = 0; @@ -10,9 +10,8 @@ export default function computeSlotQuantityFilled(node, dependencyGraph){ let childProp = child.node; dependencyGraph.addLink(slot._id, childProp._id, 'slotFill'); if ( - childProp.type === 'slotFiller' && Number.isFinite(childProp.slotQuantityFilled) - ){ + ) { slot.totalFilled += childProp.slotQuantityFilled; } else { slot.totalFilled++; diff --git a/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js index b7a98595..3d9bf564 100644 --- a/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js +++ b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js @@ -1,16 +1,31 @@ import walkDown from '/imports/api/engine/computation/utility/walkdown.js'; +import { getEffectTagTargets } from '/imports/api/engine/computation/buildComputation/linkTypeDependencies.js'; -export default function computeToggleDependencies(node, dependencyGraph){ +export default function computeToggleDependencies(node, dependencyGraph, computation, forest) { const prop = node.node; - // Only for toggles that aren't inactive and aren't set to enabled or disabled - if ( - prop.inactive || - prop.type !== 'toggle' || - prop.disabled || - prop.enabled - ) return; + // Only for toggles + if (prop.type !== 'toggle') return; + + if (prop.targetByTags) { + // Find all the props targeted by tags, and disable them and their children + getEffectTagTargets(prop, computation).forEach(targetId => { + const target = forest.nodeIndex[targetId]; + if (!target) return; + target.node._computationDetails.toggleAncestors.push(prop); + dependencyGraph.addLink(target.node._id, prop._id, 'toggle'); + walkDown(target.children, child => { + // The child nodes depend on the toggle + child.node._computationDetails.toggleAncestors.push(prop); + dependencyGraph.addLink(child.node._id, prop._id, 'toggle'); + }); + }); + } + + // We don't need to link direct children of static toggles, it's already done + if (prop.disabled || prop.enabled) return; + walkDown(node.children, child => { - // The child nodes depend on the toggle condition compuation + // The child nodes depend on the toggle child.node._computationDetails.toggleAncestors.push(prop); dependencyGraph.addLink(child.node._id, prop._id, 'toggle'); }); diff --git a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js index 7ed69d19..0a856386 100644 --- a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js +++ b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js @@ -164,7 +164,7 @@ function linkEffects(dependencyGraph, prop, computation) { } // Returns an array of IDs of the properties the effect targets -function getEffectTagTargets(effect, computation) { +export function getEffectTagTargets(effect, computation) { let targets = getTargetListFromTags(effect.targetTags, computation); let notIds = []; if (effect.extraTags) { @@ -218,7 +218,6 @@ function getDefaultCalculationField(prop) { case 'roll': return 'roll'; case 'savingThrow': return 'dc'; case 'skill': return 'baseValue'; - case 'slotFiller': return null; case 'slot': return 'quantityExpected'; case 'spellList': return 'attackRollBonus'; case 'spell': return null; @@ -268,20 +267,45 @@ function linkPointBuy(dependencyGraph, prop) { if (prop.inactive) return; } -function linkProficiencies(dependencyGraph, prop) { +function linkProficiencies(dependencyGraph, prop, computation) { // The stats depend on the proficiency if (prop.inactive) return; - prop.stats.forEach(statName => { - if (!statName) return; - dependencyGraph.addLink(statName, prop._id, prop.type); - }); + if (prop.targetByTags) { + // Tag targeted proficiencies depend on the creature's proficiencyBonus, + // since they add it directly to the targeted field + dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus'); + getEffectTagTargets(prop, computation).forEach(targetId => { + const targetProp = computation.propsById[targetId]; + if ( + (targetProp.type === 'attribute' || targetProp.type === 'skill') + && targetProp.variableName + && !prop.targetField + ) { + // If the field wasn't specified and we're targeting an attribute or + // skill, just treat it like a normal proficiency on its variable name + dependencyGraph.addLink(targetProp.variableName, prop._id, 'proficiency'); + } else { + // Otherwise target a field on that property + const key = prop.targetField || getDefaultCalculationField(targetProp); + const calcObj = get(targetProp, key); + if (calcObj && calcObj.calculation) { + dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency'); + } + } + }); + } else { + prop.stats.forEach(statName => { + if (!statName) return; + dependencyGraph.addLink(statName, prop._id, 'proficiency'); + }); + } } function linkSavingThrow(dependencyGraph, prop) { dependOnCalc({ dependencyGraph, prop, key: 'dc' }); } -function linkSkill(dependencyGraph, prop) { +function linkSkill(dependencyGraph, prop, computation) { // Depends on base value dependOnCalc({ dependencyGraph, prop, key: 'baseValue' }); // Link dependents @@ -293,6 +317,20 @@ function linkSkill(dependencyGraph, prop) { } // Skills depend on the creature's proficiencyBonus dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus'); + + // Skills can apply their value as a proficiency bonus to calculations based on tag + if (prop.targetByTags) { + getEffectTagTargets(prop, computation).forEach(targetId => { + const targetProp = computation.propsById[targetId]; + // Always target a field on the target property, applying a skill to an attribute or + // other skill isn't supported + const key = prop.targetField || getDefaultCalculationField(targetProp); + const calcObj = get(targetProp, key); + if (calcObj && calcObj.calculation) { + dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency'); + } + }); + } } function linkSlot(dependencyGraph, prop) { diff --git a/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js b/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js index 3de657fe..73217f80 100644 --- a/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js +++ b/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js @@ -5,14 +5,14 @@ import { get, unset } from 'lodash'; import errorNode from '/imports/parser/parseTree/error.js'; import cyrb53 from '/imports/api/engine/computation/utility/cyrb53.js'; -export default function parseCalculationFields(prop, schemas){ +export default function parseCalculationFields(prop, schemas) { discoverInlineCalculationFields(prop, schemas); parseAllCalculationFields(prop, schemas); } -function discoverInlineCalculationFields(prop, schemas){ +function discoverInlineCalculationFields(prop, schemas) { // For each key in the schema - schemas[prop.type].inlineCalculationFields().forEach( calcKey => { + schemas[prop.type]?.inlineCalculationFields?.()?.forEach(calcKey => { // That ends in .inlineCalculations applyFnToKey(prop, calcKey, (prop, key) => { const inlineCalcObj = get(prop, key); @@ -22,7 +22,7 @@ function discoverInlineCalculationFields(prop, schemas){ // Extract the calculations and store them on the property let string = inlineCalcObj.text; // If there is no text, delete the whole field - if (!string){ + if (!string) { unset(prop, calcKey); return; } @@ -32,7 +32,7 @@ function discoverInlineCalculationFields(prop, schemas){ // Has the text, if it matches the existing hash, stop const inlineCalcHash = cyrb53(inlineCalcObj.text); - if (inlineCalcHash === inlineCalcObj.hash){ + if (inlineCalcHash === inlineCalcObj.hash) { return; } inlineCalcObj.hash = inlineCalcHash; @@ -41,7 +41,7 @@ function discoverInlineCalculationFields(prop, schemas){ // It will be re set including the embedded calculation at the end of // the computation let matches = string.matchAll(INLINE_CALCULATION_REGEX); - for (let match of matches){ + for (let match of matches) { let calculation = match[1]; inlineCalcObj.inlineCalculations.push({ calculation, @@ -51,9 +51,9 @@ function discoverInlineCalculationFields(prop, schemas){ }); } -function parseAllCalculationFields(prop, schemas){ +function parseAllCalculationFields(prop, schemas) { // For each computed key in the schema - schemas[prop.type].computedFields().forEach( calcKey => { + schemas[prop.type]?.computedFields?.()?.forEach(calcKey => { // Determine the level the calculation should compute down to let parseLevel = schemas[prop.type].getDefinition(calcKey).parseLevel || 'reduce'; @@ -66,7 +66,7 @@ function parseAllCalculationFields(prop, schemas){ const calcObj = get(prop, key); if (!calcObj) return; // Delete the whole calculation object if the calculation string isn't set - if (!calcObj.calculation){ + if (!calcObj.calculation) { unset(prop, calcKey); return; } @@ -84,10 +84,10 @@ function parseAllCalculationFields(prop, schemas){ }); } -function parseCalculation(calcObj){ +function parseCalculation(calcObj) { const calcHash = cyrb53(calcObj.calculation); // If the cached parse calculation is equal to the calculation, skip - if (calcHash === calcObj.hash){ + if (calcHash === calcObj.hash) { return; } calcObj.hash = calcHash; @@ -100,6 +100,6 @@ function parseCalculation(calcObj){ message: prettifyParseError(e), }; calcObj.parseError = error; - calcObj.parseNode = errorNode.create({error}); + calcObj.parseNode = errorNode.create({ error }); } } diff --git a/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js b/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js index 9c783df0..d87fe3b3 100644 --- a/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js +++ b/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js @@ -1,9 +1,9 @@ import applyFnToKey from '../utility/applyFnToKey.js'; import { unset } from 'lodash'; -export default function removeSchemaFields(schemas, prop){ +export default function removeSchemaFields(schemas, prop) { schemas.forEach(schema => { - schema.removeBeforeComputeFields().forEach( + schema?.removeBeforeComputeFields?.().forEach( key => applyFnToKey(prop, key, unset) ); }); diff --git a/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js index 8a8af817..b2c80f9a 100644 --- a/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js +++ b/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js @@ -2,7 +2,7 @@ import { buildComputationFromProps } from '/imports/api/engine/computation/build import { assert } from 'chai'; import clean from '../../utility/cleanProp.testFn.js'; -export default function(){ +export default function () { const computation = buildComputationFromProps(testProperties); const totalFilled = computation.propsById['slotId'].totalFilled; assert.equal(totalFilled, 4); @@ -13,24 +13,24 @@ var testProperties = [ clean({ _id: 'slotId', type: 'propertySlot', - ancestors: [{id: 'charId'}], + ancestors: [{ id: 'charId' }], }), // Children clean({ _id: 'slotFillerId', - type: 'slotFiller', + type: 'folder', slotQuantityFilled: 3, slotFillerType: 'item', - ancestors: [{id: 'charId'}, {id: 'slotId'}], + ancestors: [{ id: 'charId' }, { id: 'slotId' }], }), clean({ _id: 'slotChildId', type: 'item', - ancestors: [{id: 'charId'}, {id: 'slotId'}], + ancestors: [{ id: 'charId' }, { id: 'slotId' }], }), clean({ _id: 'slotGrandchildId', type: 'effect', - ancestors: [{id: 'charId'}, {id: 'slotId'}, {id: 'slotChildId'}], + ancestors: [{ id: 'charId' }, { id: 'slotId' }, { id: 'slotChildId' }], }), ]; diff --git a/app/imports/api/engine/computation/buildCreatureComputation.js b/app/imports/api/engine/computation/buildCreatureComputation.js index a736354c..a97f5b0a 100644 --- a/app/imports/api/engine/computation/buildCreatureComputation.js +++ b/app/imports/api/engine/computation/buildCreatureComputation.js @@ -29,7 +29,7 @@ import removeSchemaFields from './buildComputation/removeSchemaFields.js'; * computed toggles */ -export default function buildCreatureComputation(creatureId){ +export default function buildCreatureComputation(creatureId) { const creature = getCreature(creatureId); const variables = getVariables(creatureId); const properties = getProperties(creatureId); @@ -37,7 +37,7 @@ export default function buildCreatureComputation(creatureId){ return computation; } -export function buildComputationFromProps(properties, creature, variables){ +export function buildComputationFromProps(properties, creature, variables) { const computation = new CreatureComputation(properties, creature, variables); // Dependency graph where edge(a, b) means a depends on b @@ -49,14 +49,14 @@ export function buildComputationFromProps(properties, creature, variables){ const dependencyGraph = computation.dependencyGraph; // Link the denormalizedStats from the creature - if (creature && creature.denormalizedStats){ - if (creature.denormalizedStats.xp){ + if (creature && creature.denormalizedStats) { + if (creature.denormalizedStats.xp) { dependencyGraph.addNode('xp', { baseValue: creature.denormalizedStats.xp, type: '_variable' }); } - if (creature.denormalizedStats.milestoneLevels){ + if (creature.denormalizedStats.milestoneLevels) { dependencyGraph.addNode('milestoneLevels', { baseValue: creature.denormalizedStats.milestoneLevels, type: '_variable' @@ -93,7 +93,7 @@ export function buildComputationFromProps(properties, creature, variables){ // Inactive status must be complete for the whole tree before toggle deps // are calculated walkDown(forest, node => { - computeToggleDependencies(node, dependencyGraph); + computeToggleDependencies(node, dependencyGraph, computation, forest); computeSlotQuantityFilled(node, dependencyGraph); }); diff --git a/app/imports/api/engine/computation/computeComputation/computeByType.js b/app/imports/api/engine/computation/computeComputation/computeByType.js index 0b5633e3..0f3d5436 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType.js @@ -6,6 +6,7 @@ import pointBuy from './computeByType/computePointBuy.js'; import propertySlot from './computeByType/computeSlot.js'; import container from './computeByType/computeContainer.js'; import spellList from './computeByType/computeSpellList.js'; +import toggle from './computeByType/computeToggle.js'; import _calculation from './computeByType/computeCalculation.js'; export default Object.freeze({ @@ -19,4 +20,5 @@ export default Object.freeze({ propertySlot, spell: action, spellList, + toggle, }); diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js index 054fc7f9..f695b25d 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js @@ -1,12 +1,17 @@ import evaluateCalculation from '../../utility/evaluateCalculation.js'; +import { getPropertyName } from '/imports/constants/PROPERTIES.js'; -export default function computeCalculation(computation, node){ +export default function computeCalculation(computation, node) { const calcObj = node.data; evaluateCalculation(calcObj, computation.scope); + if (calcObj.effects || calcObj.proficiencies) { + calcObj.baseValue = calcObj.value; + } aggregateCalculationEffects(node, computation); + aggregateCalculationProficiencies(node, computation); } -export function aggregateCalculationEffects(node, computation){ +function aggregateCalculationEffects(node, computation) { const calcObj = node.data; delete calcObj.effects; computation.dependencyGraph.forEachLinkedNode( @@ -34,15 +39,77 @@ export function aggregateCalculationEffects(node, computation){ }, true // enumerate only outbound links ); - if (calcObj.effects && typeof calcObj.value === 'number'){ - calcObj.baseValue = calcObj.value; + if (calcObj.effects && typeof calcObj.value === 'number') { calcObj.effects.forEach(effect => { if ( effect.operation === 'add' && effect.amount && typeof effect.amount.value === 'number' - ){ + ) { calcObj.value += effect.amount.value } }); } } + +function aggregateCalculationProficiencies(node, computation) { + const calcObj = node.data; + delete calcObj.proficiencies; + delete calcObj.proficiency; + let profBonus = computation.scope['proficiencyBonus']?.value || 0; + + // Go through all the links and collect them on the calculation + computation.dependencyGraph.forEachLinkedNode( + node.id, + (linkedNode, link) => { + // Only proficiency links + if (link.data !== 'proficiency') return; + // That have data + if (!linkedNode.data) return; + // Ignoring inactive props + if (linkedNode.data.inactive) return; + // Compute the proficiency and value + let proficiency, value; + if (linkedNode.data.type === 'proficiency') { + proficiency = linkedNode.data.value || 0; + // Multiply the proficiency bonus by the actual proficiency + if (proficiency === 0.49) { + // Round down proficiency bonus in the special case + value = Math.floor(profBonus * 0.5); + } else { + value = Math.ceil(profBonus * proficiency); + } + } else if (linkedNode.data.type === 'skill') { + value = linkedNode.data.value || 0; + proficiency = linkedNode.data.proficiency || 0; + } + // Collate proficiencies + calcObj.proficiencies = calcObj.proficiencies || []; + calcObj.proficiencies.push({ + _id: linkedNode.data._id, + name: linkedNode.data.name, + type: linkedNode.data.type, + proficiency, + value, + }); + }, + true // enumerate only outbound links + ); + + // Apply the highest proficiency, marking all others as overridden + if (calcObj.proficiencies && typeof calcObj.value === 'number') { + calcObj.proficiency = 0; + calcObj.proficiencyBonus = 0; + let currentProf; + calcObj.proficiencies.forEach(prof => { + if (prof.value > calcObj.proficiencyBonus) { + if (currentProf) currentProf.overridden = true; + calcObj.proficiencyBonus = prof.value; + calcObj.proficiency = prof.proficiency; + currentProf = prof; + } else { + prof.overridden = true; + } + }); + calcObj.value += calcObj.proficiencyBonus; + } +} diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js index 9e9dc07b..8128719a 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js @@ -1,6 +1,6 @@ -export default function computSlot(computation, node){ +export default function computeSlot(computation, node) { const prop = node.data; - if (prop.quantityExpected && prop.quantityExpected.value){ + if (prop.quantityExpected && prop.quantityExpected.value) { prop.spaceLeft = prop.quantityExpected.value - prop.totalFilled; } } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeToggle.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeToggle.js new file mode 100644 index 00000000..bba5c4ff --- /dev/null +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeToggle.js @@ -0,0 +1,7 @@ +export default function computeToggle(computation, node) { + const prop = node.data; + if (!prop.enabled && !prop.disabled && prop.condition && !prop.condition.value) { + prop.inactive = true; + prop.deactivatedBySelf = true; + } +} diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js index 6ec5df2d..395619a9 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js @@ -1,16 +1,40 @@ import getAggregatorResult from './getAggregatorResult.js'; -export default function computeVariableAsAttribute(computation, node, prop){ +export default function computeVariableAsAttribute(computation, node, prop) { let result = getAggregatorResult(node) || 0; prop.total = result; + + // Apply damage in a way that respects the damage rules, modifying damage if need be + // Bound the damage + if (!prop.ignoreLowerLimit && prop.damage > prop.total) { + console.log(`reducing damage from ${prop.damage} to ${prop.total}`); + prop.damage = prop.total; + } + if (!prop.ignoreUpperLimit && prop.damage < 0) { + console.log(`increasing damage from ${prop.damage} to 0`); + prop.damage = 0; + } + // Apply damage prop.value = prop.total - (prop.damage || 0); // Proficiency prop.proficiency = node.data.proficiency; + // Advantage/disadvantage + const aggregator = node.data.effectAggregator; + if (aggregator) { + if (aggregator.advantage && !aggregator.disadvantage) { + prop.advantage = 1; + } else if (aggregator.disadvantage && !aggregator.advantage) { + prop.advantage = -1; + } else { + prop.advantage = 0; + } + } + // Ability scores get modifiers - if (prop.attributeType === 'ability'){ + if (prop.attributeType === 'ability') { prop.modifier = Math.floor((prop.value - 10) / 2); } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js index 01d34101..336a5ab7 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js @@ -1,12 +1,12 @@ import aggregate from './aggregate/index.js'; -export default function computeVariableAsSkill(computation, node, prop){ +export default function computeVariableAsSkill(computation, node, prop) { // Skills are based on some ability Modifier let ability = computation.scope[prop.ability]; prop.abilityMod = ability?.modifier || 0; // Inherit the ability's skill effects and proficiencies if skill is not a save - if (prop.skillType !== 'save' && ability){ + if (prop.skillType !== 'save' && ability) { aggregateAbilityEffects({ computation, skillNode: node, @@ -21,7 +21,7 @@ export default function computeVariableAsSkill(computation, node, prop){ let profBonus = computation.scope['proficiencyBonus']?.value || 0; // Multiply the proficiency bonus by the actual proficiency - if(prop.proficiency === 0.49){ + if (prop.proficiency === 0.49) { // Round down proficiency bonus in the special case profBonus = Math.floor(profBonus * 0.5); } else { @@ -37,7 +37,7 @@ export default function computeVariableAsSkill(computation, node, prop){ prop.effects = node.data.effects; // If there is no aggregator, determine if the prop can hide, then exit - if (!aggregator){ + if (!aggregator) { prop.hide = statBase === undefined && prop.proficiency == 0 || undefined; @@ -52,20 +52,32 @@ export default function computeVariableAsSkill(computation, node, prop){ if (aggregator.set !== undefined) { result = aggregator.set; } - if (Number.isFinite(result)){ + if (Number.isFinite(result)) { result = Math.floor(result); } prop.value = result; // Advantage/disadvantage - if (aggregator.advantage && !aggregator.disadvantage){ + if (aggregator.advantage && !aggregator.disadvantage) { prop.advantage = 1; - } else if (aggregator.disadvantage && !aggregator.advantage){ + } else if (aggregator.disadvantage && !aggregator.advantage) { prop.advantage = -1; } else { prop.advantage = 0; } // Passive bonus prop.passiveBonus = aggregator.passiveAdd; + // +/- 5 to passive bonus if the skill has advantage/disadvantage + if ( + prop.advantage === 1 + && Number.isFinite(prop.passiveBonus) + ) { + prop.passiveBonus += 5; + } else if ( + prop.advantage === -1 + && Number.isFinite(prop.passiveBonus) + ) { + prop.bassiveBonus -= 5; + } // conditional benefits prop.conditionalBenefits = aggregator.conditional; // Roll bonuses @@ -76,7 +88,8 @@ export default function computeVariableAsSkill(computation, node, prop){ prop.rollBonuses = aggregator.rollBonus; } -function aggregateAbilityEffects({computation, skillNode, abilityNode}){ +function aggregateAbilityEffects({ computation, skillNode, abilityNode }) { + if (!abilityNode?.id) return; computation.dependencyGraph.forEachLinkedNode( abilityNode.id, (linkedNode, link) => { @@ -85,15 +98,15 @@ function aggregateAbilityEffects({computation, skillNode, abilityNode}){ if (linkedNode.data.inactive) return; // Check that the link is a valid effect/proficiency to pass on // to a skill from its ability - if (link.data === 'effect'){ + if (link.data === 'effect') { if (![ 'advantage', 'disadvantage', 'passiveAdd', 'fail', 'conditional' - ].includes(linkedNode.data.operation)){ + ].includes(linkedNode.data.operation)) { return; } } // Apply the aggregations - let arg = {node: skillNode, linkedNode, link}; + let arg = { node: skillNode, linkedNode, link }; aggregate.effect(arg); aggregate.proficiency(arg); }, diff --git a/app/imports/api/engine/computation/computeComputation/computeToggles.js b/app/imports/api/engine/computation/computeComputation/computeToggles.js index caa310d2..657305d6 100644 --- a/app/imports/api/engine/computation/computeComputation/computeToggles.js +++ b/app/imports/api/engine/computation/computeComputation/computeToggles.js @@ -1,13 +1,16 @@ -export default function evaluateToggles(computation, node){ +export default function evaluateToggles(computation, node) { let prop = node.data; if (!prop) return; let toggles = prop._computationDetails?.toggleAncestors; if (!toggles) return; toggles.forEach(toggle => { - if (!toggle.condition) return; - if (!toggle.condition.value){ + if ( + (!toggle.enabled && !toggle.disabled && toggle.condition && !toggle.condition.value) + || (toggle.disabled) + ) { prop.inactive = true; prop.deactivatedByToggle = true; + prop.deactivatingToggleId = toggle._id; } }); } diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeProficiencies.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeProficiencies.testFn.js new file mode 100644 index 00000000..b61395da --- /dev/null +++ b/app/imports/api/engine/computation/computeComputation/tests/computeProficiencies.testFn.js @@ -0,0 +1,64 @@ +import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js'; +import { assert } from 'chai'; +import computeCreatureComputation from '../../computeCreatureComputation.js'; +import clean from '../../utility/cleanProp.testFn.js'; + +export default function () { + const computation = buildComputationFromProps(testProperties); + computeCreatureComputation(computation); + const prop = id => computation.propsById[id]; + assert.equal( + prop('strengthId').value, 8, + 'The proficiency bonus should not change the strength score' + ); + assert.equal( + prop('strengthId').modifier, -1, + 'The proficiency bonus should not change the strength modifier' + ); + assert.exists(prop('actionId').attackRoll.proficiencies, 'The proficiency aggregator should be here') + assert.exists(prop('actionId').attackRoll.proficiencies[0], 'The proficiency should be here') + // attack roll = strength.mod + proficiencyBonus/2 rounded down + // = -1 + 13/2 = -1 + 6 = 5 + assert.equal( + prop('actionId').attackRoll.value, 5, + 'The proficiency should apply correctly to modify the attack roll' + ); +} + +var testProperties = [ + clean({ + _id: 'strengthId', + variableName: 'strength', + type: 'attribute', + attributeType: 'ability', + baseValue: { + calculation: '8' + }, + }), + clean({ + _id: 'actionId', + type: 'action', + ancestors: [{ id: 'charId' }], + attackRoll: { + calculation: 'strength.modifier', + }, + tags: ['rapier', 'martial weapon', 'weapon', 'attack'] + }), + clean({ + _id: 'profBonusId', + type: 'attribute', + variableName: 'proficiencyBonus', + ancestors: [{ id: 'charId' }], + baseValue: { + calculation: '13' + }, + }), + clean({ + _id: 'tagTargetedProficiency', + type: 'proficiency', + stats: ['strength'], // Should be ignored, we are targeting by tags + value: 0.49, + targetByTags: true, + targetTags: ['martial weapon'] + }), +]; diff --git a/app/imports/api/engine/computation/computeComputation/tests/index.js b/app/imports/api/engine/computation/computeComputation/tests/index.js index b9c8baf7..768fee2d 100644 --- a/app/imports/api/engine/computation/computeComputation/tests/index.js +++ b/app/imports/api/engine/computation/computeComputation/tests/index.js @@ -6,29 +6,33 @@ import computeInventory from './computeInventory.testFn.js'; import computeDamageMultipliers from './computeDamageMultipliers.testFn.js'; import computeEffects from './computeEffects.testFn.js'; import computeSkills from './computeSkills.testFn.js'; +import computeProficiencies from './computeProficiencies.testFn.js'; export default [{ text: 'Computes actions', fn: computeAction, -},{ +}, { text: 'Computes attributes', fn: computeAttribute, -},{ +}, { text: 'Computes classes', fn: computeClasses, -},{ +}, { text: 'Computes constants', fn: computeConstants, -},{ +}, { text: 'Computes inventory', fn: computeInventory, -},{ +}, { text: 'Computes damage multipliers', fn: computeDamageMultipliers, -},{ +}, { text: 'Computes effects', fn: computeEffects, -},{ +}, { text: 'Computes skills', fn: computeSkills, +}, { + text: 'Computes proficiencies', + fn: computeProficiencies, }]; diff --git a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js index fcad024c..0548fedd 100644 --- a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js +++ b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js @@ -3,12 +3,12 @@ import { EJSON } from 'meteor/ejson'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import propertySchemasIndex from '/imports/api/properties/computedOnlyPropertySchemasIndex.js'; -export default function writeAlteredProperties(computation){ +export default function writeAlteredProperties(computation) { let bulkWriteOperations = []; // Loop through all properties on the memo computation.props.forEach(changed => { let schema = propertySchemasIndex[changed.type]; - if (!schema){ + if (!schema) { console.warn('No schema for ' + changed.type); return; } @@ -20,12 +20,13 @@ export default function writeAlteredProperties(computation){ 'deactivatedBySelf', 'deactivatedByAncestor', 'deactivatedByToggle', + 'deactivatingToggleId', 'damage', 'dirty', ...schema.objectKeys(), ]; op = addChangedKeysToOp(op, keys, original, changed); - if (op){ + if (op) { bulkWriteOperations.push(op); } }); @@ -37,10 +38,10 @@ function addChangedKeysToOp(op, keys, original, changed) { // Loop through all keys that can be changed by computation // and compile an operation that sets all those keys for (let key of keys) { - if (!EJSON.equals(original[key], changed[key])){ + if (!EJSON.equals(original[key], changed[key])) { if (!op) op = newOperation(original._id, changed.type); let value = changed[key]; - if (value === undefined){ + if (value === undefined) { // Unset values that become undefined addUnsetOp(op, key); } else { @@ -52,32 +53,32 @@ function addChangedKeysToOp(op, keys, original, changed) { return op; } -function newOperation(_id, type){ +function newOperation(_id, type) { let newOp = { updateOne: { - filter: {_id}, + filter: { _id }, update: {}, } }; - if (Meteor.isClient){ + if (Meteor.isClient) { newOp.type = type; } return newOp; } -function addSetOp(op, key, value){ - if (op.updateOne.update.$set){ +function addSetOp(op, key, value) { + if (op.updateOne.update.$set) { op.updateOne.update.$set[key] = value; } else { - op.updateOne.update.$set = {[key]: value}; + op.updateOne.update.$set = { [key]: value }; } } -function addUnsetOp(op, key){ - if (op.updateOne.update.$unset){ +function addUnsetOp(op, key) { + if (op.updateOne.update.$unset) { op.updateOne.update.$unset[key] = 1; } else { - op.updateOne.update.$unset = {[key]: 1}; + op.updateOne.update.$unset = { [key]: 1 }; } } @@ -100,14 +101,14 @@ function writePropertiesSequentially(bulkWriteOps) { // in the UI because of incompatibility with latency compensation. If the // duplicate redraws can be fixed, this is a strictly better way of processing // writes -function bulkWriteProperties(bulkWriteOps){ +function bulkWriteProperties(bulkWriteOps) { if (!bulkWriteOps.length) return; // bulkWrite is only available on the server if (Meteor.isServer) { CreatureProperties.rawCollection().bulkWrite( bulkWriteOps, - {ordered : false}, - function(e){ + { ordered: false }, + function (e) { if (e) { console.error('Bulk write failed: '); console.error(e); diff --git a/app/imports/api/engine/computation/writeComputation/writeScope.js b/app/imports/api/engine/computation/writeComputation/writeScope.js index d96c2bfe..a25e9ba0 100644 --- a/app/imports/api/engine/computation/writeComputation/writeScope.js +++ b/app/imports/api/engine/computation/writeComputation/writeScope.js @@ -15,18 +15,21 @@ export default function writeScope(creatureId, computation) { let $set, $unset; - for (const key in scope){ + for (const key in scope) { + // Mongo can't handle keys that start with a dollar sign + if (key[0] === '$' || key[0] === '_') continue; + // Remove large properties that aren't likely to be accessed delete scope[key].parent; delete scope[key].ancestors; - + // Remove empty keys for (const subKey in scope[key]) { if (scope[key][subKey] === undefined) { delete scope[key][subKey] } } - + // Only update changed fields if (!EJSON.equals(variables[key], scope[key])) { if (!$set) $set = {}; @@ -53,9 +56,19 @@ export default function writeScope(creatureId, computation) { const update = {}; if ($set) update.$set = $set; if ($unset) update.$unset = $unset; - CreatureVariables.update({_creatureId: creatureId}, update); + CreatureVariables.update({ _creatureId: creatureId }, update); } if (computation.creature?.dirty) { - Creatures.update({_id: creatureId}, {$unset: { dirty: 1 }}); + Creatures.update({ _id: creatureId }, { $unset: { dirty: 1 } }); } } +/* +function calculateSize(computation) { + const sizeEstimator = { + creature: computation.creature, + variables: computation.variables, + props: computation.originalPropsById, + }; + return MongoInternals.NpmModule.BSON.calculateObjectSize(sizeEstimator, { checkKeys: false }) +} +*/ diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.js index d0a3d469..b6dba147 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.js @@ -4,7 +4,7 @@ import writeAlteredProperties from './computation/writeComputation/writeAlteredP import writeScope from './computation/writeComputation/writeScope.js'; import writeErrors from './computation/writeComputation/writeErrors.js'; -export default function computeCreature(creatureId){ +export default function computeCreature(creatureId) { if (Meteor.isClient) return; // console.log('compute ' + creatureId); const computation = buildCreatureComputation(creatureId); @@ -16,7 +16,7 @@ function computeComputation(computation, creatureId) { computeCreatureComputation(computation); writeAlteredProperties(computation); writeScope(creatureId, computation); - } catch (e){ + } catch (e) { const errorText = e.reason || e.message || e.toString(); computation.errors.push({ type: 'crash', @@ -32,6 +32,19 @@ function computeComputation(computation, creatureId) { console.error(logError); throw e; } finally { + checkPropertyCount(computation) writeErrors(creatureId, computation.errors); } } + +const MAX_PROPS = 1000; +function checkPropertyCount(computation) { + const count = computation.props.length; + if (count <= MAX_PROPS) return; + computation.errors.push({ + type: 'warning', + details: { + error: `This character sheet has too many properties and may perform poorly ( ${count} / ${MAX_PROPS} )` + }, + }); +} diff --git a/app/imports/api/library/Libraries.js b/app/imports/api/library/Libraries.js index 696f0001..1cbb723e 100644 --- a/app/imports/api/library/Libraries.js +++ b/app/imports/api/library/Libraries.js @@ -29,6 +29,16 @@ let LibrarySchema = new SimpleSchema({ optional: true, max: STORAGE_LIMITS.summary, }, + showInMarket: { + index: 1, + type: Boolean, + optional: true, + }, + subscriberCount: { + index: 1, + type: Number, + optional: true, + }, }); LibrarySchema.extend(SharingSchema); @@ -104,6 +114,29 @@ const updateLibraryDescription = new ValidatedMethod({ }, }); +const updateLibraryShowInMarket = new ValidatedMethod({ + name: 'libraries.updateShowInMarket', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.id + }, + value: { + type: Boolean, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id, value }) { + let library = Libraries.findOne(_id); + assertEditPermission(library, this.userId); + Libraries.update(_id, { $set: { showInMarket: value } }); + }, +}); + const removeLibrary = new ValidatedMethod({ name: 'libraries.remove', validate: new SimpleSchema({ @@ -130,4 +163,4 @@ export function removeLibaryWork(libraryId) { LibraryNodes.remove({ 'ancestors.id': libraryId }); } -export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, removeLibrary }; +export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, updateLibraryShowInMarket, removeLibrary }; diff --git a/app/imports/api/library/LibraryCollections.js b/app/imports/api/library/LibraryCollections.js index aec4d351..aff9fe20 100644 --- a/app/imports/api/library/LibraryCollections.js +++ b/app/imports/api/library/LibraryCollections.js @@ -32,6 +32,16 @@ const LibraryCollectionSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, + showInMarket: { + index: 1, + type: Boolean, + optional: true, + }, + subscriberCount: { + index: 1, + type: Number, + optional: true, + }, }); LibraryCollectionSchema.extend(SharingSchema); @@ -48,12 +58,12 @@ const insertLibraryCollection = new ValidatedMethod({ run(libraryCollection) { if (!this.userId) { throw new Meteor.Error('LibraryCollections.methods.insert.denied', - 'You need to be logged in to insert a library'); + 'You need to be logged in to insert a library'); } let tier = getUserTier(this.userId); - if (!tier.paidBenefits){ + if (!tier.paidBenefits) { throw new Meteor.Error('LibraryCollections.methods.insert.denied', - `The ${tier.name} tier does not allow you to insert a library collection`); + `The ${tier.name} tier does not allow you to insert a library collection`); } libraryCollection.owner = this.userId; return LibraryCollections.insert(libraryCollection); @@ -72,7 +82,7 @@ const updateLibraryCollection = new ValidatedMethod({ }, update: { type: LibraryCollectionSchema - .pick('name', 'description', 'libraries') + .pick('name', 'description', 'libraries', 'showInMarket') .extend({ //make libraries optional libraries: { optional: true, @@ -85,7 +95,7 @@ const updateLibraryCollection = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id, update}){ + run({ _id, update }) { const libraryCollection = LibraryCollections.findOne(_id, { fields: { owner: 1, @@ -93,7 +103,7 @@ const updateLibraryCollection = new ValidatedMethod({ } }); assertEditPermission(libraryCollection, this.userId); - return LibraryCollections.update(_id, {$set: update}); + return LibraryCollections.update(_id, { $set: update }); }, }); @@ -110,7 +120,7 @@ const removeLibraryCollection = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id}){ + run({ _id }) { const libraryCollection = LibraryCollections.findOne(_id, { fields: { owner: 1, diff --git a/app/imports/api/library/LibraryNodes.js b/app/imports/api/library/LibraryNodes.js index 50d97cd3..39264fb6 100644 --- a/app/imports/api/library/LibraryNodes.js +++ b/app/imports/api/library/LibraryNodes.js @@ -4,7 +4,7 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import SimpleSchema from 'simpl-schema'; import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema.js'; -import ChildSchema from '/imports/api/parenting/ChildSchema.js'; +import ChildSchema, { RefSchema } from '/imports/api/parenting/ChildSchema.js'; import propertySchemasIndex from '/imports/api/properties/propertySchemasIndex.js'; import Libraries from '/imports/api/library/Libraries.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; @@ -15,6 +15,8 @@ import '/imports/api/library/methods/index.js'; import { updateReferenceNodeWork } from '/imports/api/library/methods/updateReferenceNode.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import { restore } from '/imports/api/parenting/softRemove.js'; +import { getAncestry } from '/imports/api/parenting/parenting.js'; +import { reorderDocs } from '/imports/api/parenting/order.js'; let LibraryNodes = new Mongo.Collection('libraryNodes'); @@ -36,20 +38,66 @@ let LibraryNodeSchema = new SimpleSchema({ type: String, max: STORAGE_LIMITS.tagLength, }, + icon: { + type: storedIconsSchema, + optional: true, + max: STORAGE_LIMITS.icon, + }, + + // Library-specific properties, these can be stripped from the resulting + // creature properties + + // Will this property show up in the slot-fill dialog + fillSlots: { + type: Boolean, + optional: true, + index: 1, + }, + // Will this property show up in the insert-from-library dialog + searchable: { + type: Boolean, + optional: true, + index: 1, + }, libraryTags: { type: Array, - defaultValue: [], + optional: true, maxCount: STORAGE_LIMITS.tagCount, }, 'libraryTags.$': { type: String, max: STORAGE_LIMITS.tagLength, }, - icon: { - type: storedIconsSchema, + // Overrides the type when searching for properties + slotFillerType: { + type: String, optional: true, - max: STORAGE_LIMITS.icon, - } + max: STORAGE_LIMITS.variableName, + }, + // Image to display when filling the slot + slotFillImage: { + type: String, + optional: true, + max: STORAGE_LIMITS.url, + }, + // Fill more than one quantity in a slot, like feats and ability score + // improvements, filtered out of UI if there isn't space in quantityExpected + slotQuantityFilled: { + type: SimpleSchema.Integer, + optional: true, // Undefined implies 1 + }, + // Filters out of UI if condition isn't met, but isn't otherwise enforced + slotFillerCondition: { + type: String, + optional: true, + max: STORAGE_LIMITS.calculation, + }, + // Text to display if slot filler condition fails + slotFillerConditionNote: { + type: String, + optional: true, + max: STORAGE_LIMITS.calculation, + }, }); // Set up server side search index @@ -86,20 +134,56 @@ function assertNodeEditPermission(node, userId) { const insertNode = new ValidatedMethod({ name: 'libraryNodes.insert', - validate: null, + validate: new SimpleSchema({ + libraryNode: { + type: Object, + blackbox: true, + }, + parentRef: RefSchema, + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run(libraryNode) { + run({ libraryNode, parentRef }) { + // get the new ancestry + let { parentDoc, ancestors } = getAncestry({ parentRef }); + + // Check permission to edit + let root; + if (parentRef.collection === 'libraries') { + root = parentDoc; + } else if (parentRef.collection === 'libraryNodes') { + root = Libraries.findOne(parentDoc.ancestors[0].id); + } else { + throw `${parentRef.collection} is not a valid parent collection` + } + assertEditPermission(root, this.userId); + + // Set the ancestry of the library node + libraryNode.parent = parentRef; + libraryNode.ancestors = ancestors; + // Remove its ID if it came with one to force a random one to be generated + // server-side delete libraryNode._id; - assertNodeEditPermission(libraryNode, this.userId); - let nodeId = LibraryNodes.insert(libraryNode); + + // Insert the node + const nodeId = LibraryNodes.insert(libraryNode); + + // Update the node if it was a reference node if (libraryNode.type == 'reference') { libraryNode._id = nodeId; updateReferenceNodeWork(libraryNode, this.userId); } + + // Tree structure changed by insert, reorder the tree + reorderDocs({ + collection: LibraryNodes, + ancestorId: root._id, + }); + + // Return the id of the inserted node return nodeId; }, }); @@ -119,7 +203,7 @@ const updateLibraryNode = new ValidatedMethod({ }, mixins: [RateLimiterMixin], rateLimit: { - numRequests: 5, + numRequests: 15, timeInterval: 5000, }, run({ _id, path, value }) { diff --git a/app/imports/api/library/getCreatureLibraryIds.js b/app/imports/api/library/getCreatureLibraryIds.js index beb9c7d3..a951a042 100644 --- a/app/imports/api/library/getCreatureLibraryIds.js +++ b/app/imports/api/library/getCreatureLibraryIds.js @@ -4,6 +4,7 @@ import getUserLibraryIds from './getUserLibraryIds'; import { intersection, union } from 'lodash'; export default function getCreatureLibraryIds(creature, userId) { + if (!userId) console.log('no userId, returning empty array'); if (!userId) return []; // Get the ids of libraries the user is permitted to view @@ -17,14 +18,14 @@ export default function getCreatureLibraryIds(creature, userId) { allowedLibraryCollections: 1, } }); - if (!creature) return []; + if (!creature) return userLibIds; } // If the creature does not restrict the libraries, let it use them all if (!creature.allowedLibraryCollections && !creature.allowedLibraries) { return userLibIds; } - + // Get the ids of the libraries that the creature allows const allowedCollections = creature.allowedLibraryCollections || []; let creatureLibIds = creature.allowedLibraries || []; diff --git a/app/imports/api/library/methods/getDefaultSlotFiller.js b/app/imports/api/library/methods/getDefaultSlotFiller.js new file mode 100644 index 00000000..aeb5917b --- /dev/null +++ b/app/imports/api/library/methods/getDefaultSlotFiller.js @@ -0,0 +1,18 @@ +export default function getDefaultSlotFiller(slot) { + if (typeof slot !== 'object') throw 'getDefaultSlotFiller requires a slot'; + if (slot.type !== 'propertySlot') throw 'provided slot must be a propertySlot'; + + let slotType = slot.slotType; + if (!slotType || slot.slotType === 'slotFiller') { + slotType = 'folder'; + } + + const filler = { + type: slotType, + libraryTags: slot.slotTags || [], + name: 'Custom ' + slot.name || 'slot filler', + parent: { collection: 'creatureProperties', id: slot._id }, + ancestors: [...slot.ancestors, { collection: 'creatureProperties', id: slot._id }], + }; + return filler; +} diff --git a/app/imports/api/library/methods/updateReferenceNode.js b/app/imports/api/library/methods/updateReferenceNode.js index f933d065..aa2a5407 100644 --- a/app/imports/api/library/methods/updateReferenceNode.js +++ b/app/imports/api/library/methods/updateReferenceNode.js @@ -21,7 +21,7 @@ const updateReferenceNode = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id}) { + run({ _id }) { let userId = this.userId; let node = LibraryNodes.findOne(_id); assertDocEditPermission(node, userId); @@ -29,15 +29,15 @@ const updateReferenceNode = new ValidatedMethod({ }, }); -function writeCache(_id, cache){ - LibraryNodes.update(_id, {$set: {cache}}, { - selector: {type: 'reference'}, +function writeCache(_id, cache) { + LibraryNodes.update(_id, { $set: { cache } }, { + selector: { type: 'reference' }, }); } -function updateReferenceNodeWork(node, userId){ +function updateReferenceNodeWork(node, userId) { let cache = {} - if (!node.ref){ + if (!node.ref?.collection || !node.ref?.id) { writeCache(node._id, cache); return; } @@ -45,20 +45,23 @@ function updateReferenceNodeWork(node, userId){ try { doc = fetchDocByRef(node.ref); if (doc.removed) throw 'Property has been deleted'; - if (doc.ancestors[0].id !== node.ancestors[0].id){ + if (doc.ancestors[0].id !== node.ancestors[0].id) { library = fetchDocByRef(doc.ancestors[0]); assertViewPermission(library, userId) } - } catch(e){ - cache = {error: e.reason || e.message || e.toString()} + } catch (e) { + cache = { error: e.reason || e.message || e.toString() } writeCache(node._id, cache); return; } cache = { node: doc, }; - if (library){ - cache.library = {name: library.name}; + if (library) { + cache.library = { + id: library._id, + name: library.name, + }; } writeCache(node._id, cache); } diff --git a/app/imports/api/parenting/nodesToTree.js b/app/imports/api/parenting/nodesToTree.js index 3e4b5733..fae36df1 100644 --- a/app/imports/api/parenting/nodesToTree.js +++ b/app/imports/api/parenting/nodesToTree.js @@ -26,6 +26,7 @@ export function nodeArrayToTree(nodes) { forest.push(treeNode); } }); + forest.nodeIndex = nodeIndex; return forest; } diff --git a/app/imports/api/parenting/organizeMethods.js b/app/imports/api/parenting/organizeMethods.js index 88500085..54d5f1ba 100644 --- a/app/imports/api/parenting/organizeMethods.js +++ b/app/imports/api/parenting/organizeMethods.js @@ -23,13 +23,20 @@ const organizeDoc = new ValidatedMethod({ type: Boolean, optional: true, }, + skipClient: { + type: Boolean, + optional: true, + }, }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({docRef, parentRef, order, skipRecompute}) { + run({ docRef, parentRef, order, skipRecompute, skipClient }) { + if (skipClient && this.isSimulation) { + return; + } let doc = fetchDocByRef(docRef); let collection = getCollectionByName(docRef.collection); // The user must be able to edit both the doc and its parent to move it @@ -39,23 +46,23 @@ const organizeDoc = new ValidatedMethod({ assertDocEditPermission(parent, this.userId); // Change the doc's parent - updateParent({docRef, parentRef}); + updateParent({ docRef, parentRef }); // Change the doc's order to be a half step ahead of its target location - collection.update(doc._id, {$set: {order}}, {selector: {type: 'any'}}); + collection.update(doc._id, { $set: { order } }, { selector: { type: 'any' } }); // Reorder both ancestors' documents let oldAncestorId = doc.ancestors[0].id; - reorderDocs({collection, ancestorId: oldAncestorId}); + reorderDocs({ collection, ancestorId: oldAncestorId }); let newAncestorId = getRootId(parent); - if (newAncestorId !== oldAncestorId){ - reorderDocs({collection, ancestorId: newAncestorId}); + if (newAncestorId !== oldAncestorId) { + reorderDocs({ collection, ancestorId: newAncestorId }); } // Figure out which creatures need to be recalculated after this move let docCreatures = getCreatureAncestors(doc); let parentCreatures = getCreatureAncestors(parent); - if (!skipRecompute){ + if (!skipRecompute) { let creaturesToRecompute = union(docCreatures, parentCreatures); // Mark the creatures for recompute Creatures.update({ @@ -81,10 +88,10 @@ const reorderDoc = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({docRef, order}) { + run({ docRef, order }) { let doc = fetchDocByRef(docRef); assertDocEditPermission(doc, this.userId); - safeUpdateDocOrder({docRef, order}); + safeUpdateDocOrder({ docRef, order }); // Recompute the affected creatures const ancestors = getCreatureAncestors(doc); if (ancestors.length) { @@ -97,22 +104,22 @@ const reorderDoc = new ValidatedMethod({ }, }); -function getRootId(doc){ - if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]){ +function getRootId(doc) { + if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) { return doc.ancestors[0].id; } else { return doc._id; } } -function getCreatureAncestors(doc){ +function getCreatureAncestors(doc) { let ids = []; - if(doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster'){ + if (doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster') { ids.push(doc._id); } - if (doc.ancestors){ + if (doc.ancestors) { doc.ancestors.forEach(ancestorRef => { - if (ancestorRef.collection === 'creatures'){ + if (ancestorRef.collection === 'creatures') { ids.push(ancestorRef.id); } }); diff --git a/app/imports/api/properties/Actions.js b/app/imports/api/properties/Actions.js index d4a5c4b4..3de84914 100644 --- a/app/imports/api/properties/Actions.js +++ b/app/imports/api/properties/Actions.js @@ -50,7 +50,6 @@ let ActionSchema = createPropertySchema({ attackRoll: { type: 'fieldToCompute', optional: true, - defaultValue: 'strength.modifier + proficiencyBonus', }, // Calculation of how many times this action can be used uses: { diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.js index 94e5d657..8835661a 100644 --- a/app/imports/api/properties/Attributes.js +++ b/app/imports/api/properties/Attributes.js @@ -173,6 +173,13 @@ let ComputedOnlyAttributeSchema = createPropertySchema({ optional: true, removeBeforeCompute: true, }, + // Attributes with advantage grant it to all skills based on the attribute + advantage: { + type: SimpleSchema.Integer, + optional: true, + allowedValues: [-1, 0, 1], + removeBeforeCompute: true, + }, // The computed creature constitution modifier for hit dice constitutionMod: { type: Number, diff --git a/app/imports/api/properties/ClassLevels.js b/app/imports/api/properties/ClassLevels.js index bbd76d58..5fd8a702 100644 --- a/app/imports/api/properties/ClassLevels.js +++ b/app/imports/api/properties/ClassLevels.js @@ -26,12 +26,6 @@ const ClassLevelSchema = createPropertySchema({ defaultValue: 1, max: STORAGE_LIMITS.levelMax, }, - // Filters out of UI if condition isn't met, but isn't otherwise enforced - slotFillerCondition: { - type: String, - optional: true, - max: STORAGE_LIMITS.calculation, - }, }); const ComputedOnlyClassLevelSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Effects.js b/app/imports/api/properties/Effects.js index e1ba0c37..77eadbeb 100644 --- a/app/imports/api/properties/Effects.js +++ b/app/imports/api/properties/Effects.js @@ -1,6 +1,7 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; +import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js'; /* * Effects are reason-value attached to skills and abilities @@ -50,57 +51,7 @@ let EffectSchema = createPropertySchema({ type: String, max: STORAGE_LIMITS.variableName, }, - // True when targeting by tags instead of stats - targetByTags: { - type: Boolean, - optional: true, - }, - // If targeting by tags, the field which will be targeted - targetField: { - type: String, - optional: true, - max: STORAGE_LIMITS.variableName, - }, - // Which tags the effect is applied to - targetTags: { - type: Array, - optional: true, - maxCount: STORAGE_LIMITS.tagCount, - }, - 'targetTags.$': { - type: String, - max: STORAGE_LIMITS.tagLength, - }, - extraTags: { - type: Array, - optional: true, - maxCount: STORAGE_LIMITS.extraTagsCount, - }, - 'extraTags.$': { - type: Object, - }, - 'extraTags.$._id': { - type: String, - regEx: SimpleSchema.RegEx.Id, - autoValue(){ - if (!this.isSet) return Random.id(); - } - }, - 'extraTags.$.operation': { - type: String, - allowedValues: ['OR', 'NOT'], - defaultValue: 'OR', - }, - 'extraTags.$.tags': { - type: Array, - defaultValue: [], - maxCount: STORAGE_LIMITS.tagCount, - }, - 'extraTags.$.tags.$': { - type: String, - max: STORAGE_LIMITS.tagLength, - }, -}); +}).extend(TagTargetingSchema); const ComputedOnlyEffectSchema = createPropertySchema({ amount: { diff --git a/app/imports/api/properties/Folders.js b/app/imports/api/properties/Folders.js index 66a10355..626b07ed 100644 --- a/app/imports/api/properties/Folders.js +++ b/app/imports/api/properties/Folders.js @@ -1,14 +1,19 @@ +import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; // Folders organize a character sheet into a tree, particularly to group things // like 'race' and 'background' -let FolderSchema = new createPropertySchema({ +let FolderSchema = createPropertySchema({ name: { type: String, max: STORAGE_LIMITS.name, optional: true, }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, groupStats: { type: Boolean, optional: true, @@ -33,6 +38,15 @@ let FolderSchema = new createPropertySchema({ }, }); -const ComputedOnlyFolderSchema = new createPropertySchema({}); +const ComputedOnlyFolderSchema = createPropertySchema({ + description: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, +}); -export { FolderSchema, ComputedOnlyFolderSchema }; +const ComputedFolderSchema = new SimpleSchema() + .extend(FolderSchema) + .extend(ComputedOnlyFolderSchema); + +export { FolderSchema, ComputedFolderSchema, ComputedOnlyFolderSchema }; diff --git a/app/imports/api/properties/Proficiencies.js b/app/imports/api/properties/Proficiencies.js index e79276ff..cec32217 100644 --- a/app/imports/api/properties/Proficiencies.js +++ b/app/imports/api/properties/Proficiencies.js @@ -1,5 +1,6 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js'; let ProficiencySchema = new SimpleSchema({ name: { @@ -24,7 +25,7 @@ let ProficiencySchema = new SimpleSchema({ allowedValues: [0.49, 0.5, 1, 2], defaultValue: 1, }, -}); +}).extend(TagTargetingSchema); const ComputedOnlyProficiencySchema = new SimpleSchema({}); diff --git a/app/imports/api/properties/References.js b/app/imports/api/properties/References.js index 31ae8288..d6ece6e6 100644 --- a/app/imports/api/properties/References.js +++ b/app/imports/api/properties/References.js @@ -51,6 +51,10 @@ let ReferenceSchema = new SimpleSchema({ type: Object, optional: true, }, + 'cache.library.id': { + type: String, + optional: true, + }, 'cache.library.name': { type: String, optional: true, diff --git a/app/imports/api/properties/Skills.js b/app/imports/api/properties/Skills.js index a2083cdf..7f15df3a 100644 --- a/app/imports/api/properties/Skills.js +++ b/app/imports/api/properties/Skills.js @@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema'; import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; +import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js'; /* * Skills are anything that results in a modifier to be added to a D20 @@ -59,7 +60,8 @@ let SkillSchema = createPropertySchema({ type: 'inlineCalculationFieldToCompute', optional: true, }, -}); + // Skills can apply their value to other calculations as a proficiency using tag targeting +}).extend(TagTargetingSchema); let ComputedOnlySkillSchema = createPropertySchema({ // Computed value of skill to be added to skill rolls diff --git a/app/imports/api/properties/SlotFillers.js b/app/imports/api/properties/SlotFillers.js deleted file mode 100644 index 185e7ba2..00000000 --- a/app/imports/api/properties/SlotFillers.js +++ /dev/null @@ -1,44 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; - -// SlotFiller fillers specifically fill a slot with a bit more control than -// other properties -let SlotFillerSchema = new SimpleSchema({ - name: { - type: String, - optional: true, - max: STORAGE_LIMITS.name, - }, - picture: { - type: String, - optional: true, - max: STORAGE_LIMITS.url, - }, - description: { - type: String, - optional: true, - max: STORAGE_LIMITS.description, - }, - // Overrides the type when searching for properties - slotFillerType: { - type: String, - optional: true, - max: STORAGE_LIMITS.variableName, - }, - // Fill more than one quantity in a slot, like feats and ability score - // improvements, filtered out of UI if there isn't space in quantityExpected - slotQuantityFilled: { - type: SimpleSchema.Integer, - defaultValue: 1, - }, - // Filters out of UI if condition isn't met, but isn't otherwise enforced - slotFillerCondition: { - type: String, - optional: true, - max: STORAGE_LIMITS.calculation, - }, -}); - -const ComputedOnlySlotFillerSchema = new SimpleSchema({}); - -export { SlotFillerSchema, ComputedOnlySlotFillerSchema }; diff --git a/app/imports/api/properties/Toggles.js b/app/imports/api/properties/Toggles.js index 755ed89b..c5babdc6 100644 --- a/app/imports/api/properties/Toggles.js +++ b/app/imports/api/properties/Toggles.js @@ -1,6 +1,7 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; +import TagTargetingSchema from '/imports/api/properties/subSchemas/TagTargetingSchema.js'; const ToggleSchema = createPropertySchema({ name: { @@ -31,7 +32,7 @@ const ToggleSchema = createPropertySchema({ type: 'fieldToCompute', optional: true, }, -}); +}).extend(TagTargetingSchema); const ComputedOnlyToggleSchema = createPropertySchema({ condition: { diff --git a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js index 93f3793a..89cb21e4 100644 --- a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js +++ b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js @@ -23,7 +23,6 @@ import { ComputedOnlyRollSchema } from '/imports/api/properties/Rolls.js'; import { ComputedOnlySavingThrowSchema } from '/imports/api/properties/SavingThrows.js'; import { ComputedOnlySkillSchema } from '/imports/api/properties/Skills.js'; import { ComputedOnlySlotSchema } from '/imports/api/properties/Slots.js'; -import { ComputedOnlySlotFillerSchema } from '/imports/api/properties/SlotFillers.js'; import { ComputedOnlySpellSchema } from '/imports/api/properties/Spells.js'; import { ComputedOnlySpellListSchema } from '/imports/api/properties/SpellLists.js'; import { ComputedOnlyToggleSchema } from '/imports/api/properties/Toggles.js'; @@ -54,7 +53,6 @@ const propertySchemasIndex = { roll: ComputedOnlyRollSchema, savingThrow: ComputedOnlySavingThrowSchema, skill: ComputedOnlySkillSchema, - slotFiller: ComputedOnlySlotFillerSchema, spellList: ComputedOnlySpellListSchema, spell: ComputedOnlySpellSchema, toggle: ComputedOnlyToggleSchema, diff --git a/app/imports/api/properties/computedPropertySchemasIndex.js b/app/imports/api/properties/computedPropertySchemasIndex.js index 7bdfb302..b6adaaa2 100644 --- a/app/imports/api/properties/computedPropertySchemasIndex.js +++ b/app/imports/api/properties/computedPropertySchemasIndex.js @@ -13,7 +13,7 @@ import { ComputedDamageSchema } from '/imports/api/properties/Damages.js'; import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js'; import { ComputedEffectSchema } from '/imports/api/properties/Effects.js'; import { ComputedFeatureSchema } from '/imports/api/properties/Features.js'; -import { FolderSchema } from '/imports/api/properties/Folders.js'; +import { ComputedFolderSchema } from '/imports/api/properties/Folders.js'; import { ComputedItemSchema } from '/imports/api/properties/Items.js'; import { ComputedNoteSchema } from '/imports/api/properties/Notes.js'; import { ComputedPointBuySchema } from '/imports/api/properties/PointBuys.js'; @@ -23,7 +23,6 @@ import { ComputedRollSchema } from '/imports/api/properties/Rolls.js'; import { ComputedSavingThrowSchema } from '/imports/api/properties/SavingThrows.js'; import { ComputedSkillSchema } from '/imports/api/properties/Skills.js'; import { ComputedSlotSchema } from '/imports/api/properties/Slots.js'; -import { SlotFillerSchema } from '/imports/api/properties/SlotFillers.js'; import { ComputedSpellSchema } from '/imports/api/properties/Spells.js'; import { ComputedSpellListSchema } from '/imports/api/properties/SpellLists.js'; import { ComputedToggleSchema } from '/imports/api/properties/Toggles.js'; @@ -43,7 +42,7 @@ const propertySchemasIndex = { damageMultiplier: DamageMultiplierSchema, effect: ComputedEffectSchema, feature: ComputedFeatureSchema, - folder: FolderSchema, + folder: ComputedFolderSchema, note: ComputedNoteSchema, pointBuy: ComputedPointBuySchema, proficiency: ProficiencySchema, @@ -52,7 +51,6 @@ const propertySchemasIndex = { roll: ComputedRollSchema, savingThrow: ComputedSavingThrowSchema, skill: ComputedSkillSchema, - slotFiller: SlotFillerSchema, spellList: ComputedSpellListSchema, spell: ComputedSpellSchema, toggle: ComputedToggleSchema, diff --git a/app/imports/api/properties/propertySchemasIndex.js b/app/imports/api/properties/propertySchemasIndex.js index fdccedc9..ed0a3a22 100644 --- a/app/imports/api/properties/propertySchemasIndex.js +++ b/app/imports/api/properties/propertySchemasIndex.js @@ -21,7 +21,6 @@ import { RollSchema } from '/imports/api/properties/Rolls.js'; import { SavingThrowSchema } from '/imports/api/properties/SavingThrows.js'; import { SkillSchema } from '/imports/api/properties/Skills.js'; import { SlotSchema } from '/imports/api/properties/Slots.js'; -import { SlotFillerSchema } from '/imports/api/properties/SlotFillers.js'; import { SpellListSchema } from '/imports/api/properties/SpellLists.js'; import { SpellSchema } from '/imports/api/properties/Spells.js'; import { ToggleSchema } from '/imports/api/properties/Toggles.js'; @@ -52,7 +51,6 @@ const propertySchemasIndex = { roll: RollSchema, savingThrow: SavingThrowSchema, skill: SkillSchema, - slotFiller: SlotFillerSchema, spellList: SpellListSchema, spell: SpellSchema, toggle: ToggleSchema, diff --git a/app/imports/api/properties/subSchemas/TagTargetingSchema.js b/app/imports/api/properties/subSchemas/TagTargetingSchema.js new file mode 100644 index 00000000..8bbe1ddb --- /dev/null +++ b/app/imports/api/properties/subSchemas/TagTargetingSchema.js @@ -0,0 +1,57 @@ +import SimpleSchema from 'simpl-schema'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; + +const TagTargetingSchema = new SimpleSchema({ + // True when targeting by tags instead of stats + targetByTags: { + type: Boolean, + optional: true, + }, + // If targeting by tags, the field which will be targeted + targetField: { + type: String, + optional: true, + max: STORAGE_LIMITS.variableName, + }, + // Which tags the effect is applied to + targetTags: { + type: Array, + optional: true, + maxCount: STORAGE_LIMITS.tagCount, + }, + 'targetTags.$': { + type: String, + max: STORAGE_LIMITS.tagLength, + }, + extraTags: { + type: Array, + optional: true, + maxCount: STORAGE_LIMITS.extraTagsCount, + }, + 'extraTags.$': { + type: Object, + }, + 'extraTags.$._id': { + type: String, + regEx: SimpleSchema.RegEx.Id, + autoValue() { + if (!this.isSet) return Random.id(); + } + }, + 'extraTags.$.operation': { + type: String, + allowedValues: ['OR', 'NOT'], + defaultValue: 'OR', + }, + 'extraTags.$.tags': { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.tagCount, + }, + 'extraTags.$.tags.$': { + type: String, + max: STORAGE_LIMITS.tagLength, + }, +}); + +export default TagTargetingSchema; diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js index 54328914..dc9779e9 100644 --- a/app/imports/api/users/Users.js +++ b/app/imports/api/users/Users.js @@ -1,11 +1,12 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import Libraries from '/imports/api/library/Libraries.js'; +import LibraryCollections from '/imports/api/library/LibraryCollections.js'; import '/imports/api/users/methods/deleteMyAccount.js'; import '/imports/api/users/methods/addEmail.js'; import '/imports/api/users/methods/removeEmail.js'; import '/imports/api/users/methods/updateFileStorageUsed.js'; - import { some } from 'lodash'; const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || []; const defaultLibraryCollections = process.env.DEFAULT_LIBRARY_COLLECTIONS && process.env.DEFAULT_LIBRARY_COLLECTIONS.split(',') || []; @@ -142,12 +143,12 @@ Meteor.users.generateApiKey = new ValidatedMethod({ Meteor.users.setDarkMode = new ValidatedMethod({ name: 'users.setDarkMode', validate: new SimpleSchema({ - darkMode: { type: Boolean }, + darkMode: { type: Boolean, optional: true }, }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, - timeInterval: 5000, + timeInterval: 2000, }, run({ darkMode }) { if (!this.userId) return; @@ -250,6 +251,29 @@ Meteor.users.setPreference = new ValidatedMethod({ }, }); +if (Meteor.isServer) { + Accounts.onCreateUser(() => { + if (defaultLibraries?.length) { + Libraries.update({ + _id: { $in: defaultLibraries } + }, { + $inc: { subscriberCount: 1 } + }, { + multi: true, + }, () => {/**/ }); + } + if (defaultLibraryCollections?.length) { + LibraryCollections.update({ + _id: { $in: defaultLibraryCollections } + }, { + $inc: { subscriberCount: 1 } + }, { + multi: true, + }, () => {/**/ }); + } + }); +} + Meteor.users.subscribeToLibrary = new ValidatedMethod({ name: 'users.subscribeToLibrary', validate: new SimpleSchema({ @@ -264,15 +288,17 @@ Meteor.users.subscribeToLibrary = new ValidatedMethod({ mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, - timeInterval: 5000, + timeInterval: 2000, }, run({ libraryId, subscribe }) { if (!this.userId) throw 'Can only subscribe if logged in'; if (subscribe) { + Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: 1 } }, () => {/**/ }); return Meteor.users.update(this.userId, { $addToSet: { subscribedLibraries: libraryId }, }); } else { + Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: -1 } }, () => {/**/ }); return Meteor.users.update(this.userId, { $pullAll: { subscribedLibraries: libraryId }, }); @@ -299,10 +325,12 @@ Meteor.users.subscribeToLibraryCollection = new ValidatedMethod({ run({ libraryCollectionId, subscribe }) { if (!this.userId) throw 'Can only subscribe if logged in'; if (subscribe) { + LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: 1 } }, () => {/**/ }); return Meteor.users.update(this.userId, { $addToSet: { subscribedLibraryCollections: libraryCollectionId }, }); } else { + LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: -1 } }, () => {/**/ }); return Meteor.users.update(this.userId, { $pullAll: { subscribedLibraryCollections: libraryCollectionId }, }); diff --git a/app/imports/api/utility/escapeRegex.js b/app/imports/api/utility/escapeRegex.js new file mode 100644 index 00000000..8ec4073c --- /dev/null +++ b/app/imports/api/utility/escapeRegex.js @@ -0,0 +1,3 @@ +export default function escapeRegex(string) { + return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, ''); +} \ No newline at end of file diff --git a/app/imports/api/utility/numberToSignedString.js b/app/imports/api/utility/numberToSignedString.js index da6b124b..f60b3412 100644 --- a/app/imports/api/utility/numberToSignedString.js +++ b/app/imports/api/utility/numberToSignedString.js @@ -1,10 +1,11 @@ -export default function numberToSignedString(number, spaced){ +export default function numberToSignedString(number, spaced) { if (typeof number !== 'number') return number; - if (number === 0){ + if (number === 0) { return spaced ? '+ 0' : '+0'; - } else if (number > 0){ + } else if (number > 0) { return spaced ? `+ ${number}` : `+${number}`; } else { - return spaced ? `- ${Math.abs(number) || number}` : `${number}`; + // Uses the unicode minus sign '−' instead of a dash '-' to help line up numbers nicely + return spaced ? `− ${Math.abs(number) || number}` : `−${Math.abs(number) || number}`; } } diff --git a/app/imports/client/ui/components/ColorPicker.vue b/app/imports/client/ui/components/ColorPicker.vue index 92f304fe..ef73f90d 100644 --- a/app/imports/client/ui/components/ColorPicker.vue +++ b/app/imports/client/ui/components/ColorPicker.vue @@ -7,16 +7,18 @@ > - - diff --git a/app/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue b/app/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue index 3fa48041..025e1fd8 100644 --- a/app/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue +++ b/app/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue @@ -9,20 +9,20 @@ style="flex-grow: 0;" @duplicate="duplicate" @remove="remove" + @copy-to-library="copyToLibrary" @toggle-editing="editing = !editing" - @color-changed="value => change({path: ['color'], value})" />
{ + if (error) { + console.error(error); + snackbar({ + text: error.reason || error.message || error.toString(), + }); + } else { + snackbar({ + text: 'Copied successfully', + }); + } + }); + } + }); + }, + addProperty({elementId, suggestedType}){ let parentPropertyId = this.model._id; this.$store.commit('pushDialogStack', { - component: 'add-creature-property-dialog', - elementId: 'insert-creature-property-btn', + component: 'insert-property-dialog', + elementId, data: { parentDoc: this.model, creatureId: this.creatureId, + suggestedType, + noBackdropClose: true, }, callback(result){ if (!result) return; diff --git a/app/imports/client/ui/creature/creatureProperties/CreaturePropertyInsertForm.vue b/app/imports/client/ui/creature/creatureProperties/CreaturePropertyInsertForm.vue deleted file mode 100644 index e2deb0bd..00000000 --- a/app/imports/client/ui/creature/creatureProperties/CreaturePropertyInsertForm.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - diff --git a/app/imports/client/ui/creature/dependencyGraph/DependencyGraphDialog.vue b/app/imports/client/ui/creature/dependencyGraph/DependencyGraphDialog.vue new file mode 100644 index 00000000..3037960c --- /dev/null +++ b/app/imports/client/ui/creature/dependencyGraph/DependencyGraphDialog.vue @@ -0,0 +1,215 @@ + + + + + \ No newline at end of file diff --git a/app/imports/client/ui/creature/slots/LevelUpDialog.vue b/app/imports/client/ui/creature/slots/LevelUpDialog.vue index 460e2ec5..aefd4405 100644 --- a/app/imports/client/ui/creature/slots/LevelUpDialog.vue +++ b/app/imports/client/ui/creature/slots/LevelUpDialog.vue @@ -192,7 +192,7 @@ import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/ import Libraries from '/imports/api/library/Libraries.js'; import LibraryNodeExpansionContent from '/imports/client/ui/library/LibraryNodeExpansionContent.vue'; import PropertyTags from '/imports/client/ui/properties/viewers/shared/PropertyTags.vue'; -import { clone } from 'lodash'; +import { clone, difference } from 'lodash'; export default { components: { @@ -248,6 +248,29 @@ export default { return { or, not }; }, }, + watch: { + selectedNodeIds(selectedIds, oldSelectedIds) { + // Skip if we didn't increase the length by adding a new Id + if (oldSelectedIds.length >= selectedIds.length) return; + // Find out which library node was added + const addedId = difference(selectedIds, oldSelectedIds)[0]; + if (!addedId) return; + const addedNode = LibraryNodes.findOne(addedId); + if (!addedNode) return; + // Tick any unchecked nodes of a lower level, but only one per level + const backFilledLevels = new Set(); + this.libraryNodes.forEach(node => { + if ( + !selectedIds.includes(node._id) + && node.level < addedNode.level + && !backFilledLevels.has(node.level) + ) { + selectedIds.push(node._id); + } + }); + this.selectedNodeIds = selectedIds; + } + }, methods: { loadMore() { if (this.currentLimit >= this.countAll) return; @@ -345,9 +368,15 @@ export default { Libraries.find().forEach(lib => names[lib._id] = lib.name) return names; }, + libraryNodeFilter() { + const filterString = this._subs['classFillers'].data('libraryNodeFilter'); + if (!filterString) return; + return EJSON.parse(filterString); + }, libraryNodes() { - let filter = getSlotFillFilter({ slot: this.model }); - let nodes = LibraryNodes.find(filter, { + if (!this.libraryNodeFilter) return []; + if (!this.$subReady.classFillers) return []; + let nodes = LibraryNodes.find(this.libraryNodeFilter, { sort: { name: 1, order: 1 } }).fetch(); let disabledNodeCount = 0; diff --git a/app/imports/client/ui/creature/slots/SlotFillDialog.vue b/app/imports/client/ui/creature/slots/SlotFillDialog.vue index 8b2d24f0..75af7420 100644 --- a/app/imports/client/ui/creature/slots/SlotFillDialog.vue +++ b/app/imports/client/ui/creature/slots/SlotFillDialog.vue @@ -26,7 +26,7 @@ :string="model.description" />

- {{ slotPropertyTypeName }} with tags: + {{ slotPropertyTypeName }} with library tags:

- - @@ -41,6 +49,7 @@ import LibraryCollections from '/imports/api/library/LibraryCollections.js'; import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { mapMutations } from 'vuex'; +import formatter from '/imports/client/ui/utility/numberFormatter.js'; export default { data() { @@ -87,6 +96,9 @@ export default { ...mapMutations([ 'toggleDrawer', ]), + formatNumber(num) { + return formatter.format(num); + }, subscribe(value) { this.loading = true; Meteor.users.subscribeToLibraryCollection.call({ @@ -103,6 +115,9 @@ export default { data: { _id: this.$route.params.id }, }); }, + back() { + return window.history.length > 2 ? this.$router.back() : this.$router.push('/library'); + }, }, } diff --git a/app/imports/client/ui/library/LibraryEditDialog.vue b/app/imports/client/ui/library/LibraryEditDialog.vue index 36decbbc..772d9625 100644 --- a/app/imports/client/ui/library/LibraryEditDialog.vue +++ b/app/imports/client/ui/library/LibraryEditDialog.vue @@ -8,6 +8,7 @@ mdi-share-variant @@ -15,12 +16,32 @@ mdi-delete + +
+ + + + {{ typeName }} + +
+
@@ -28,26 +46,26 @@ size="64" />
- - -

- This property can't be viewed yet. -

diff --git a/app/imports/client/ui/library/SingleLibraryToolbar.vue b/app/imports/client/ui/library/SingleLibraryToolbar.vue index 038dc867..40cd8ec8 100644 --- a/app/imports/client/ui/library/SingleLibraryToolbar.vue +++ b/app/imports/client/ui/library/SingleLibraryToolbar.vue @@ -10,7 +10,7 @@ mdi-arrow-left @@ -34,11 +34,20 @@ > mdi-cog + +
+ {{ formatNumber(library.subscriberCount) }} subscribers +
diff --git a/app/imports/client/ui/pages/About.vue b/app/imports/client/ui/pages/About.vue index f71356bd..7eed0225 100644 --- a/app/imports/client/ui/pages/About.vue +++ b/app/imports/client/ui/pages/About.vue @@ -87,7 +87,12 @@ export default { name: 'Dai', title: 'A Kobold\'s Best Friend', avatar: 'dai' - },], + }, { + name: 'Vibes', + title: 'Kell of Nothing', + avatar: 'vibes' + } + ], }}, } diff --git a/app/imports/client/ui/pages/Account.vue b/app/imports/client/ui/pages/Account.vue index 5f196ce8..807428cd 100644 --- a/app/imports/client/ui/pages/Account.vue +++ b/app/imports/client/ui/pages/Account.vue @@ -20,15 +20,18 @@ - + Preferences - @@ -260,7 +263,7 @@ return user && user.emails; }, darkMode(){ - return this.user && this.tier.paidBenefits && this.user.darkMode; + return this.user && this.user.darkMode; }, invites(){ let usernames = {}; @@ -342,8 +345,16 @@ Meteor.logout(); router.push('/'); }, - setDarkMode(value, ack){ - Meteor.users.setDarkMode.call({darkMode: !!value}, ack); + setDarkMode(value, ack) { + let darkMode; + if (value === 'true') { + darkMode = true; + } else if (value === 'false') { + darkMode = false; + } else if (value === 'unset') { + darkMode = null; + } + Meteor.users.setDarkMode.call({darkMode}, ack); }, swapAbilityScoresAndModifiers(value, ack){ Meteor.users.setPreference.call({ diff --git a/app/imports/client/ui/pages/Files.vue b/app/imports/client/ui/pages/Files.vue index d9af57ec..738c2a2d 100644 --- a/app/imports/client/ui/pages/Files.vue +++ b/app/imports/client/ui/pages/Files.vue @@ -44,6 +44,7 @@ style="height: 100%; width: 100%; min-height: 120px;" class="archive-button" :color="archiveFileError ? 'error' : undefined" + :disabled="archiveUploadInProgress" @click="$refs.archiveFileInput.click()" > @@ -57,7 +58,7 @@ @@ -107,6 +108,7 @@ import ImageUploadInput from '/imports/client/ui/components/ImageUploadInput.vue import UserImageCard from '/imports/client/ui/files/UserImageCard.vue'; import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js'; import { archiveSchema } from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; +import migrateArchive from '/imports/migrations/archive/migrateArchive.js'; export default { components: { @@ -120,8 +122,8 @@ export default { archiveFileError: undefined, archiveFile: undefined, archiveUploadInProgress: false, - archiveUploadProgress: undefined, - archiveUploadIndeterminate: false, + archiveUploadProgress: 0, + archiveUploadIndeterminate: true, }}, meteor: { $subscribe: { @@ -158,11 +160,21 @@ export default { }); } }, + watch: { + archiveUploadInProgress(val){ + if (val === false) { + this.archiveUploadProgress = 0; + this.archiveUploadIndeterminate = true; + } + }, + }, methods: { inputArchiveFile(){ this.archiveFile = undefined; this.archiveFileError = undefined; const file = this.$refs.archiveFileInput.files[0]; + // Reset the file input + this.$refs.archiveFileInput.value = null; if (!file) return; if (file.type !== 'application/json'){ this.archiveFileError = 'File must be .json'; @@ -174,6 +186,8 @@ export default { } this.archiveFile = file; this.archiveUploadIndeterminate = true; + this.archiveUploadInProgress = true; + this.archiveUploadProgress = undefined; const fr = new FileReader(); const self = this; @@ -184,14 +198,18 @@ export default { data = JSON.parse(fr.result); } catch (e){ self.archiveFileError = 'File could not be parsed'; + self.archiveUploadInProgress = false; console.error(e); return; } - try { - archiveSchema.validate(archiveSchema.clean(data)); + // Migrate, clean, and validate the archive + migrateArchive(data); + data = archiveSchema.clean(data); + archiveSchema.validate(data); } catch (e){ self.archiveFileError = 'File failed validation: ' + (e.reason || e.message || e.toString()); + self.archiveUploadInProgress = false; console.error(e); return; } @@ -210,7 +228,6 @@ export default { uploadInstance.on('start', function () { console.log('Starting'); self.archiveUploadIndeterminate = false; - self.archiveUploadInProgress = true; }); uploadInstance.on('end', function (error, fileObj) { @@ -226,7 +243,6 @@ export default { // Reset our state for the next file self.archiveUploadInProgress = false; - self.archiveUploadProgress = 0; }); uploadInstance.on('error', function (error, fileObj) { @@ -234,6 +250,7 @@ export default { const text = error.reason || error.message || error; snackbar({text}); self.archiveFileError = text; + self.archiveUploadInProgress = false; }); uploadInstance.on('progress', function (progress, fileObj) { diff --git a/app/imports/client/ui/pages/Library.vue b/app/imports/client/ui/pages/Library.vue index c1fefb4b..2415f614 100644 --- a/app/imports/client/ui/pages/Library.vue +++ b/app/imports/client/ui/pages/Library.vue @@ -29,11 +29,18 @@ -
+
+ + Browse community libraries + diff --git a/app/imports/client/ui/pages/LibraryBrowser.vue b/app/imports/client/ui/pages/LibraryBrowser.vue new file mode 100644 index 00000000..bbf65365 --- /dev/null +++ b/app/imports/client/ui/pages/LibraryBrowser.vue @@ -0,0 +1,153 @@ + + + diff --git a/app/imports/client/ui/pages/NotImplemented.vue b/app/imports/client/ui/pages/NotImplemented.vue index 87558758..e3a3c471 100644 --- a/app/imports/client/ui/pages/NotImplemented.vue +++ b/app/imports/client/ui/pages/NotImplemented.vue @@ -4,7 +4,7 @@ :value="true" type="info" > - This page is not available in this version of the beta. + This page is not available in this version of DiceCloud.
diff --git a/app/imports/client/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue b/app/imports/client/ui/properties/InsertPropertyDialog.vue similarity index 87% rename from app/imports/client/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue rename to app/imports/client/ui/properties/InsertPropertyDialog.vue index 32ae0e01..d346c19a 100644 --- a/app/imports/client/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue +++ b/app/imports/client/ui/properties/InsertPropertyDialog.vue @@ -16,7 +16,7 @@ flat @change="propertyHelpChanged" /> - Create - + Library @@ -56,19 +59,25 @@ > - + - - + - Cancel + {{ tab === 1 ? "Discard" : "Cancel" }} [], + }, + hideLibraryTab: Boolean, + showLibraryOnlyProps: Boolean, }, reactiveProvide: { name: 'context', @@ -229,9 +254,10 @@ export default { }, data(){return { selectedNodeIds: [], - type: this.forcedType || this.suggestedType, - model: { + type: this.forcedType || this.suggestedType || this.prop?.type || undefined, + model: this.prop || { type: this.type, + children: [], }, searchValue: undefined, debounceTime: 0, @@ -253,12 +279,14 @@ export default { type(newType){ this.changeType(newType); }, + prop(newProp) { + this.model = newProp + }, }, mounted(){ this.changeType(this.type); }, methods: { - propertyHelpChanged(value){ Meteor.users.setPreference.call({ preference: 'hidePropertySelectDialogHelp', @@ -291,17 +319,14 @@ export default { if (this.currentLimit >= this.countAll) return; this._subs.searchLibraryNodes.setData('limit', this.currentLimit + 32); }, - insert(){ - if (!this.selectedNodeIds.length) return; - this.$store.dispatch('popDialogStack', this.selectedNodeIds); - }, changeType(type){ this._subs.searchLibraryNodes.setData('type', type); if (!type) return; this.tab = 1; this.schema = propertySchemasIndex[type]; this.validationContext = this.schema.newContext(); - let model = this.schema.clean({}); + let model = this.model || {}; + model = this.schema.clean(model); model.type = type; this.model = model; }, @@ -355,4 +380,11 @@ export default { diff --git a/app/imports/client/ui/properties/PropertyForm.vue b/app/imports/client/ui/properties/PropertyForm.vue new file mode 100644 index 00000000..02dbb8ba --- /dev/null +++ b/app/imports/client/ui/properties/PropertyForm.vue @@ -0,0 +1,310 @@ + + + diff --git a/app/imports/client/ui/properties/components/actions/ActionCard.vue b/app/imports/client/ui/properties/components/actions/ActionCard.vue index b2c67042..9565868f 100644 --- a/app/imports/client/ui/properties/components/actions/ActionCard.vue +++ b/app/imports/client/ui/properties/components/actions/ActionCard.vue @@ -211,13 +211,13 @@ export default { doAction.call({ actionId: this.model._id, scope: { - $attackAdvantage: advantage, + '~attackAdvantage': { value: advantage }, } }, error => { this.doActionLoading = false; if (error) { console.error(error); - snackbar({ text: error.reason }); + snackbar({ text: error.reason || error.message || error.toString() }); } }); }, diff --git a/app/imports/client/ui/properties/components/actions/EventButton.vue b/app/imports/client/ui/properties/components/actions/EventButton.vue index 27773e04..f7333739 100644 --- a/app/imports/client/ui/properties/components/actions/EventButton.vue +++ b/app/imports/client/ui/properties/components/actions/EventButton.vue @@ -53,7 +53,7 @@ export default { doAction.call({ actionId: this.model._id, scope: { - $attackAdvantage: advantage, + '~attackAdvantage': { value: advantage }, } }, error => { this.doActionLoading = false; diff --git a/app/imports/client/ui/properties/components/actions/SelectItemToConsume.vue b/app/imports/client/ui/properties/components/actions/SelectItemToConsume.vue index 5634958e..33b5b7e7 100644 --- a/app/imports/client/ui/properties/components/actions/SelectItemToConsume.vue +++ b/app/imports/client/ui/properties/components/actions/SelectItemToConsume.vue @@ -13,7 +13,7 @@ - No equipped items found with the tag "{{ itemConsumed.tag }}" + No active items found with the tag "{{ itemConsumed.tag }}" @@ -42,7 +42,6 @@ export default { return CreatureProperties.find({ 'ancestors.id': this.action.ancestors[0].id, type: 'item', - equipped: true, tags: this.itemConsumed.tag, removed: {$ne: true}, inactive: {$ne: true}, diff --git a/app/imports/client/ui/properties/components/attributes/AbilityListTile.vue b/app/imports/client/ui/properties/components/attributes/AbilityListTile.vue index 2cc2eb95..3a1d6866 100644 --- a/app/imports/client/ui/properties/components/attributes/AbilityListTile.vue +++ b/app/imports/client/ui/properties/components/attributes/AbilityListTile.vue @@ -46,6 +46,18 @@ {{ model.name }} + + mdi-chevron-double-up + + + mdi-chevron-double-down + @@ -89,7 +101,7 @@ export default { doCheck.call({ propId: this.model._id, scope: { - $checkAdvantage: advantage, + '~checkAdvantage': { value: advantage }, }, }, error => { this.checkLoading = false; diff --git a/app/imports/client/ui/properties/components/attributes/AttributeCard.vue b/app/imports/client/ui/properties/components/attributes/AttributeCard.vue index 2f96dd5f..088c94ff 100644 --- a/app/imports/client/ui/properties/components/attributes/AttributeCard.vue +++ b/app/imports/client/ui/properties/components/attributes/AttributeCard.vue @@ -1,6 +1,9 @@ @@ -75,7 +87,7 @@ export default { doCheck.call({ propId: this.model._id, scope: { - $checkAdvantage: advantage, + '~checkAdvantage': { value: advantage }, }, }, error => { this.checkLoading = false; diff --git a/app/imports/client/ui/properties/components/attributes/ResourceCard.vue b/app/imports/client/ui/properties/components/attributes/ResourceCard.vue index a46cede2..164599c5 100644 --- a/app/imports/client/ui/properties/components/attributes/ResourceCard.vue +++ b/app/imports/client/ui/properties/components/attributes/ResourceCard.vue @@ -2,6 +2,9 @@ import CardHighlight from '/imports/client/ui/components/CardHighlight.vue'; import ResourceCardContent from '/imports/client/ui/properties/components/attributes/ResourceCardContent.vue'; +import isDarkColor from '/imports/client/ui/utility/isDarkColor.js'; export default { components: { @@ -38,6 +42,12 @@ export default { hover: false, } }, + computed: { + isDark() { + if (!this.model.color) return; + return isDarkColor(this.model.color); + }, + } }; diff --git a/app/imports/client/ui/properties/components/attributes/ResourceCardContent.vue b/app/imports/client/ui/properties/components/attributes/ResourceCardContent.vue index 1f9de91f..325651be 100644 --- a/app/imports/client/ui/properties/components/attributes/ResourceCardContent.vue +++ b/app/imports/client/ui/properties/components/attributes/ResourceCardContent.vue @@ -1,26 +1,28 @@ diff --git a/app/imports/client/ui/properties/components/buffs/BuffListItem.vue b/app/imports/client/ui/properties/components/buffs/BuffListItem.vue index b122f33c..ef2cb391 100644 --- a/app/imports/client/ui/properties/components/buffs/BuffListItem.vue +++ b/app/imports/client/ui/properties/components/buffs/BuffListItem.vue @@ -10,7 +10,7 @@ mdi-delete diff --git a/app/imports/client/ui/properties/components/folders/FolderGroupCard.vue b/app/imports/client/ui/properties/components/folders/FolderGroupCard.vue index 1784a60a..b21a1a2b 100644 --- a/app/imports/client/ui/properties/components/folders/FolderGroupCard.vue +++ b/app/imports/client/ui/properties/components/folders/FolderGroupCard.vue @@ -17,7 +17,7 @@ @click="$emit('click-property', {_id: prop._id})" @click-property="e => $emit('click-property', e)" @sub-click="e => $emit('sub-click', e)" - @remove="$emit('remove', prop._id)" + @remove="id => $emit('remove', id || prop._id)" />
@@ -49,6 +49,7 @@ export default { type: 'toggle', showUI: true, deactivatedByAncestor: { $ne: true }, + deactivatedByToggle: { $ne: true }, }, { type: { $ne: 'toggle' }, diff --git a/app/imports/client/ui/properties/components/folders/folderGroupComponents/FolderGroupChildren.vue b/app/imports/client/ui/properties/components/folders/folderGroupComponents/FolderGroupChildren.vue index 32edef29..bbb026b5 100644 --- a/app/imports/client/ui/properties/components/folders/folderGroupComponents/FolderGroupChildren.vue +++ b/app/imports/client/ui/properties/components/folders/folderGroupComponents/FolderGroupChildren.vue @@ -42,6 +42,7 @@ export default { type: 'toggle', showUI: true, deactivatedByAncestor: { $ne: true }, + deactivatedByToggle: { $ne: true }, }, { type: { $ne: 'toggle' }, diff --git a/app/imports/client/ui/properties/components/folders/propertyComponentIndex.js b/app/imports/client/ui/properties/components/folders/propertyComponentIndex.js index c6f866e8..8676d317 100644 --- a/app/imports/client/ui/properties/components/folders/propertyComponentIndex.js +++ b/app/imports/client/ui/properties/components/folders/propertyComponentIndex.js @@ -22,12 +22,10 @@ import propertySlot from '/imports/client/ui/properties/components/folders/folde //import roll from ''; //import savingThrow from ''; import skill from '/imports/client/ui/properties/components/skills/SkillListTile.vue'; -//import slotFiller from ''; import spellList from '/imports/client/ui/properties/components/spells/SpellListCard.vue'; import spell from '/imports/client/ui/properties/components/spells/SpellListTile.vue'; import toggle from '/imports/client/ui/properties/components/toggles/ToggleCard.vue'; //import trigger from ''; -import FolderGroupChildren from '/imports/client/ui/properties/components/folders/folderGroupComponents/FolderGroupChildren.vue'; export default { action, @@ -54,7 +52,6 @@ export default { //roll, //savingThrow, skill, - slotFiller: FolderGroupChildren, spellList, spell, toggle, diff --git a/app/imports/client/ui/properties/components/folders/tabFoldersMixin.js b/app/imports/client/ui/properties/components/folders/tabFoldersMixin.js index 5097143e..33dd5105 100644 --- a/app/imports/client/ui/properties/components/folders/tabFoldersMixin.js +++ b/app/imports/client/ui/properties/components/folders/tabFoldersMixin.js @@ -11,6 +11,10 @@ function getFolders(creatureId, tab, location) { removed: { $ne: true }, tab, location, + }, { + sort: { + order: 1, + } }); } diff --git a/app/imports/client/ui/properties/components/inventory/ContainerCard.vue b/app/imports/client/ui/properties/components/inventory/ContainerCard.vue index b2d6de9f..fee97c5b 100644 --- a/app/imports/client/ui/properties/components/inventory/ContainerCard.vue +++ b/app/imports/client/ui/properties/components/inventory/ContainerCard.vue @@ -99,6 +99,7 @@ export default { removed: { $ne: true }, equipped: { $ne: true }, deactivatedByAncestor: { $ne: true }, + deactivatedByToggle: { $ne: true }, }, { sort: { order: 1 }, }); diff --git a/app/imports/client/ui/properties/components/proficiencies/InlineProficiency.vue b/app/imports/client/ui/properties/components/proficiencies/InlineProficiency.vue new file mode 100644 index 00000000..7af9155c --- /dev/null +++ b/app/imports/client/ui/properties/components/proficiencies/InlineProficiency.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/app/imports/client/ui/properties/components/skills/SkillListTile.vue b/app/imports/client/ui/properties/components/skills/SkillListTile.vue index ec436241..d10c4a78 100644 --- a/app/imports/client/ui/properties/components/skills/SkillListTile.vue +++ b/app/imports/client/ui/properties/components/skills/SkillListTile.vue @@ -111,7 +111,7 @@ export default { doCheck.call({ propId: this.model._id, scope: { - $checkAdvantage: advantage, + '~checkAdvantage': { value: advantage }, }, }, error => { this.checkLoading = false; diff --git a/app/imports/client/ui/properties/components/spells/SpellListCard.vue b/app/imports/client/ui/properties/components/spells/SpellListCard.vue index d2d3134b..c31e5897 100644 --- a/app/imports/client/ui/properties/components/spells/SpellListCard.vue +++ b/app/imports/client/ui/properties/components/spells/SpellListCard.vue @@ -15,7 +15,7 @@ v-if="preparingSpells || preparedError" :class="{'error--text' : preparedError}" > - {{ numPrepared }}/{{ model.maxPrepared.value }} spells prepared + {{ numPrepared }}/{{ model.maxPrepared && model.maxPrepared.value || 0 }} spells prepared
- - - - - - + + + + + + - - - - + @@ -84,18 +87,12 @@ label="Description" hint="This text will be displayed in the log when the action is taken" :model="model.description" - :error-messages="errors.description" + :error-messages="errors['description.text']" @change="({path, value, ack}) => $emit('change', {path: ['description', ...path], value, ack})" /> - - - - + - - - + - - - + + + +
@@ -179,13 +156,11 @@ diff --git a/app/imports/client/ui/properties/forms/BuffRemoverForm.vue b/app/imports/client/ui/properties/forms/BuffRemoverForm.vue index abd05c62..6aa8e299 100644 --- a/app/imports/client/ui/properties/forms/BuffRemoverForm.vue +++ b/app/imports/client/ui/properties/forms/BuffRemoverForm.vue @@ -1,101 +1,58 @@ diff --git a/app/imports/client/ui/properties/forms/ConstantForm.vue b/app/imports/client/ui/properties/forms/ConstantForm.vue index c6c5e07a..316688bd 100644 --- a/app/imports/client/ui/properties/forms/ConstantForm.vue +++ b/app/imports/client/ui/properties/forms/ConstantForm.vue @@ -1,36 +1,33 @@ diff --git a/app/imports/client/ui/properties/forms/ContainerForm.vue b/app/imports/client/ui/properties/forms/ContainerForm.vue index 5cef5978..9d8556f3 100644 --- a/app/imports/client/ui/properties/forms/ContainerForm.vue +++ b/app/imports/client/ui/properties/forms/ContainerForm.vue @@ -1,14 +1,45 @@ diff --git a/app/imports/client/ui/properties/forms/DamageForm.vue b/app/imports/client/ui/properties/forms/DamageForm.vue index 23d00ad8..b7ed8881 100644 --- a/app/imports/client/ui/properties/forms/DamageForm.vue +++ b/app/imports/client/ui/properties/forms/DamageForm.vue @@ -8,7 +8,7 @@ - - - - - + + + + + +
diff --git a/app/imports/client/ui/properties/forms/DamageMultiplierForm.vue b/app/imports/client/ui/properties/forms/DamageMultiplierForm.vue index 130fbc85..41cfa862 100644 --- a/app/imports/client/ui/properties/forms/DamageMultiplierForm.vue +++ b/app/imports/client/ui/properties/forms/DamageMultiplierForm.vue @@ -1,29 +1,23 @@ @@ -102,18 +96,6 @@ export default { data() { return { DAMAGE_TYPES, - values: [ - { - value: 0, - text: 'Immunity', - }, { - value: 0.5, - text: 'Resistance', - }, { - value: 2, - text: 'Vulnerability', - }, - ], damageTypeRules: [ value => { if (value && value.length) { diff --git a/app/imports/client/ui/properties/forms/EffectForm.vue b/app/imports/client/ui/properties/forms/EffectForm.vue index 1d386bf8..fbf57736 100644 --- a/app/imports/client/ui/properties/forms/EffectForm.vue +++ b/app/imports/client/ui/properties/forms/EffectForm.vue @@ -1,181 +1,119 @@ @@ -184,20 +122,18 @@ import getEffectIcon from '/imports/client/ui/utility/getEffectIcon.js'; import propertyFormMixin from '/imports/client/ui/properties/forms/shared/propertyFormMixin.js'; import attributeListMixin from '/imports/client/ui/properties/forms/shared/lists/attributeListMixin.js'; -import { EffectSchema } from '/imports/api/properties/Effects.js'; -import FormSection from '/imports/client/ui/properties/forms/shared/FormSection.vue'; +import TagTargeting from '/imports/client/ui/properties/forms/shared/TagTargeting.vue'; const ICON_SPIN_DURATION = 300; export default { components: { - FormSection, + TagTargeting, }, mixins: [propertyFormMixin, attributeListMixin], data() { return { displayedIcon: 'add', iconClass: '', - addExtraTagsLoading: false, oldOperation: undefined, operations: [ { value: 'base', text: 'Base Value' }, @@ -218,11 +154,6 @@ export default { radioGroup() { return this.model.targetByTags ? 'tags' : 'stats'; }, - extraTagsFull() { - if (!this.model.extraTags) return false; - let maxCount = EffectSchema.get('extraTags', 'maxCount'); - return this.model.extraTags.length >= maxCount; - }, needsValue() { switch (this.model.operation) { case 'base': return true; @@ -242,7 +173,7 @@ export default { operationHint() { switch (this.model.operation) { case 'base': return 'Stats take their largest base value, and then apply all other effects'; - case 'add': return 'Add this vaulue to the stat'; + case 'add': return 'Add this value to the stat'; case 'mul': return 'Multiply the stat by this value'; case 'min': return 'The stat will be at least this value'; case 'max': return 'The stat will not exceed this value'; @@ -279,32 +210,20 @@ export default { }, methods: { getEffectIcon, - changeTargetByTags(value) { + changeTargetByTags(value, ack) { if (value === 'stats') { - this.$emit('change', { path: ['targetByTags'], value: undefined }); + this.$emit('change', { path: ['targetByTags'], value: undefined, ack }); if (this.oldOperation && this.oldOperation !== this.model.operation) { this.$emit('change', { path: ['operation'], value: this.oldOperation }); } } else if (value === 'tags') { - this.$emit('change', { path: ['targetByTags'], value: true }); + this.$emit('change', { path: ['targetByTags'], value: true, ack }); if (this.model.operation !== 'add') { this.oldOperation = this.model.operation; this.$emit('change', { path: ['operation'], value: 'add' }); } } }, - addExtraTags() { - this.addExtraTagsLoading = true; - this.$emit('push', { - path: ['extraTags'], - value: { - _id: Random.id(), - operation: 'OR', - tags: [], - }, - ack: () => this.addExtraTagsLoading = false, - }); - }, } }; diff --git a/app/imports/client/ui/properties/forms/FeatureForm.vue b/app/imports/client/ui/properties/forms/FeatureForm.vue index 10aa74fd..acf86e2c 100644 --- a/app/imports/client/ui/properties/forms/FeatureForm.vue +++ b/app/imports/client/ui/properties/forms/FeatureForm.vue @@ -1,17 +1,10 @@ diff --git a/app/imports/client/ui/properties/forms/FolderForm.vue b/app/imports/client/ui/properties/forms/FolderForm.vue index 9c993d3d..7263f1d7 100644 --- a/app/imports/client/ui/properties/forms/FolderForm.vue +++ b/app/imports/client/ui/properties/forms/FolderForm.vue @@ -1,76 +1,59 @@ diff --git a/app/imports/client/ui/properties/forms/ItemForm.vue b/app/imports/client/ui/properties/forms/ItemForm.vue index 45ef9e5c..3bbebdd4 100644 --- a/app/imports/client/ui/properties/forms/ItemForm.vue +++ b/app/imports/client/ui/properties/forms/ItemForm.vue @@ -1,14 +1,6 @@ diff --git a/app/imports/client/ui/properties/forms/NoteForm.vue b/app/imports/client/ui/properties/forms/NoteForm.vue index 36d693bb..43397b4f 100644 --- a/app/imports/client/ui/properties/forms/NoteForm.vue +++ b/app/imports/client/ui/properties/forms/NoteForm.vue @@ -1,18 +1,10 @@ diff --git a/app/imports/client/ui/properties/forms/PointBuyForm.vue b/app/imports/client/ui/properties/forms/PointBuyForm.vue index 4698559d..e366abf9 100644 --- a/app/imports/client/ui/properties/forms/PointBuyForm.vue +++ b/app/imports/client/ui/properties/forms/PointBuyForm.vue @@ -1,26 +1,13 @@ @@ -188,6 +185,9 @@ export default { PointBuySpendForm, }, mixins: [propertyFormMixin, attributeListMixin], + inject: { + context: { default: {} } + }, data() { return { addRowLoading: false, diff --git a/app/imports/client/ui/properties/forms/PointBuySpendForm.vue b/app/imports/client/ui/properties/forms/PointBuySpendForm.vue index 1eeb8ed8..5fbbaf28 100644 --- a/app/imports/client/ui/properties/forms/PointBuySpendForm.vue +++ b/app/imports/client/ui/properties/forms/PointBuySpendForm.vue @@ -118,6 +118,7 @@ export default { const currentSpent = this.model.spent; let newSpent = currentSpent - row.spent; const costFunction = EJSON.clone(row.cost || this.model.cost); + if (!costFunction?.parseNode) return; if (costFunction) costFunction.parseLevel = 'reduce'; evaluateCalculation(costFunction, { value }); if (Number.isFinite(costFunction.value)) { diff --git a/app/imports/client/ui/properties/forms/ProficiencyForm.vue b/app/imports/client/ui/properties/forms/ProficiencyForm.vue index a8a18d09..f66c7055 100644 --- a/app/imports/client/ui/properties/forms/ProficiencyForm.vue +++ b/app/imports/client/ui/properties/forms/ProficiencyForm.vue @@ -1,49 +1,86 @@ @@ -51,10 +88,12 @@ import ProficiencySelect from '/imports/client/ui/properties/forms/shared/ProficiencySelect.vue'; import skillListMixin from '/imports/client/ui/properties/forms/shared/lists/skillListMixin.js'; import propertyFormMixin from '/imports/client/ui/properties/forms/shared/propertyFormMixin.js'; +import TagTargeting from '/imports/client/ui/properties/forms/shared/TagTargeting.vue'; export default { components: { ProficiencySelect, + TagTargeting, }, mixins: [propertyFormMixin, skillListMixin], }; diff --git a/app/imports/client/ui/properties/forms/ReferenceForm.vue b/app/imports/client/ui/properties/forms/ReferenceForm.vue index 88032c03..1248f89a 100644 --- a/app/imports/client/ui/properties/forms/ReferenceForm.vue +++ b/app/imports/client/ui/properties/forms/ReferenceForm.vue @@ -1,34 +1,66 @@ @@ -36,12 +68,12 @@ import TreeNodeView from '/imports/client/ui/properties/treeNodeViews/TreeNodeView.vue'; import propertyFormMixin from '/imports/client/ui/properties/forms/shared/propertyFormMixin.js'; import updateReferenceNode from '/imports/api/library/methods/updateReferenceNode.js'; - import PropertyField from '/imports/client/ui/properties/viewers/shared/PropertyField.vue'; + import OutlinedInput from '/imports/client/ui/properties/viewers/shared/OutlinedInput.vue'; export default { components: { TreeNodeView, - PropertyField, + OutlinedInput, }, mixins: [propertyFormMixin], data(){return { diff --git a/app/imports/client/ui/properties/forms/RollForm.vue b/app/imports/client/ui/properties/forms/RollForm.vue index 87b4664a..27b326c4 100644 --- a/app/imports/client/ui/properties/forms/RollForm.vue +++ b/app/imports/client/ui/properties/forms/RollForm.vue @@ -1,17 +1,6 @@ diff --git a/app/imports/client/ui/properties/forms/SavingThrowForm.vue b/app/imports/client/ui/properties/forms/SavingThrowForm.vue index b2bd4cf5..4b5aaf25 100644 --- a/app/imports/client/ui/properties/forms/SavingThrowForm.vue +++ b/app/imports/client/ui/properties/forms/SavingThrowForm.vue @@ -1,25 +1,13 @@ @@ -87,25 +63,5 @@ import propertyFormMixin from '/imports/client/ui/properties/forms/shared/proper export default { mixins: [saveListMixin, propertyFormMixin], - computed: { - targetOptions() { - return [ - { - text: 'Self', - value: 'self', - }, { - text: 'Target', - value: 'target', - }, - ]; - }, - targetOptionHint() { - let hints = { - self: 'The save will be applied to the character taking the action', - target: 'The save will be applied to the targets of the action', - }; - return hints[this.model.target]; - } - }, }; diff --git a/app/imports/client/ui/properties/forms/SkillForm.vue b/app/imports/client/ui/properties/forms/SkillForm.vue index e7c2ab54..7078ea59 100644 --- a/app/imports/client/ui/properties/forms/SkillForm.vue +++ b/app/imports/client/ui/properties/forms/SkillForm.vue @@ -1,85 +1,105 @@ @@ -89,11 +109,13 @@ import ProficiencySelect from '/imports/client/ui/properties/forms/shared/Profic import FormSection from '/imports/client/ui/properties/forms/shared/FormSection.vue'; import createListOfProperties from '/imports/client/ui/properties/forms/shared/lists/createListOfProperties.js'; import propertyFormMixin from '/imports/client/ui/properties/forms/shared/propertyFormMixin.js'; +import TagTargeting from '/imports/client/ui/properties/forms/shared/TagTargeting.vue'; export default { components: { ProficiencySelect, FormSection, + TagTargeting, }, mixins: [propertyFormMixin], data() { diff --git a/app/imports/client/ui/properties/forms/SlotFillerForm.vue b/app/imports/client/ui/properties/forms/SlotFillerForm.vue deleted file mode 100644 index 88e0f350..00000000 --- a/app/imports/client/ui/properties/forms/SlotFillerForm.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - diff --git a/app/imports/client/ui/properties/forms/SlotForm.vue b/app/imports/client/ui/properties/forms/SlotForm.vue index e973932b..14fab234 100644 --- a/app/imports/client/ui/properties/forms/SlotForm.vue +++ b/app/imports/client/ui/properties/forms/SlotForm.vue @@ -1,200 +1,144 @@ @@ -203,19 +147,19 @@ import propertyFormMixin from '/imports/client/ui/properties/forms/shared/propertyFormMixin.js'; import FormSection from '/imports/client/ui/properties/forms/shared/FormSection.vue'; import PROPERTIES from '/imports/constants/PROPERTIES.js'; -import { SlotSchema } from '/imports/api/properties/Slots.js'; +import TagTargeting from '/imports/client/ui/properties/forms/shared/TagTargeting.vue'; +import OutlinedInput from '/imports/client/ui/properties/viewers/shared/OutlinedInput.vue'; export default { components: { FormSection, + TagTargeting, + OutlinedInput, }, mixins: [propertyFormMixin], inject: { context: { default: {} } }, - props: { - classForm: Boolean, - }, data() { let slotTypes = []; for (let key in PROPERTIES) { @@ -223,8 +167,6 @@ export default { } return { slotTypes, - addExtraTagsLoading: false, - extraTagOperations: ['OR', 'NOT'], uniqueOptions: [{ text: 'Each property inside this slot should be unique', value: 'uniqueInSlot', @@ -234,29 +176,7 @@ export default { }], }; }, - computed: { - extraTagsFull() { - if (!this.model.extraTags) return false; - let maxCount = SlotSchema.get('extraTags', 'maxCount'); - return this.model.extraTags.length >= maxCount; - } - }, methods: { - acknowledgeAddResult() { - this.addExtraTagsLoading = false; - }, - addExtraTags() { - this.addExtraTagsLoading = true; - this.$emit('push', { - path: ['extraTags'], - value: { - _id: Random.id(), - operation: 'OR', - tags: [], - }, - ack: this.acknowledgeAddResult, - }); - }, testSlot() { if (!this.context.isLibraryForm) return; this.$store.commit('pushDialogStack', { diff --git a/app/imports/client/ui/properties/forms/SpellForm.vue b/app/imports/client/ui/properties/forms/SpellForm.vue index 14c06817..34a6cce1 100644 --- a/app/imports/client/ui/properties/forms/SpellForm.vue +++ b/app/imports/client/ui/properties/forms/SpellForm.vue @@ -1,17 +1,5 @@ @@ -311,7 +287,6 @@ - - diff --git a/app/imports/client/ui/properties/forms/ToggleForm.vue b/app/imports/client/ui/properties/forms/ToggleForm.vue index 3a9f80fe..24cbd6d5 100644 --- a/app/imports/client/ui/properties/forms/ToggleForm.vue +++ b/app/imports/client/ui/properties/forms/ToggleForm.vue @@ -1,18 +1,6 @@ diff --git a/app/imports/client/ui/properties/forms/shared/IconColorMenu.vue b/app/imports/client/ui/properties/forms/shared/IconColorMenu.vue index aa58ed16..824f645c 100644 --- a/app/imports/client/ui/properties/forms/shared/IconColorMenu.vue +++ b/app/imports/client/ui/properties/forms/shared/IconColorMenu.vue @@ -1,32 +1,39 @@ diff --git a/app/imports/client/ui/properties/forms/shared/TagTargeting.vue b/app/imports/client/ui/properties/forms/shared/TagTargeting.vue new file mode 100644 index 00000000..fb9663a0 --- /dev/null +++ b/app/imports/client/ui/properties/forms/shared/TagTargeting.vue @@ -0,0 +1,141 @@ + + + \ No newline at end of file diff --git a/app/imports/client/ui/properties/forms/shared/propertyFormIndex.js b/app/imports/client/ui/properties/forms/shared/propertyFormIndex.js index 28cf4172..def2dba2 100644 --- a/app/imports/client/ui/properties/forms/shared/propertyFormIndex.js +++ b/app/imports/client/ui/properties/forms/shared/propertyFormIndex.js @@ -22,7 +22,6 @@ import RollForm from '/imports/client/ui/properties/forms/RollForm.vue'; import SavingThrowForm from '/imports/client/ui/properties/forms/SavingThrowForm.vue'; import SkillForm from '/imports/client/ui/properties/forms/SkillForm.vue'; import SlotForm from '/imports/client/ui/properties/forms/SlotForm.vue'; -import SlotFillerForm from '/imports/client/ui/properties/forms/SlotFillerForm.vue'; import SpellListForm from '/imports/client/ui/properties/forms/SpellListForm.vue'; import SpellForm from '/imports/client/ui/properties/forms/SpellForm.vue'; import ToggleForm from '/imports/client/ui/properties/forms/ToggleForm.vue'; @@ -53,7 +52,6 @@ export default { roll: RollForm, savingThrow: SavingThrowForm, skill: SkillForm, - slotFiller: SlotFillerForm, spellList: SpellListForm, spell: SpellForm, toggle: ToggleForm, diff --git a/app/imports/client/ui/properties/forms/shared/propertyFormMixin.js b/app/imports/client/ui/properties/forms/shared/propertyFormMixin.js index 58c3ec28..84f6760e 100644 --- a/app/imports/client/ui/properties/forms/shared/propertyFormMixin.js +++ b/app/imports/client/ui/properties/forms/shared/propertyFormMixin.js @@ -19,22 +19,12 @@ export default { default: () => ({}), }, }, - mounted(){ - // Don't autofocus on mobile, it brings up the on-screen keyboard - if (this.$vuetify.breakpoint.smAndDown) return; - - setTimeout(() => { - if (this.$refs.focusFirst && this.$refs.focusFirst.focus){ - this.$refs.focusFirst.focus() - } - }, 300); - }, methods: { - change(path, value, ack){ - if (!Array.isArray(path)){ + change(path, value, ack) { + if (!Array.isArray(path)) { path = [path]; } - this.$emit('change', {path, value, ack}); + this.$emit('change', { path, value, ack }); } }, } diff --git a/app/imports/client/ui/properties/shared/PropertySelectCard.vue b/app/imports/client/ui/properties/shared/PropertySelectCard.vue index 2e7f08f8..e8fab6fe 100644 --- a/app/imports/client/ui/properties/shared/PropertySelectCard.vue +++ b/app/imports/client/ui/properties/shared/PropertySelectCard.vue @@ -2,6 +2,8 @@ @@ -46,6 +47,7 @@ > @@ -72,6 +74,10 @@ export default { type: Array, default: undefined, }, + currentType: { + type: String, + default: undefined, + } }, data() { return { diff --git a/app/imports/client/ui/properties/shared/PropertyViewer.vue b/app/imports/client/ui/properties/shared/PropertyViewer.vue index 20a3aeed..de7777b0 100644 --- a/app/imports/client/ui/properties/shared/PropertyViewer.vue +++ b/app/imports/client/ui/properties/shared/PropertyViewer.vue @@ -1,11 +1,149 @@ \ No newline at end of file diff --git a/app/imports/client/ui/properties/viewers/shared/propertyViewerIndex.js b/app/imports/client/ui/properties/viewers/shared/propertyViewerIndex.js index 456bdbf5..29753094 100644 --- a/app/imports/client/ui/properties/viewers/shared/propertyViewerIndex.js +++ b/app/imports/client/ui/properties/viewers/shared/propertyViewerIndex.js @@ -22,7 +22,6 @@ import RollViewer from '/imports/client/ui/properties/viewers/RollViewer.vue'; import SkillViewer from '/imports/client/ui/properties/viewers/SkillViewer.vue'; import SavingThrowViewer from '/imports/client/ui/properties/viewers/SavingThrowViewer.vue'; import SlotViewer from '/imports/client/ui/properties/viewers/SlotViewer.vue'; -import SlotFillerViewer from '/imports/client/ui/properties/viewers/SlotFillerViewer.vue'; import SpellListViewer from '/imports/client/ui/properties/viewers/SpellListViewer.vue'; import SpellViewer from '/imports/client/ui/properties/viewers/SpellViewer.vue'; import ToggleViewer from '/imports/client/ui/properties/viewers/ToggleViewer.vue'; @@ -52,7 +51,6 @@ export default { roll: RollViewer, reference: ReferenceViewer, savingThrow: SavingThrowViewer, - slotFiller: SlotFillerViewer, skill: SkillViewer, spellList: SpellListViewer, spell: SpellViewer, diff --git a/app/imports/client/ui/router.js b/app/imports/client/ui/router.js index b7e473be..4f3fe2cc 100644 --- a/app/imports/client/ui/router.js +++ b/app/imports/client/ui/router.js @@ -9,6 +9,7 @@ const CharacterListToolbarItems = () => import('/imports/client/ui/creature/crea const Library = () => import('/imports/client/ui/pages/Library.vue'); const LibraryCollection = () => import('/imports/client/ui/pages/LibraryCollection.vue'); const LibraryCollectionToolbar = () => import('/imports/client/ui/library/LibraryCollectionToolbar.vue'); +const LibraryBrowser = () => import('/imports/client/ui/pages/LibraryBrowser.vue'); const CharacterSheetPage = () => import('/imports/client/ui/pages/CharacterSheetPage.vue'); const CharacterSheetToolbar = () => import('/imports/client/ui/creature/character/CharacterSheetToolbar.vue'); const CharacterSheetRightDrawer = () => import('/imports/client/ui/creature/character/CharacterSheetRightDrawer.vue'); @@ -168,6 +169,15 @@ RouterFactory.configure(router => { meta: { title: 'Library Collection', }, + }, { + name: 'libraryBrowser', + path: '/community-libraries', + components: { + default: LibraryBrowser, + }, + meta: { + title: 'Community Libraries', + }, }, { name: 'characterSheet', path: '/character/:id', @@ -377,7 +387,11 @@ RouterFactory.configure(router => { function redirectIfMaintenance(to, from, next) { if (!MAINTENANCE_MODE) return next(); - if (to?.path === '/admin' || to?.path === '/maintenance' || to?.path === '/sign-in') return next(); + if ( + to?.path === '/admin' || + to?.path === '/maintenance' || + to?.path === '/sign-in' + ) return next(); Tracker.autorun((computation) => { if (userSubscription.ready()) { computation.stop(); diff --git a/app/imports/client/ui/sharing/ShareDialog.vue b/app/imports/client/ui/sharing/ShareDialog.vue index 7db4aa24..5b81c469 100644 --- a/app/imports/client/ui/sharing/ShareDialog.vue +++ b/app/imports/client/ui/sharing/ShareDialog.vue @@ -27,7 +27,7 @@ v-if="model.public && docRef.collection === 'libraries'" readonly label="Link" - :value="'https://beta.dicecloud.com' + $router.resolve({ + :value="'https://dicecloud.com' + $router.resolve({ name: 'singleLibrary', params: { id: model._id }, }).href" diff --git a/app/imports/client/ui/utility/numberFormatter.js b/app/imports/client/ui/utility/numberFormatter.js new file mode 100644 index 00000000..794498f3 --- /dev/null +++ b/app/imports/client/ui/utility/numberFormatter.js @@ -0,0 +1,3 @@ +const formatter = Intl.NumberFormat('en', { notation: 'compact' }); + +export default formatter; \ No newline at end of file diff --git a/app/imports/client/ui/vuexStore.js b/app/imports/client/ui/vuexStore.js index d4578aa0..fec340cd 100644 --- a/app/imports/client/ui/vuexStore.js +++ b/app/imports/client/ui/vuexStore.js @@ -17,6 +17,7 @@ const store = new Vuex.Store({ pageTitle: undefined, characterSheetTabs: {}, showDetailsDialog: false, + formExpansions: {}, }, getters: { tabById: (state) => (id) => { @@ -30,7 +31,10 @@ const store = new Vuex.Store({ } else { return tabs[tabNumber] } - } + }, + formExpansionByType: (state) => (type) => { + return state.formExpansions[type] || []; + }, }, mutations: { toggleDrawer(state) { @@ -68,6 +72,9 @@ const store = new Vuex.Store({ setShowDetailsDialog(state, value) { state.showDetailsDialog = value; }, + setFormExpansion(state, { type, value }) { + state.formExpansions[type] = value; + }, }, }); diff --git a/app/imports/constants/PROPERTIES.js b/app/imports/constants/PROPERTIES.js index f3db0cbe..4686082c 100644 --- a/app/imports/constants/PROPERTIES.js +++ b/app/imports/constants/PROPERTIES.js @@ -170,13 +170,6 @@ const PROPERTIES = Object.freeze({ helpText: 'A slot in the character sheet is used to specify that a property needs to be selected from a library to fill the slot. The slot can determine what tags it is looking for, and any subscribed library property with matching tags can fill the slot', suggestedParents: [], }, - slotFiller: { - icon: 'mdi-power-plug-outline', - name: 'Slot filler', - docsPath: 'property/slot-filler', - helpText: 'A slot filler allows for more advanced logic when it attempts to fill a slot. It can masquarade as any property type, and calculate whether it should fill a slot or not.', - suggestedParents: ['propertySlot'], - }, spellList: { icon: '$vuetify.icons.spell_list', name: 'Spell list', @@ -209,14 +202,25 @@ const PROPERTIES = Object.freeze({ export default PROPERTIES; -export function getPropertyName(type){ - return type && PROPERTIES[type] && PROPERTIES[type].name; +export function getPropertyName(type) { + return (type && PROPERTIES[type] && PROPERTIES[type].name) || type; } -export function getPropertyIcon(type){ +export function getPropertyIcon(type) { return type && PROPERTIES[type] && PROPERTIES[type].icon; } +export function getSuggestedChildren(type) { + const suggestions = []; + for (const key in PROPERTIES) { + const prop = PROPERTIES[key]; + if (prop.suggestedParents.includes(type)) { + suggestions.push({ type: key, details: prop }); + } + } + return suggestions; +} + const propsByDocsPath = new Map(); for (const key in PROPERTIES) { diff --git a/app/imports/constants/SCHEMA_VERSION.js b/app/imports/constants/SCHEMA_VERSION.js index 4ac57384..829e4147 100644 --- a/app/imports/constants/SCHEMA_VERSION.js +++ b/app/imports/constants/SCHEMA_VERSION.js @@ -1,3 +1,3 @@ -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; export default SCHEMA_VERSION; diff --git a/app/imports/constants/VARIABLE_NAME_REGEX.js b/app/imports/constants/VARIABLE_NAME_REGEX.js index ac7c1804..8ea249ca 100644 --- a/app/imports/constants/VARIABLE_NAME_REGEX.js +++ b/app/imports/constants/VARIABLE_NAME_REGEX.js @@ -1,4 +1,4 @@ // Must contain a letter, and be made of word characters only -const VARIABLE_NAME_REGEX = /^[a-z][\w-]*$/i; +const VARIABLE_NAME_REGEX = /^[~#]?[a-zA-Z]*[a-ce-zA-Z][a-zA-Z0-9_]*$/i; export default VARIABLE_NAME_REGEX; diff --git a/app/imports/migrations/archive/cleanArchiveAt2.js b/app/imports/migrations/archive/cleanArchiveAt2.js new file mode 100644 index 00000000..224734ba --- /dev/null +++ b/app/imports/migrations/archive/cleanArchiveAt2.js @@ -0,0 +1,16 @@ +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; + +export default function cleanAt2(archive) { + archive.properties = archive.properties.map(prop => { + let cleanProp = prop; + try { + const schema = CreatureProperties.simpleSchema(prop); + // Clean according to schema + cleanProp = schema.clean(prop); + schema.validate(cleanProp); + } catch (e) { + console.warn('Failed to clean archive prop', { propId: prop._id, error: e.message || e.reason || e.toString() }); + } + return cleanProp; + }); +} diff --git a/app/imports/migrations/archive/migrateArchive.js b/app/imports/migrations/archive/migrateArchive.js new file mode 100644 index 00000000..029b146e --- /dev/null +++ b/app/imports/migrations/archive/migrateArchive.js @@ -0,0 +1,28 @@ +import migrateTo1 from './migrateArchiveTo1.js'; +import migrate1To2 from './migrateArchive1To2.js'; +import cleanAt2 from './cleanArchiveAt2.js'; + +/* eslint no-fallthrough: "off" -- Using switch fallthrough to run all +migration steps after the current version of the file. */ +export default function migrateArchive(archive) { + switch (archive.meta.schemaVersion) { + // V1 of DiceCloud + case 'version1': + migrateLegacyArchive(archive); + // V2 of DiceCloud, Schema version 1 + case 1: + migrateTo1(archive); + migrate1To2(archive); + // V2 of DiceCloud, Schema version 2 + case 2: + cleanAt2(archive); + break; + default: + throw 'Archive version not supported'; + } +} + +function migrateLegacyArchive() { + // TODO: + throw 'Not implemented'; +} diff --git a/app/imports/migrations/archive/migrateArchive1To2.js b/app/imports/migrations/archive/migrateArchive1To2.js new file mode 100644 index 00000000..6038c2a9 --- /dev/null +++ b/app/imports/migrations/archive/migrateArchive1To2.js @@ -0,0 +1,50 @@ +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js'; +import { get } from 'lodash'; + +const dollarSignRegex = /(\W)\$(\w+)/gi; + +export default function migrate1To2(archive) { + archive.properties = archive.properties.map(prop => { + try { + // Migrate slot fillers to folders + if (prop.type === 'slotFiller') { + prop.type = 'folder'; + } + // Migrate slot filler slot type to folders + if (prop.slotType === 'slotFiller') { + prop.slotType = 'folder'; + } + // Get the schema + const schema = CreatureProperties.simpleSchema(prop); + // Replace dollar signs in calculations with tildes + schema.inlineCalculationFields().forEach(key => { + applyFnToKey(prop, key, (prop, key) => { + const inlineCalcObj = get(prop, key); + const string = inlineCalcObj?.text; + if (!string) return; + const newString = string.replace(dollarSignRegex, '$1~$2'); + if (string !== newString) { + inlineCalcObj.text = newString; + inlineCalcObj.hash = null; + } + }); + }); + schema.computedFields().forEach(key => { + applyFnToKey(prop, key, (prop, key) => { + const inlineCalcObj = get(prop, key); + const string = inlineCalcObj?.calculation; + if (!string) return; + const newString = string.replace(dollarSignRegex, '$1~$2'); + if (string !== newString) { + inlineCalcObj.calculation = newString; + inlineCalcObj.hash = null; + } + }); + }); + } catch (e) { + console.warn('Property migration 1 -> 2 failed: ', { propId: prop._id, error: e.message || e.reason || e.toString() }); + } + return prop; + }); +} diff --git a/app/imports/migrations/server/dbv1/cleanAt1.js b/app/imports/migrations/archive/migrateArchiveTo1.js similarity index 62% rename from app/imports/migrations/server/dbv1/cleanAt1.js rename to app/imports/migrations/archive/migrateArchiveTo1.js index d58e3c74..6e8fd558 100644 --- a/app/imports/migrations/server/dbv1/cleanAt1.js +++ b/app/imports/migrations/archive/migrateArchiveTo1.js @@ -1,39 +1,45 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { get, set } from 'lodash'; import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js'; -import { calculationUp } from '/imports/migrations/server/dbv1/dbv1.js'; -export default function cleanAt1(archive){ + +function calculationUp(val) { + if (typeof val !== 'string') return val; + if (!val.replace) console.log({ val, replace: val.replace }); + return val.replace(/#(\w+).(\w+)Result/g, '#$1.$2') + .replace(/\.value/g, '.total') + .replace(/\.currentValue/g, '.value'); +} + +export default function migrateTo1(archive) { archive.properties = archive.properties.map(prop => { - let cleanProp = prop; try { if (prop.type === 'attack') prop.type = 'action'; + if (prop.type === 'slotFiller') prop.type = 'folder'; // Get the schema const schema = CreatureProperties.simpleSchema(prop); // Clean all the text fields with inline calcs schema.inlineCalculationFields().forEach(key => { applyFnToKey(prop, key, (prop, key) => { let field = get(prop, key); - if (typeof field === 'string' || typeof field === 'number'){ + if (typeof field === 'string' || typeof field === 'number') { field = calculationUp(field); - set(prop, key, {text: `${field}`}); + set(prop, key, { text: `${field}` }); } }); }); schema.computedFields().forEach(key => { applyFnToKey(prop, key, (prop, key) => { let field = get(prop, key) || get(prop, key + 'Calculation'); - if (typeof field === 'string' || typeof field === 'number'){ + if (typeof field === 'string' || typeof field === 'number') { field = calculationUp(field); - set(prop, key, {calculation: `${field}`}); + set(prop, key, { calculation: `${field}` }); } }); }); - cleanProp = schema.clean(prop); - schema.validate(cleanProp); - } catch (e){ - console.warn({propId: prop._id, error: e.message || e.reason || e.toString()}); + } catch (e) { + console.warn('Property migration -> 1 failed: ', { propId: prop._id, error: e.message || e.reason || e.toString() }); } - return cleanProp; + return prop; }); } diff --git a/app/imports/migrations/server/dbv1/dbv1.js b/app/imports/migrations/server/dbv1/dbv1.js index 8ecfef81..afcce135 100644 --- a/app/imports/migrations/server/dbv1/dbv1.js +++ b/app/imports/migrations/server/dbv1/dbv1.js @@ -10,52 +10,52 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; Migrations.add({ version: 1, name: 'Unifies calculated field schema', - up(){ + up() { migrate(); }, - down(){ - migrate({reversed: true}); + down() { + migrate({ reversed: true }); }, }); -function migrate({reversed} = {}){ +function migrate({ reversed } = {}) { console.log('migrating creature properties'); - migrateCollection({collection: CreatureProperties, reversed}); + migrateCollection({ collection: CreatureProperties, reversed }); console.log('migrating library nodes') - migrateCollection({collection: LibraryNodes, reversed}); + migrateCollection({ collection: LibraryNodes, reversed }); } -function migrateCollection({collection, reversed}){ +function migrateCollection({ collection, reversed }) { const bulk = collection.rawCollection().initializeUnorderedBulkOp(); collection.find({}).forEach(prop => { - const newProp = migrateProperty({collection, reversed, prop}); + const newProp = migrateProperty({ collection, reversed, prop }); bulk.find({ _id: prop._id }).replaceOne(newProp); }); bulk.execute(); } -export function migrateProperty({collection, reversed, prop}){ +export function migrateProperty({ collection, reversed, prop }) { const transforms = [ ...(transformsByPropType[prop.type] || []), - {from: 'dependencies'} + { from: 'dependencies' } ]; let migratedProp = transformFields(prop, transforms, reversed); - const schema = collection.simpleSchema({type: migratedProp.type}); + const schema = collection.simpleSchema({ type: migratedProp.type }); // Only clean if the schema version matches our destination version - if(!reversed && SCHEMA_VERSION === 1){ + if (!reversed && SCHEMA_VERSION >= 1) { try { migratedProp = schema.clean(migratedProp); schema.validate(migratedProp); - } catch(e){ - if (e.details[0]?.type === 'maxString'){ + } catch (e) { + if (e.details[0]?.type === 'maxString') { console.log({ prop: prop, details: e.details, }); } else { - console.warn({prop, error: e}); + console.warn({ prop, error: e }); } } } @@ -74,31 +74,31 @@ const transformsByPropType = { 'action': actionTransforms, 'adjustment': [ ...getComputedPropertyTransforms('amount'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'attack': [ ...actionTransforms, ...getComputedPropertyTransforms('rollBonus', 'attackRoll'), //change type to action - {from: 'type', to: 'type', up: () => 'action'}, + { from: 'type', to: 'type', up: () => 'action' }, ], 'attribute': [ // from: baseValue must be first or else it will delete the field we need - {from: 'baseValue', to: 'baseValue.value', up: nanToNull}, - {from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown}, - {from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors}, + { from: 'baseValue', to: 'baseValue.value', up: nanToNull }, + { from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown }, + { from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors }, ...getComputedPropertyTransforms('spellSlotLevel'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'total', up: nanToNull}, - {from: 'currentValue', to: 'value', up: nanToNull}, - {from: 'proficiency', to: 'proficiency', up: stripZero}, + { from: 'value', to: 'total', up: nanToNull }, + { from: 'currentValue', to: 'value', up: nanToNull }, + { from: 'proficiency', to: 'proficiency', up: stripZero }, ], 'buff': [ ...getComputedPropertyTransforms('duration'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'total', up: nanToNull}, - {from: 'target', to: 'target', up: simplifyTarget}, - {from: 'applied'}, + { from: 'value', to: 'total', up: nanToNull }, + { from: 'target', to: 'target', up: simplifyTarget }, + { from: 'applied' }, ], 'classLevel': [ ...getInlineComputationTransforms('description'), @@ -108,20 +108,22 @@ const transformsByPropType = { ], 'damage': [ ...getComputedPropertyTransforms('amount'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'effect': [ - {from: 'calculation', to: 'amount.calculation'}, - {from: 'result', to: 'amount.value', up: nanToNull}, - {from: 'errors', to: 'amount.errors', up: trimErrors}, - {from: 'name', to: 'name', up(val, src, doc){ - if (src.operation === 'conditional'){ - doc.text = val; - return; - } else { - return val; + { from: 'calculation', to: 'amount.calculation' }, + { from: 'result', to: 'amount.value', up: nanToNull }, + { from: 'errors', to: 'amount.errors', up: trimErrors }, + { + from: 'name', to: 'name', up(val, src, doc) { + if (src.operation === 'conditional') { + doc.text = val; + return; + } else { + return val; + } } - }}, + }, ], 'feature': [ ...getInlineComputationTransforms('summary'), @@ -139,20 +141,20 @@ const transformsByPropType = { ], 'savingThrow': [ ...getComputedPropertyTransforms('dc'), - {from: 'target', to: 'target', up: simplifyTarget}, + { from: 'target', to: 'target', up: simplifyTarget }, ], 'skill': [ ...getComputedPropertyTransforms('baseValue'), ...getInlineComputationTransforms('description'), - {from: 'value', to: 'value', up: nanToNull}, - {from: 'passiveBonus', to: 'passiveBonus', up: nanToNull}, - {from: 'proficiency', to: 'proficiency', up: stripZero}, + { from: 'value', to: 'value', up: nanToNull }, + { from: 'passiveBonus', to: 'passiveBonus', up: nanToNull }, + { from: 'proficiency', to: 'proficiency', up: stripZero }, ], 'spell': [ ...actionTransforms, ], 'proficiency': [ - {from: 'value', to: 'value', up: stripZero}, + { from: 'value', to: 'value', up: stripZero }, ], 'propertySlot': [ ...getComputedPropertyTransforms('quantityExpected'), @@ -166,70 +168,70 @@ const transformsByPropType = { ...getInlineComputationTransforms('description'), ], 'toggle': [ - {from: 'condition', to: 'condition.calculation'}, - {from: 'toggleResult', to: 'condition.value', up: nanToNull}, - {from: 'errors', to: 'condition.errors', up: trimErrors}, + { from: 'condition', to: 'condition.calculation' }, + { from: 'toggleResult', to: 'condition.value', up: nanToNull }, + { from: 'errors', to: 'condition.errors', up: trimErrors }, ], }; -function getComputedPropertyTransforms(key, toKey){ +function getComputedPropertyTransforms(key, toKey) { if (!toKey) toKey = key; return [ - {from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown}, - {from: `${key}Result`, to: `${toKey}.value`, up: nanToNull}, - {from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors}, + { from: key, to: `${toKey}.calculation`, up: calculationUp, down: calculationDown }, + { from: `${key}Result`, to: `${toKey}.value`, up: nanToNull }, + { from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors }, ]; } -function getInlineComputationTransforms(key){ +function getInlineComputationTransforms(key) { return [ - {from: key, to: `${key}.text`, up: calculationUp, down: calculationDown}, - {from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown}, - {from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`}, + { from: key, to: `${key}.text`, up: calculationUp, down: calculationDown }, + { from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown }, + { from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value` }, ]; } -export function calculationUp(val){ +export function calculationUp(val) { if (typeof val !== 'string') return val; - if (!val.replace) console.log({val, replace: val.replace}); + if (!val.replace) console.log({ val, replace: val.replace }); return val.replace(/#(\w+).(\w+)Result/g, '#$1.$2') .replace(/\.value/g, '.total') .replace(/\.currentValue/g, '.value'); } -function calculationDown(val){ +function calculationDown(val) { if (typeof val !== 'string') return val; return val.replace(/\.value/g, '.currentValue').replace(/\.total/g, '.value'); } -function nanToNull(val){ - if (Number.isNaN(val)){ +function nanToNull(val) { + if (Number.isNaN(val)) { return null; } else { return val; } } -function stripZero(val){ - if (val === 0){ +function stripZero(val) { + if (val === 0) { return undefined; } else { return val; } } -function simplifyTarget(val){ - if (val === 'self'){ +function simplifyTarget(val) { + if (val === 'self') { return val; } else { return 'target'; } } -function trimErrors(arr){ - if(!arr) return arr; +function trimErrors(arr) { + if (!arr) return arr; arr.forEach(e => { - if (e.message.length > STORAGE_LIMITS.errorMessage){ + if (e.message.length > STORAGE_LIMITS.errorMessage) { e.message = e.message.slice(0, STORAGE_LIMITS.errorMessage); } }); diff --git a/app/imports/migrations/server/dbv1/dbv1.test.js b/app/imports/migrations/server/dbv1/dbv1.test.js index efbd4fdd..edc81d81 100644 --- a/app/imports/migrations/server/dbv1/dbv1.test.js +++ b/app/imports/migrations/server/dbv1/dbv1.test.js @@ -31,7 +31,7 @@ const exampleAction = { 'ancestors': [{ 'collection': 'creatures', 'id': 'X9rzFhsgFhodYfHmG' - }, ], + },], 'order': 315, 'summary': 'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}', 'uses': '1', @@ -45,21 +45,21 @@ const exampleAction = { 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' }], 'summaryCalculations': [{ - 'calculation': 'warlock.level >14 ? "" : "the target dies, or"', - 'result': 'the target dies, or' - }, - { - 'calculation': 'proficiencyBonus', - 'result': '4' - }, - { - 'calculation': 'warlock.level+charisma.modifier', - 'result': '15' - }, - { - 'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', - 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' - } + 'calculation': 'warlock.level >14 ? "" : "the target dies, or"', + 'result': 'the target dies, or' + }, + { + 'calculation': 'proficiencyBonus', + 'result': '4' + }, + { + 'calculation': 'warlock.level+charisma.modifier', + 'result': '15' + }, + { + 'calculation': 'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', + 'result': '- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' + } ] }; @@ -76,13 +76,13 @@ const exampleAttribute = { 'collection': 'creatureProperties' }, ancestors: [{ - 'collection': 'creatures', - 'id': 'm9sdCvs6iDf7qRaGv' - }, - { - 'id': '8jSWKxvgQyKbunFtD', - 'collection': 'creatureProperties' - } + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, + { + 'id': '8jSWKxvgQyKbunFtD', + 'collection': 'creatureProperties' + } ], order: 84, value: 20, @@ -110,13 +110,13 @@ const expectedMigratedAttribute = { 'collection': 'creatureProperties' }, ancestors: [{ - 'collection': 'creatures', - 'id': 'm9sdCvs6iDf7qRaGv' - }, - { - 'id': '8jSWKxvgQyKbunFtD', - 'collection': 'creatureProperties' - } + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, + { + 'id': '8jSWKxvgQyKbunFtD', + 'collection': 'creatureProperties' + } ], order: 84, total: 20, @@ -205,11 +205,10 @@ const expectedMigratedAttack = { }], 'order': 56, 'usesUsed': 2, - libraryTags: [], } -describe('migrateProperty', function() { - it('Migrates actions reversibly', function() { +describe('migrateProperty', function () { + it('Migrates actions reversibly', function () { const action = { ...exampleAction }; @@ -226,7 +225,7 @@ describe('migrateProperty', function() { assert.deepEqual(action, exampleAction, 'action should not be bashed'); assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible'); }); - it('Migrates attributes as expected', function() { + it('Migrates attributes as expected', function () { const attribute = { ...exampleAttribute }; @@ -237,7 +236,7 @@ describe('migrateProperty', function() { assert.deepEqual(newAttribute, expectedMigratedAttribute, 'Attribute should match the expected result'); }); - it('Migrates attacks as expected', function() { + it('Migrates attacks as expected', function () { const attribute = { ...exampleAttack }; diff --git a/app/imports/migrations/server/dbv2/dbv2.js b/app/imports/migrations/server/dbv2/dbv2.js new file mode 100644 index 00000000..9df7b2ce --- /dev/null +++ b/app/imports/migrations/server/dbv2/dbv2.js @@ -0,0 +1,153 @@ +import { Migrations } from 'meteor/percolate:migrations'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { union, get } from 'lodash'; +import Libraries from '/imports/api/library/Libraries.js'; +import LibraryCollections from '/imports/api/library/LibraryCollections.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex.js'; +import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js'; + +// Git version 2.0.52 +// Database version 2 +Migrations.add({ + version: 2, + name: 'Separates creature property tags from library tags', + + up() { + console.log('migrating up library nodes 1 -> 2'); + migrateCollection(LibraryNodes, migratePropUp); + console.log('migrating up creature props 1 -> 2'); + migrateCollection(CreatureProperties, migratePropUp); + console.log('Migrating up libraries and collections to count subscribers'); + countSubscribers(); + }, + + down() { + console.log('Migrating down library nodes 2 -> 1'); + migrateCollection(LibraryNodes, migratePropDown); + console.log('Migrating down creature props 2 -> 1'); + migrateCollection(CreatureProperties, migratePropDown); + }, + +}); + +function migrateCollection(collection, migrateDoc) { + const bulk = collection.rawCollection().initializeUnorderedBulkOp(); + collection.find({}).forEach(doc => migrateDoc(bulk, doc, collection)); + bulk.execute(); +} + +export function migratePropUp(bulk, prop, collection) { + let update; + if (prop.type === 'slotFiller') { + update = update || { $set: {} }; + // Change the type to folder, slotFiller is deprecated + update.$set.type = 'folder' + // If the slot filler has an image set, move it + if (typeof prop.picture === 'string') { + update.$set.slotFillImage = prop.picture; + update.$unset = { picture: 1 }; + } + } + + // Don't look for slot fillers + if (prop.slotType === 'slotFiller') { + update = update || { $set: {} }; + update.$set.slotType = 'folder' + } + + // If there are tags, copy them to libraryTags and set findable flags + if (Array.isArray(prop.tags) && prop.tags.length && collection === LibraryNodes) { + update = update || { $set: {} }; + update.$set.libraryTags = prop.tags; + update.$set.fillSlots = true; + update.$set.searchable = true; + } + + // Replace dollar sign with tilde in calculated fields + update = dollarSignToTilde(prop, update); + + // Add the update to the bulk op + if (update) { + bulk.find({ _id: prop._id }).updateOne(update); + } +} + +export function migratePropDown(bulk, prop) { + const update = { + $unset: { + slotFillImage: 1, + slotFillerCondition: 1, + libraryTags: 1, + fillSlots: 1, + searchable: 1, + } + }; + if (prop.libraryTags?.length) { + update.$set = { + tags: union(prop.libraryTags, prop.tags) + } + } + bulk.find({ _id: prop._id }).updateOne(update); +} + +function countSubscribers() { + const bulkLib = Libraries.rawCollection().initializeUnorderedBulkOp(); + Libraries.find({}, { + fields: { _id: 1 } + }).forEach(lib => { + bulkLib.find({ _id: lib._id }).updateOne({ + $set: { + subscriberCount: Meteor.users.find({ subscribedLibraries: lib._id }).count(), + } + }); + }); + bulkLib.execute(); + + const bulkLibCols = Libraries.rawCollection().initializeUnorderedBulkOp(); + LibraryCollections.find({}, { + fields: { _id: 1 } + }).forEach(col => { + bulkLibCols.find({ _id: col._id }).updateOne({ + $set: { + subscriberCount: Meteor.users.find({ subscribedLibraryCollections: col._id }).count(), + } + }); + }); + bulkLibCols.execute(); +} + +const dollarSignRegex = /(\W)\$(\w+)/gi; +function dollarSignToTilde(prop, update) { + computedSchemas[prop.type]?.inlineCalculationFields()?.forEach(calcKey => { + applyFnToKey(prop, calcKey, (prop, key) => { + const inlineCalcObj = get(prop, key); + const string = inlineCalcObj?.text; + if (!string) return; + const newString = string.replace(dollarSignRegex, '$1~$2'); + if (string !== newString) { + // If changed + update = update || { $set: {} }; + if (!update.$unset) update.$unset = {}; + update.$unset[key + '.hash'] = 1; // zero the hash so it re-parses the calculation + update.$set[key + '.text'] = newString + } + }); + }); + computedSchemas[prop.type]?.computedFields()?.forEach(calcKey => { + applyFnToKey(prop, calcKey, (prop, key) => { + const inlineCalcObj = get(prop, key); + const string = inlineCalcObj?.calculation; + if (!string) return; + const newString = string.replace(dollarSignRegex, '$1~$2'); + if (string !== newString) { + // If changed + update = update || { $set: {} }; + if (!update.$unset) update.$unset = {}; + update.$unset[key + '.hash'] = 1; // remove the hash so it re-parses the calculation + update.$set[key + '.calculation'] = newString + } + }); + }); + return update; +} diff --git a/app/imports/migrations/server/dbv2/dbv2.test.js b/app/imports/migrations/server/dbv2/dbv2.test.js new file mode 100644 index 00000000..642f4c2f --- /dev/null +++ b/app/imports/migrations/server/dbv2/dbv2.test.js @@ -0,0 +1,179 @@ +import { migratePropUp, migratePropDown } from './dbv2.js'; +import { assert } from 'chai'; + +const exampleAttack = { + '_id': 'vw23EnJwBRcXEJg7i', + 'actionType': 'attack', + 'target': 'singleTarget', + 'tags': ['attack', 'magical', 'very cool'], + 'resources': { + 'itemsConsumed': [], + 'attributesConsumed': [] + }, + 'attackRoll': { + calculation: 'dexterity.modifier + proficiency$Bonus + 2 - hp.total + hp.value + $dollarSign', + hash: 1234567, + }, + 'summary': { + text: 'What if we {$had} two {$dollarSigns?} ', + hash: 123456, + }, + 'type': 'action', + 'name': 'Claws', + 'parent': { + 'id': 'Jpx8q3WjM5SCoGBm8', + 'collection': 'creatureProperties' + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv' + }, { + 'id': '3WS2xsSPAqB4eF9YH', + 'collection': 'creatureProperties' + }, { + 'id': 'rhYLEycvtHjcioaQL', + 'collection': 'creatureProperties' + }, { + 'id': 'Jpx8q3WjM5SCoGBm8', + 'collection': 'creatureProperties' + }], + 'order': 56, + 'usesUsed': 2, +}; + +const expectedAttackUpdate = { + $set: { + 'attackRoll.calculation': 'dexterity.modifier + proficiency$Bonus + 2 - hp.total + hp.value + ~dollarSign', + 'libraryTags': ['attack', 'magical', 'very cool'], + 'fillSlots': true, + 'searchable': true, + 'summary.text': 'What if we {~had} two {~dollarSigns?} ', + }, + $unset: { + 'attackRoll.hash': 1, + 'summary.hash': 1, + }, +}; + +const emptyFolderExample = { + _id: 'DXPYsHKF6W8Hh3hZs', + type: 'folder', + name: 'Empty Folder', + 'parent': { + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }], +}; + +const exampleSlotFiller = { + _id: 'DXPYsHKF6888h3hZs', + type: 'slotFiller', + name: 'Slot Filler Example', + 'picture': 'https://url.to.pic', + 'tags': ['slot', 'tags'], + 'parent': { + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }], +}; +const expectedSlotFillerUpdate = { + $set: { + 'libraryTags': ['slot', 'tags'], + 'fillSlots': true, + 'searchable': true, + 'slotFillImage': 'https://url.to.pic', + }, + $unset: { + picture: 1, + }, +}; + +const DownMergeExample = { + _id: 'DXPYsHKF6W8Hh3hZs', + type: 'feature', + name: 'Feature With Tags and library Tags', + 'parent': { + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }, + 'ancestors': [{ + 'collection': 'creatures', + 'id': 'm9sdCvs6iDf7qRaGv', + }], + 'libraryTags': ['tags', 'from', 'library'], + 'tags': ['attack', 'magical', 'very cool'], +}; + +const expectedDownMergeUpdate = { + $unset: { + slotFillImage: 1, + slotFillerCondition: 1, + libraryTags: 1, + fillSlots: 1, + searchable: 1, + }, + $set: { + tags: ['tags', 'from', 'library', 'attack', 'magical', 'very cool'], + } +}; + +describe('dbv2 Migrate library nodes', function () { + it('Migrates attacks up', function () { + const bulk = stubBulk(); + migratePropUp(bulk, exampleAttack); + const { query, update } = bulk.result(); + assert.deepEqual(query, { _id: 'vw23EnJwBRcXEJg7i' }, 'The query should match the id of the given prop'); + assert.deepEqual(update, expectedAttackUpdate, 'The update should match the expected update'); + }); + it('Migrates props without tags up', function () { + const bulk = stubBulk(); + migratePropUp(bulk, emptyFolderExample); + const { query, update, timesFind, timesUpdate } = bulk.result(); + assert.isUndefined(query, 'There should be no query on a prop with no tags'); + assert.equal(timesFind, 0, 'Find should be called zero times on a prop with no tags'); + assert.isUndefined(update, 'There should be no update on a prop with no tags'); + assert.equal(timesUpdate, 0, 'Update should be called zero times on a prop with no tags'); + }); + it('Migrates slot fillers up', function () { + const bulk = stubBulk(); + migratePropUp(bulk, exampleSlotFiller); + const { query, update } = bulk.result(); + assert.deepEqual(query, { _id: 'DXPYsHKF6888h3hZs' }, 'The query should match the id of the given prop'); + assert.deepEqual(update, expectedSlotFillerUpdate, 'The update should match the expected update'); + }); + it('Merges tags when down migrating', function () { + const bulk = stubBulk(); + migratePropDown(bulk, DownMergeExample); + const { query, update } = bulk.result(); + assert.deepEqual(query, { _id: 'DXPYsHKF6W8Hh3hZs' }, 'The query should match the id of the given prop'); + assert.deepEqual(update, expectedDownMergeUpdate, 'The update should match the expected update'); + }); +}); + +// Create a stub for bulk udateOne operations that accepts a single op +function stubBulk() { + let query, update, timesFind = 0, timesUpdate = 0; + return { + find(inputQuery) { + query = inputQuery; + timesFind += 1; + return { + updateOne(inputUpdate) { + update = inputUpdate; + timesUpdate += 1; + } + } + }, + result() { + return { query, update, timesFind, timesUpdate } + } + } +} \ No newline at end of file diff --git a/app/imports/migrations/server/index.js b/app/imports/migrations/server/index.js index 54ca97d0..07b974f8 100644 --- a/app/imports/migrations/server/index.js +++ b/app/imports/migrations/server/index.js @@ -1 +1,2 @@ import './dbv1/dbv1.js'; +import './dbv2/dbv2.js'; diff --git a/app/imports/migrations/server/migrateArchive.js b/app/imports/migrations/server/migrateArchive.js deleted file mode 100644 index 0fb557ed..00000000 --- a/app/imports/migrations/server/migrateArchive.js +++ /dev/null @@ -1,19 +0,0 @@ -import cleanAt1 from '/imports/migrations/server/dbv1/cleanAt1.js'; - -/* eslint no-fallthrough: "off" -- Using switch fallthrough to run all -migration steps after the current version of the file. */ -export default function migrateArchive(archive){ - switch (archive.meta.schemaVersion){ - // V1 of DiceCloud - case 'version1': - migrateLegacyArchive(archive); - // V2 of DiceCloud, Schema version 1 - case 1: - cleanAt1(archive); - } -} - -function migrateLegacyArchive(archive){ - // TODO: - throw 'Not implemented'; -} diff --git a/app/imports/parser/functions.js b/app/imports/parser/functions.js index 90a7e8df..9482fd9b 100644 --- a/app/imports/parser/functions.js +++ b/app/imports/parser/functions.js @@ -1,11 +1,12 @@ import resolve from '/imports/parser/resolve.js' +import rollDice from '/imports/parser/rollDice.js'; export default { 'abs': { comment: 'Returns the absolute value of a number', examples: [ - {input: 'abs(9)', result: '9'}, - {input: 'abs(-3)', result: '3'}, + { input: 'abs(9)', result: '9' }, + { input: 'abs(-3)', result: '3' }, ], arguments: ['number'], resultType: 'number', @@ -14,8 +15,8 @@ export default { 'sqrt': { comment: 'Returns the square root of a number', examples: [ - {input: 'sqrt(16)', result: '4'}, - {input: 'sqrt(10)', result: '3.1622776601683795'}, + { input: 'sqrt(16)', result: '4' }, + { input: 'sqrt(10)', result: '3.1622776601683795' }, ], arguments: ['number'], resultType: 'number', @@ -23,14 +24,14 @@ export default { }, 'max': { comment: 'Returns the largest of the given numbers', - examples: [{input: 'max(12, 6, 3, 168)', result: '168'}], + examples: [{ input: 'max(12, 6, 3, 168)', result: '168' }], arguments: anyNumberOf('number'), resultType: 'number', fn: Math.max, }, 'min': { comment: 'Returns the smallest of the given numbers', - examples: [{input: 'min(12, 6, 3, 168)', result: '3'}], + examples: [{ input: 'min(12, 6, 3, 168)', result: '3' }], arguments: anyNumberOf('number'), resultType: 'number', fn: Math.min, @@ -38,9 +39,9 @@ export default { 'round': { comment: 'Returns the value of a number rounded to the nearest integer', examples: [ - {input: 'round(5.95)', result: '6'}, - {input: 'round(5.5)', result: '6'}, - {input: 'round(5.05)', result: '5'}, + { input: 'round(5.95)', result: '6' }, + { input: 'round(5.5)', result: '6' }, + { input: 'round(5.05)', result: '5' }, ], arguments: ['number'], resultType: 'number', @@ -49,10 +50,10 @@ export default { 'floor': { comment: 'Rounds a number down to the next smallest integer', examples: [ - {input: 'floor(5.95)', result: '5'}, - {input: 'floor(5.05)', result: '5'}, - {input: 'floor(5)', result: '5'}, - {input: 'floor(-5.5)', result: '-6'}, + { input: 'floor(5.95)', result: '5' }, + { input: 'floor(5.05)', result: '5' }, + { input: 'floor(5)', result: '5' }, + { input: 'floor(-5.5)', result: '-6' }, ], arguments: ['number'], resultType: 'number', @@ -61,10 +62,10 @@ export default { 'ceil': { comment: 'Rounds a number up to the next largest integer', examples: [ - {input: 'ceil(5.95)', result: '6'}, - {input: 'ceil(5.05)', result: '6'}, - {input: 'ceil(5)', result: '5'}, - {input: 'ceil(-5.5)', result: '-5'}, + { input: 'ceil(5.95)', result: '6' }, + { input: 'ceil(5.05)', result: '6' }, + { input: 'ceil(5)', result: '5' }, + { input: 'ceil(-5.5)', result: '-5' }, ], arguments: ['number'], resultType: 'number', @@ -73,21 +74,21 @@ export default { 'trunc': { comment: 'Returns the integer part of a number by removing any fractional digits', examples: [ - {input: 'trunc(5.95)', result: '5'}, - {input: 'trunc(5.05)', result: '5'}, - {input: 'trunc(5)', result: '5'}, - {input: 'trunc(-5.5)', result: '-5'}, + { input: 'trunc(5.95)', result: '5' }, + { input: 'trunc(5.05)', result: '5' }, + { input: 'trunc(5)', result: '5' }, + { input: 'trunc(-5.5)', result: '-5' }, ], - arguments:[ 'number'], + arguments: ['number'], resultType: 'number', fn: Math.trunc, }, 'sign': { comment: 'Returns either a positive or negative 1, indicating the sign of a number, or zero', examples: [ - {input: 'sign(-3)', result: '-1'}, - {input: 'sign(3)', result: '1'}, - {input: 'sign(0)', result: '0'}, + { input: 'sign(-3)', result: '-1' }, + { input: 'sign(3)', result: '1' }, + { input: 'sign(0)', result: '0' }, ], arguments: ['number'], resultType: 'number', @@ -96,15 +97,15 @@ export default { 'tableLookup': { comment: 'Returns the index of the last value in the array that is less than the specified amount', examples: [ - {input: 'tableLookup([100, 300, 900], 457)', result: '2'}, - {input: 'tableLookup([100, 300, 900], 23)', result: '0'}, - {input: 'tableLookup([100, 300, 900, 1200], 900)', result: '3'}, - {input: 'tableLookup([100, 300], 594)', result: '2'}, + { input: 'tableLookup([100, 300, 900], 457)', result: '2' }, + { input: 'tableLookup([100, 300, 900], 23)', result: '0' }, + { input: 'tableLookup([100, 300, 900, 1200], 900)', result: '3' }, + { input: 'tableLookup([100, 300], 594)', result: '2' }, ], arguments: ['array', 'number'], resultType: 'number', - fn: function tableLookup(arrayNode, number){ - for(let i in arrayNode.values){ + fn: function tableLookup(arrayNode, number) { + for (let i in arrayNode.values) { let node = arrayNode.values[i]; if (node.value > number) return +i; } @@ -114,18 +115,146 @@ export default { 'resolve': { comment: 'Forces the given calcultion to resolve into a number, even in calculations where it would usually keep the unknown values as is', examples: [ - {input: 'resolve(someUndefinedVariable + 3 + 4)', result: '7'}, - {input: 'resolve(1d6)', result: '4'}, + { input: 'resolve(someUndefinedVariable + 3 + 4)', result: '7' }, + { input: 'resolve(1d6)', result: '4' }, ], arguments: ['parseNode'], - fn: function resolveFn(node){ - let {result} = resolve('reduce', node, this.scope, this.context); + fn: function resolveFn(node) { + let { result } = resolve('reduce', node, this.scope, this.context); return result; } - } + }, + 'dropLowest': { + comment: 'Removes one or more of the lowest values in a roll', + examples: [ + ], + arguments: ['rollArray', 'number'], + maxResolveLevels: ['roll', 'reduce'], + minArguments: 1, + maxArguments: 2, + resultType: 'rollArray', + fn: function dropLowestFn(rollArray, numberToDrop = 1) { + // Create a new array where the values are sorted in ascending order + const sortedArray = [...rollArray.values].sort(function (a, b) { + return a.value - b.value; + }); + + // mark the N smallest elements as dropped + for (let i = 0; i < numberToDrop; i += 1) { + console.log('dropped ' + sortedArray[i].value); + sortedArray[i].disabled = true; + sortedArray[i].disabledBy = 'dropLowest'; + } + return rollArray; + }, + }, + 'dropHighest': { + comment: 'Removes one or more of the highest values in a roll', + examples: [ + ], + arguments: ['rollArray', 'number'], + maxResolveLevels: ['roll', 'reduce'], + minArguments: 1, + maxArguments: 2, + resultType: 'rollArray', + fn: function dropHighestFn(rollArray, numberToDrop = 1) { + // Create a new array where the values are sorted in ascending order + const sortedArray = [...rollArray.values].sort(function (a, b) { + return b.value - a.value; + }); + + // mark the N smallest elements as dropped + for (let i = 0; i < numberToDrop; i += 1) { + sortedArray[i].disabled = true; + sortedArray[i].disabledBy = 'dropHighest'; + } + return rollArray; + }, + }, + 'reroll': { + comment: 'Rerolls if a number is less than or equal to the given value', + examples: [ + ], + arguments: ['rollArray', 'number', 'boolean'], + maxResolveLevels: ['roll', 'reduce'], + minArguments: 1, + maxArguments: 3, + resultType: 'rollArray', + fn: function rerollFn(rollArray, numberToReroll = 1, keepNewRoll = false) { + let rollValues = rollArray.values + // Iterate through the roll values + for (let i = 0; i < rollValues.length; i += 1) { + // If the number is less than the reroll limit + if (rollValues[i].value <= numberToReroll) { + // Disable it + rollValues[i].disabled = true; + rollValues[i].disabledBy = 'reroll'; + // Roll it again, insert the new roll into the list at the next index + rollValues.splice(i + 1, 0, { + value: rollDice(1, rollArray.diceSize)[0], + }); + // Skip iterating the inserted roll if we are forced to keep it + if (keepNewRoll) { + i += 1; + } + } + if (i >= 100) { + this.context.error('Can\'t roll more than 100 dice at once'); + return rollArray; + } + } + return rollArray; + }, + }, + 'explode': { + comment: 'Rerolls if a number is greater than or equal to the given value', + examples: [ + ], + arguments: ['rollArray', 'number', 'number'], + maxResolveLevels: ['roll', 'reduce', 'reduce'], + minArguments: 1, + maxArguments: 3, + resultType: 'rollArray', + fn: function explodeFn(rollArray, depth = 1, numberToReroll = rollArray.diceSize) { + let overflowErrored = false; + if (depth > 99) depth = 99; + let rollValues = rollArray.values + // Iterate through the roll values + for (let i = 0; i < rollValues.length; i += 1) { + // If the number is greater than or equal to the reroll limit + // And there is space to reroll it + if (rollValues[i].value >= numberToReroll) { + rollValues[i].bold = true; + let explodeDepth = 1; + let explodeRoll; + do { + // Before inserting this roll, make sure the total dice in the roll + // Doesn't exceed 100 + if (rollValues.length >= 100) { + if (!overflowErrored) { + this.context.error('Can\'t roll more than 100 dice at once'); + overflowErrored = true; + } + break; + } + explodeDepth += 1; + explodeRoll = rollDice(1, rollArray.diceSize)[0]; + const rollObj = { + value: explodeRoll, + italics: true, + }; + // Insert the roll + rollValues.splice(i + 1, 0, rollObj); + i += 1; + } while (explodeDepth <= depth && explodeRoll >= numberToReroll) + } + } + return rollArray; + }, + }, } -function anyNumberOf(type){ +function anyNumberOf(type) { let argumentArray = [type]; argumentArray.anyLength = true; return argumentArray; diff --git a/app/imports/parser/grammar.js b/app/imports/parser/grammar.js index 43f5b70f..8f513674 100644 --- a/app/imports/parser/grammar.js +++ b/app/imports/parser/grammar.js @@ -13,7 +13,7 @@ function id(x) { return x[0]; } value: s => s.slice(1, -1).replace('\\n', '\n'), }, name: { - match: /[a-zA-Z_#$]*[a-ce-zA-Z_#$][a-zA-Z0-9_#$]*/, + match: /[~#]?[a-zA-Z]*[a-ce-zA-Z][a-zA-Z0-9_]*/, type: moo.keywords({ 'keywords': ['true', 'false'], }), diff --git a/app/imports/parser/grammar.ne b/app/imports/parser/grammar.ne index 3ebdba75..e008ac54 100644 --- a/app/imports/parser/grammar.ne +++ b/app/imports/parser/grammar.ne @@ -11,7 +11,7 @@ value: s => s.slice(1, -1).replace('\\n', '\n'), }, name: { - match: /[a-zA-Z_#$]*[a-ce-zA-Z_#$][a-zA-Z0-9_#$]*/, + match: /[~#]?[a-zA-Z]*[a-ce-zA-Z][a-zA-Z0-9_]*/, type: moo.keywords({ 'keywords': ['true', 'false'], }), diff --git a/app/imports/parser/parseTree/call.js b/app/imports/parser/parseTree/call.js index 658c5f49..24b7d3c9 100644 --- a/app/imports/parser/parseTree/call.js +++ b/app/imports/parser/parseTree/call.js @@ -4,14 +4,14 @@ import functions from '/imports/parser/functions.js'; import resolve, { toString, traverse, map } from '../resolve.js'; const call = { - create({functionName, args}) { + create({ functionName, args }) { return { parseType: 'call', functionName, args, } }, - resolve(fn, node, scope, context){ + resolve(fn, node, scope, context) { let func = functions[node.functionName]; // Check that the function exists if (!func) { @@ -25,9 +25,22 @@ const call = { }; } + // Resolve a given node to a maximum depth of resolution + const resolveToLevel = (node, maxResolveFn = 'reduce') => { + // Determine the actual depth to resolve to + let resolveFn = 'reduce'; + if (fn === 'compile' || maxResolveFn === 'compile') { + resolveFn = 'compile'; + } else if (fn === 'roll' || maxResolveFn === 'roll') { + resolveFn = 'roll'; + } + // Resolve + return resolve(resolveFn, node, scope, context); + } + // Resolve the arguments - let resolvedArgs = node.args.map(arg => { - let { result } = resolve(fn, arg, scope, context); + let resolvedArgs = node.args.map((arg, i) => { + let { result } = resolveToLevel(arg, func.maxResolveLevels?.[i]); return result; }); @@ -36,12 +49,12 @@ const call = { node, fn, resolvedArgs, - argumentsExpected: func.arguments, + func, context, }); - if (checkFailed){ - if (fn === 'reduce'){ + if (checkFailed) { + if (fn === 'reduce') { context.error(`Invalid arguments to ${node.functionName} function`); return { result: error.create({ @@ -66,7 +79,7 @@ const call = { if ( arg.parseType === 'constant' && func.arguments[index] !== 'parseNode' - ){ + ) { return arg.value; } else { return arg; @@ -75,20 +88,21 @@ const call = { try { // Run the function - let value = func.fn.apply({scope, context}, mappedArgs); + let value = func.fn.apply({ + scope, + context, + }, mappedArgs); let valueType = typeof value; - if (valueType === 'number' || valueType === 'string' || valueType === 'boolean'){ + if (valueType === 'number' || valueType === 'string' || valueType === 'boolean') { // Convert constant results into constant nodes return { - result: constant.create({ value, valueType }), + result: constant.create({ value }), context, }; } else { - return { - result: value, - context, - }; + // Resolve the return value + return resolve(fn, value, scope, context); } } catch (error) { context.error(error.message || error); @@ -101,26 +115,28 @@ const call = { } } }, - toString(node){ + toString(node) { return `${node.functionName}(${node.args.map(arg => toString(arg)).join(', ')})`; }, - traverse(node, fn){ + traverse(node, fn) { fn(node); node.args.forEach(arg => traverse(arg, fn)); }, - map(node, fn){ + map(node, fn) { const resultingNode = fn(node); - if (resultingNode === node){ + if (resultingNode === node) { node.args = node.args.map(arg => map(arg, fn)); } return resultingNode; }, - checkArugments({node, fn, argumentsExpected, resolvedArgs, context}){ + checkArugments({ node, fn, func, resolvedArgs, context }) { + const argumentsExpected = func.arguments; // Check that the number of arguments matches the number expected if ( !argumentsExpected.anyLength && - argumentsExpected.length !== resolvedArgs.length - ){ + resolvedArgs.length > (func.maxArguments ?? argumentsExpected.length) || + resolvedArgs.length < (func.minArguments ?? argumentsExpected.length) + ) { context.error('Incorrect number of arguments ' + `to ${node.functionName} function, ` + `expected ${argumentsExpected.length} got ${resolvedArgs.length}`); @@ -131,14 +147,14 @@ const call = { // Check that each argument is of the correct type resolvedArgs.forEach((node, index) => { let type; - if (argumentsExpected.anyLength){ + if (argumentsExpected.anyLength) { type = argumentsExpected[0]; } else { type = argumentsExpected[index]; } if (type === 'parseNode') return; if (node.parseType !== type && node.valueType !== type) failed = true; - if (failed && fn === 'reduce'){ + if (failed && fn === 'reduce') { let typeName = typeof type === 'string' ? type : type.constructor.name; let nodeName = node.parseType; context.error(`Incorrect arguments to ${node.functionName} function` + diff --git a/app/imports/parser/parseTree/rollArray.js b/app/imports/parser/parseTree/rollArray.js index 969b948d..30f37240 100644 --- a/app/imports/parser/parseTree/rollArray.js +++ b/app/imports/parser/parseTree/rollArray.js @@ -4,7 +4,7 @@ const rollArray = { create({ values, diceSize, diceNum }) { return { parseType: 'rollArray', - values, + values: values.map(v => ({ value: v })), diceSize, diceNum, }; @@ -16,10 +16,13 @@ const rollArray = { }; }, toString(node) { - return `${node.diceNum || ''}d${node.diceSize} [ ${node.values.join(', ')} ]`; + return `${node.diceNum || ''}d${node.diceSize} [${valuesToString(node.values)}]`; }, reduce(node, scope, context) { - const total = node.values.reduce((a, b) => a + b, 0); + const total = node.values.reduce((a, b) => { + if (b.disabled) return a; + return a + b.value; + }, 0); return { result: constant.create({ value: total, @@ -29,4 +32,15 @@ const rollArray = { }, } +function valuesToString(values) { + return values.map(v => { + let text = `${v.value}`; + if (v.disabled) text = `~~${text}~~`; + if (v.italics) text = `*${text}*`; + if (v.bold) text = `**${text}**`; + if (v.underline) text = `__${text}__`; + return text; + }).join(', '); +} + export default rollArray; diff --git a/app/imports/server/config/redisCaching.js b/app/imports/server/config/redisCaching.js new file mode 100644 index 00000000..7ed21c26 --- /dev/null +++ b/app/imports/server/config/redisCaching.js @@ -0,0 +1,3 @@ +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; + +LibraryNodes.startCaching?.(); diff --git a/app/imports/server/discord/sendWebhook.js b/app/imports/server/discord/sendWebhook.js index 044de8c3..a8a473df 100644 --- a/app/imports/server/discord/sendWebhook.js +++ b/app/imports/server/discord/sendWebhook.js @@ -1,5 +1,5 @@ import Discord from 'discord.js' -export default function sendWebhook({webhookURL, data = {}}){ +export default function sendWebhook({ webhookURL, data = {} }) { //webhookURL = https://discordapp.com/api/webhooks// let urlArray = webhookURL.split('/'); let token = urlArray.pop(); @@ -9,11 +9,16 @@ export default function sendWebhook({webhookURL, data = {}}){ data.disableMentions = 'all'; const hook = new Discord.WebhookClient(id, token); - // Send a message using the webhook - hook.send(data); + try { + // Send a message using the webhook + hook.send(data); + } catch (e) { + // Swallow the error, we don't really care + console.error(e); + } } -export function sendWebhookAsCreature({creature, data = {}}){ +export function sendWebhookAsCreature({ creature, data = {} }) { if (!creature || !creature.settings || !creature.settings.discordWebhook) return; data.username = creature.name; data.avatarURL = creature.avatarPicture; diff --git a/app/imports/server/publications/library.js b/app/imports/server/publications/library.js index 68c378b5..5499f246 100644 --- a/app/imports/server/publications/library.js +++ b/app/imports/server/publications/library.js @@ -14,15 +14,19 @@ const LIBRARY_NODE_TREE_FIELDS = { order: 1, parent: 1, ancestors: 1, - tags: 1, - slotFillerCondition: 1, removed: 1, removedAt: 1, // Actions actionType: 1, // SlotFillers + libraryTags: 1, slotQuantityFilled: 1, slotFillerType: 1, + slotFillerConditionNote: 1, + slotFillerCondition: 1, + fillSlots: 1, + searchable: 1, + slotFillImage: 1, // Effect operation: 1, targetTags: 1, @@ -78,7 +82,14 @@ Meteor.publish('libraryCollection', function (libraryCollectionId) { }, { sort: { name: 1 } }); - return [libraryCollectionCursor, libraryCursor]; + return [ + libraryCollectionCursor, + libraryCursor, + Meteor.users.find( + libraryCollection.owner, + { fields: { username: 1 } } + ), + ]; }); }); }) @@ -137,6 +148,32 @@ Meteor.publish('libraries', function () { }); }); +Meteor.publish('browseLibraries', function () { + if (!this.userId) return []; + return [ + Libraries.find({ + showInMarket: true, + public: true, + }, { + sort: { + subscriberCount: 1, + name: 1, + }, + limit: 500, + }), + LibraryCollections.find({ + showInMarket: true, + public: true, + }, { + sort: { + subscriberCount: 1, + name: 1 + }, + limit: 500, + }), + ]; +}); + Meteor.publish('library', function (libraryId) { if (!libraryId) return []; libraryIdSchema.validate({ libraryId }); @@ -147,9 +184,15 @@ Meteor.publish('library', function (libraryId) { catch (e) { return this.error(e); } - return Libraries.find({ - _id: libraryId, - }); + return [ + Libraries.find({ + _id: libraryId, + }), + Meteor.users.find( + library.owner, + { fields: { username: 1 } } + ), + ]; }); }); diff --git a/app/imports/server/publications/searchLibraryNodes.js b/app/imports/server/publications/searchLibraryNodes.js index cedbc7a8..062cfbbf 100644 --- a/app/imports/server/publications/searchLibraryNodes.js +++ b/app/imports/server/publications/searchLibraryNodes.js @@ -4,28 +4,31 @@ import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import getCreatureLibraryIds from '/imports/api/library/getCreatureLibraryIds.js'; import getUserLibraryIds from '/imports/api/library/getUserLibraryIds.js'; import { assertViewPermission } from '/imports/api/sharing/sharingPermissions.js'; +import escapeRegex from '/imports/api/utility/escapeRegex.js'; -Meteor.publish('selectedLibraryNodes', function(selectedNodeIds){ +Meteor.publish('selectedLibraryNodes', function (selectedNodeIds) { check(selectedNodeIds, Array); // Limit to 20 selected nodes - if (selectedNodeIds.length > 20){ + if (selectedNodeIds.length > 20) { selectedNodeIds = selectedNodeIds.slice(0, 20); } let libraryViewPermissions = {}; // Check view permissions of all libraries - for (let id of selectedNodeIds){ + for (let id of selectedNodeIds) { let node = LibraryNodes.findOne(id); if (!node) continue; let libraryId = node.ancestors[0].id; - if (libraryViewPermissions[id]){ + if (libraryViewPermissions[id]) { continue; } else { - let library = Libraries.findOne(libraryId, {fields: { - owner: 1, - readers: 1, - writers: 1, - public: 1, - }}); + let library = Libraries.findOne(libraryId, { + fields: { + owner: 1, + readers: 1, + writers: 1, + public: 1, + } + }); assertViewPermission(library, this.userId); libraryViewPermissions[id] = true; } @@ -33,15 +36,15 @@ Meteor.publish('selectedLibraryNodes', function(selectedNodeIds){ // Return all nodes and their children return [LibraryNodes.find({ $or: [ - {_id: {$in: selectedNodeIds}}, - {'ancestors.id': {$in: selectedNodeIds}}, + { _id: { $in: selectedNodeIds } }, + { 'ancestors.id': { $in: selectedNodeIds } }, ], })]; }); -Meteor.publish('searchLibraryNodes', function(creatureId){ +Meteor.publish('searchLibraryNodes', function (creatureId) { let self = this; - this.autorun(function (){ + this.autorun(function () { let type = self.data('type'); if (!type) return []; @@ -60,20 +63,19 @@ Meteor.publish('searchLibraryNodes', function(creatureId){ // Build a filter for nodes in those libraries that match the type let filter = { - 'ancestors.id': {$in: libraryIds}, - removed: {$ne: true}, - tags: {$ne: []}, // Only tagged library nodes are considered + 'ancestors.id': { $in: libraryIds }, + removed: { $ne: true }, + searchable: true //library nodes must opt-in }; - if (type){ + if (type) { filter.$or = [{ - type, - },{ - type: 'slotFiller', - slotFillerType: type, + type, + }, { + slotFillerType: type, }]; } - this.autorun(function(){ + this.autorun(function () { // Get the limit of the documents the user can fetch var limit = self.data('limit') || 32; check(limit, Number); @@ -83,28 +85,34 @@ Meteor.publish('searchLibraryNodes', function(creatureId){ check(searchTerm, String); let options = undefined; - if (searchTerm){ - filter.$text = {$search: searchTerm}; + if (searchTerm) { + filter.name = { $regex: escapeRegex(searchTerm), '$options': 'i' }; + // filter.$text = {$search: searchTerm}; options = { + /* // relevant documents have a higher score. fields: { score: { $meta: 'textScore' } }, + */ sort: { // `score` property specified in the projection fields above. - score: { $meta: 'textScore' }, + // score: { $meta: 'textScore' }, 'ancestors.0.id': 1, name: 1, order: 1, } } } else { - delete filter.$text - options = {sort: { - 'ancestors.0.id': 1, - name: 1, - order: 1, - }}; + //delete filter.$text + delete filter.name; + options = { + sort: { + 'ancestors.0.id': 1, + name: 1, + order: 1, + } + }; } options.limit = limit; @@ -118,17 +126,17 @@ Meteor.publish('searchLibraryNodes', function(creatureId){ Mongo.Collection._publishCursor(libraries, self, 'libraries'); let observeHandle = cursor.observeChanges({ - added: function (id, fields) { - fields._searchResult = true; - self.added('libraryNodes', id, fields); - }, - changed: function (id, fields) { - self.changed('libraryNodes', id, fields); - }, - removed: function (id) { - self.removed('libraryNodes', id); - } + added: function (id, fields) { + fields._searchResult = true; + self.added('libraryNodes', id, fields); }, + changed: function (id, fields) { + self.changed('libraryNodes', id, fields); + }, + removed: function (id) { + self.removed('libraryNodes', id); + } + }, // Publications don't mutate the documents { nonMutatingCallbacks: true } ); diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index 28d2da6f..b41c9dcd 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -19,10 +19,10 @@ Meteor.publish('singleCharacter', function (creatureId) { const self = this; try { schema.validate({ creatureId }); - } catch (e){ + } catch (e) { this.error(e); } - this.autorun(function (computation){ + this.autorun(function (computation) { let userId = this.userId; let permissionCreature = Creatures.findOne({ _id: creatureId, @@ -32,11 +32,11 @@ Meteor.publish('singleCharacter', function (creatureId) { try { assertViewPermission(permissionCreature, userId) } catch (e) { return [] } loadCreature(creatureId, self); - if (permissionCreature.computeVersion !== VERSION && computation.firstRun){ + if (permissionCreature.computeVersion !== VERSION && computation.firstRun) { try { computeCreature(creatureId) } - catch(e){ console.error(e) } + catch (e) { console.error(e) } } return [ Creatures.find({ @@ -52,7 +52,13 @@ Meteor.publish('singleCharacter', function (creatureId) { creatureId, }, { limit: 20, - sort: {date: -1}, + sort: { date: -1 }, + }), + // Also publish the owner's username + Meteor.users.find(permissionCreature.owner, { + fields: { + username: 1, + }, }), ]; }); diff --git a/app/imports/server/publications/slotFillers.js b/app/imports/server/publications/slotFillers.js index 6427af79..fba54ca6 100644 --- a/app/imports/server/publications/slotFillers.js +++ b/app/imports/server/publications/slotFillers.js @@ -5,22 +5,28 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js' import getCreatureLibraryIds from '/imports/api/library/getCreatureLibraryIds.js'; import { LIBRARY_NODE_TREE_FIELDS } from '/imports/server/publications/library.js'; +import escapeRegex from '/imports/api/utility/escapeRegex.js'; -Meteor.publish('slotFillers', function(slotId, searchTerm){ +Meteor.publish('slotFillers', function (slotId, searchTerm, isDummySlot) { if (searchTerm) check(searchTerm, String); let self = this; - this.autorun(function (){ + this.autorun(function () { let userId = this.userId; if (!userId) { return []; } - // Get the slot - let slot = CreatureProperties.findOne(slotId); - if (!slot){ - return []; + + // Get the slot from the right collection + let slot; + if (isDummySlot) { + slot = LibraryNodes.findOne(slotId); + } else { + slot = CreatureProperties.findOne(slotId); } + if (!slot) return []; + // Get all the ids of libraries the user can access const creatureId = slot.ancestors[0].id; const libraryIds = getCreatureLibraryIds(creatureId, userId); @@ -36,31 +42,32 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ }); // Build a filter for nodes in those libraries that match the slot - let filter = getSlotFillFilter({slot, libraryIds}); - - this.autorun(function(){ + let filter = getSlotFillFilter({ slot, libraryIds }); + this.autorun(function () { // Get the limit of the documents the user can fetch var limit = self.data('limit') || 50; check(limit, Number); let options = undefined; - if (searchTerm){ - filter.$text = {$search: searchTerm}; + if (searchTerm) { + filter.name = { $regex: escapeRegex(searchTerm), '$options': 'i' }; + //filter.$text = { $search: searchTerm }; options = { // relevant documents have a higher score. fields: { - _score: { $meta: 'textScore' }, + //_score: { $meta: 'textScore' }, ...LIBRARY_NODE_TREE_FIELDS, }, sort: { // `score` property specified in the projection fields above. - _score: { $meta: 'textScore' }, + //_score: { $meta: 'textScore' }, name: 1, order: 1, } } } else { - delete filter.$text + //delete filter.$text + delete filter.name options = { sort: { name: 1, @@ -73,6 +80,7 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ self.autorun(function () { self.setData('countAll', LibraryNodes.find(filter).count()); + self.setData('libraryNodeFilter', EJSON.stringify(filter)); }); self.autorun(function () { return [ @@ -84,18 +92,18 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ }); }); -Meteor.publish('classFillers', function(classId){ +Meteor.publish('classFillers', function (classId) { let self = this; if (!classId) return []; - this.autorun(function (){ + this.autorun(function () { let userId = this.userId; if (!userId) { return []; } // Get the class let classProp = CreatureProperties.findOne(classId); - if (!classProp){ + if (!classProp) { return []; } @@ -114,15 +122,16 @@ Meteor.publish('classFillers', function(classId){ }); // Build a filter for nodes in those libraries that match the slot - let filter = getSlotFillFilter({slot: classProp, libraryIds}); + let filter = getSlotFillFilter({ slot: classProp, libraryIds }); - this.autorun(function(){ + this.autorun(function () { // Get the limit of the documents the user can fetch var limit = self.data('limit') || 50; check(limit, Number); let options = { sort: { + level: 1, name: 1, order: 1, }, @@ -132,6 +141,7 @@ Meteor.publish('classFillers', function(classId){ self.autorun(function () { self.setData('countAll', LibraryNodes.find(filter).count()); + self.setData('libraryNodeFilter', EJSON.stringify(filter)); }); self.autorun(function () { return [LibraryNodes.find(filter, options), libraries]; diff --git a/app/imports/server/rest/apiPublications/healthCheck.js b/app/imports/server/rest/apiPublications/healthCheck.js new file mode 100644 index 00000000..c6aa00a8 --- /dev/null +++ b/app/imports/server/rest/apiPublications/healthCheck.js @@ -0,0 +1,37 @@ +// A simple endpoint that does a single round trip to the database to check everything is working + +const HealthCheckCollection = new Mongo.Collection('healthCheck'); + +// Don't use redis oplog optimization on this collection, we want to hit the database every time +HealthCheckCollection.disableRedis?.(); + +const healthCheckDoc = { + status: 'ok', +}; + +// Add the health check doc on startup if it's missing +// There should only be this single doc in the collection +// A capped collection would be marginally faster, but it's a pain to make one in Meteor +Meteor.startup(function () { + if (!HealthCheckCollection.findOne()) { + HealthCheckCollection.insert(healthCheckDoc); + } +}); + +Meteor.method('api-status', function () { + let dbHealthDoc; + try { + dbHealthDoc = HealthCheckCollection.findOne(); + } catch (e) { + this.setHttpStatusCode(503); + } + if (dbHealthDoc?.status === 'ok') { + this.setHttpStatusCode(200); + } else { + this.setHttpStatusCode(500); + } + return dbHealthDoc || {}; +}, { + httpMethod: 'GET', + url: 'api/status' +}); diff --git a/app/imports/server/rest/apiPublications/index.js b/app/imports/server/rest/apiPublications/index.js index 765d7988..e95d23db 100644 --- a/app/imports/server/rest/apiPublications/index.js +++ b/app/imports/server/rest/apiPublications/index.js @@ -1 +1,2 @@ import './creature.js'; +import './healthCheck.js'; diff --git a/app/jsconfig.json b/app/jsconfig.json index b97e629d..82b81ff2 100644 --- a/app/jsconfig.json +++ b/app/jsconfig.json @@ -10,14 +10,25 @@ "paths": { "/*": [ "./*" + ], + "meteor/aldeed:collection2": [ + "packages\\collection2\\collection2.js" ] - } + }, + "checkJs": false, + "allowJs": true }, "vueCompilerOptions": { - "target": 2, + "target": 2 // For Vue version <= 2.6.14 }, "exclude": [ "node_modules", - "**/node_modules/*" - ] + "**/node_modules/*", + ".meteor" + ], + "typeAcquisition": { + "include": [ + "meteor" + ] + } } \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index cae51f46..9fe1b74e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -50,15 +50,15 @@ } }, "@babel/parser": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.3.tgz", - "integrity": "sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg==", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.3.tgz", + "integrity": "sha512-vrukxyW/ep8UD1UDzOYpTKQ6abgjFoeG6L+4ar9+c5TN9QnlqiOi6QK7LSR5ewm/ERyGkT/Ai6VboNrxhbr9Uw==", "dev": true }, "@babel/runtime": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", - "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", + "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", "requires": { "regenerator-runtime": "^0.13.11" } @@ -83,6 +83,29 @@ "mime-types": "^2.1.12" } }, + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", + "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "dev": true + } + } + }, + "@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true + }, "@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -178,6 +201,12 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@types/mocha": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", + "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "dev": true + }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -185,18 +214,19 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.44.0.tgz", - "integrity": "sha512-j5ULd7FmmekcyWeArx+i8x7sdRHzAtXTkmDPthE4amxZOWKFK7bomoJ4r7PJ8K7PoMzD16U8MmuZFAonr1ERvw==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.2.tgz", + "integrity": "sha512-yVrXupeHjRxLDcPKL10sGQ/QlVrA8J5IYOEWVqk0lJaSZP7X5DfnP7Ns3cc74/blmbipQ1htFNVGsHX6wsYm0A==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/type-utils": "5.44.0", - "@typescript-eslint/utils": "5.44.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/type-utils": "5.59.2", + "@typescript-eslint/utils": "5.59.2", "debug": "^4.3.4", + "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, @@ -211,9 +241,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -222,14 +252,14 @@ } }, "@typescript-eslint/parser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.44.0.tgz", - "integrity": "sha512-H7LCqbZnKqkkgQHaKLGC6KUjt3pjJDx8ETDqmwncyb6PuoigYajyAwBGz08VU/l86dZWZgI4zm5k2VaKqayYyA==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.2.tgz", + "integrity": "sha512-uq0sKyw6ao1iFOZZGk9F8Nro/8+gfB5ezl1cA06SrqbgJAt0SRoFhb9pXaHvkrxUpZaoLxt8KlovHNk8Gp6/HQ==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", "debug": "^4.3.4" }, "dependencies": { @@ -245,23 +275,23 @@ } }, "@typescript-eslint/scope-manager": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.44.0.tgz", - "integrity": "sha512-2pKml57KusI0LAhgLKae9kwWeITZ7IsZs77YxyNyIVOwQ1kToyXRaJLl+uDEXzMN5hnobKUOo2gKntK9H1YL8g==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", + "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", "dev": true, "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0" + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2" } }, "@typescript-eslint/type-utils": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.44.0.tgz", - "integrity": "sha512-A1u0Yo5wZxkXPQ7/noGkRhV4J9opcymcr31XQtOzcc5nO/IHN2E2TPMECKWYpM3e6olWEM63fq/BaL1wEYnt/w==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz", + "integrity": "sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "5.44.0", - "@typescript-eslint/utils": "5.44.0", + "@typescript-eslint/typescript-estree": "5.59.2", + "@typescript-eslint/utils": "5.59.2", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -278,19 +308,19 @@ } }, "@typescript-eslint/types": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.44.0.tgz", - "integrity": "sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", + "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.44.0.tgz", - "integrity": "sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", + "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", "dev": true, "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/visitor-keys": "5.59.2", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -308,9 +338,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -319,34 +349,25 @@ } }, "@typescript-eslint/utils": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.44.0.tgz", - "integrity": "sha512-fMzA8LLQ189gaBjS0MZszw5HBdZgVwxVFShCO3QN+ws3GlPkcy9YuS3U4wkT6su0w+Byjq3mS3uamy9HE4Yfjw==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", + "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", "dev": true, "requires": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", + "@typescript-eslint/scope-manager": "5.59.2", + "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.2", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", "semver": "^7.3.7" }, "dependencies": { - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - } - }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -355,49 +376,79 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.44.0.tgz", - "integrity": "sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==", + "version": "5.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", + "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", "dev": true, "requires": { - "@typescript-eslint/types": "5.44.0", + "@typescript-eslint/types": "5.59.2", "eslint-visitor-keys": "^3.3.0" }, "dependencies": { "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", + "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", "dev": true } } }, "@vue/compiler-core": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.45.tgz", - "integrity": "sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", + "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", "dev": true, "requires": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.45", + "@babel/parser": "^7.21.3", + "@vue/shared": "3.3.4", "estree-walker": "^2.0.2", - "source-map": "^0.6.1" + "source-map-js": "^1.0.2" } }, "@vue/compiler-dom": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz", - "integrity": "sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", + "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", "dev": true, "requires": { - "@vue/compiler-core": "3.2.45", - "@vue/shared": "3.2.45" + "@vue/compiler-core": "3.3.4", + "@vue/shared": "3.3.4" + } + }, + "@vue/reactivity": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz", + "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==", + "dev": true, + "requires": { + "@vue/shared": "3.3.4" + } + }, + "@vue/runtime-core": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz", + "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==", + "dev": true, + "requires": { + "@vue/reactivity": "3.3.4", + "@vue/shared": "3.3.4" + } + }, + "@vue/runtime-dom": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz", + "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==", + "dev": true, + "requires": { + "@vue/runtime-core": "3.3.4", + "@vue/shared": "3.3.4", + "csstype": "^3.1.1" } }, "@vue/shared": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz", - "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", + "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==", "dev": true }, "abbrev": { @@ -425,6 +476,11 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true }, + "add-event-listener": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/add-event-listener/-/add-event-listener-0.0.1.tgz", + "integrity": "sha512-hjRmkeDqFUWEFcDHP/Lp0Pa4MhIJk/oQX8B7lFiNrjBKHjf0q+ivCJrucY8d8UI5d0QkZgV2jGdAGXxEZcm3nA==" + }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -530,9 +586,9 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, "aws-sdk": { - "version": "2.1262.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1262.0.tgz", - "integrity": "sha512-XbaK/XUIxwLEBnHANhJ0RTZtiU288lFRj5FllSihQ5Kb0fibKyW8kJFPsY+NzzDezLH5D3WdGbTKb9fycn5TbA==", + "version": "2.1373.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1373.0.tgz", + "integrity": "sha512-3/P79VU2VVeiof25bn8TSepEhWCRhwuQGYoEWX/2pApQRJAY+w/3JFVKHjbAe3NYEEBNeiAE6PQ9DqWz5Pp+Lw==", "requires": { "buffer": "4.9.2", "events": "1.1.1", @@ -543,7 +599,7 @@ "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", - "xml2js": "0.4.19" + "xml2js": "0.5.0" }, "dependencies": { "uuid": { @@ -875,6 +931,46 @@ "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==" }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, + "cytoscape": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.25.0.tgz", + "integrity": "sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==", + "requires": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + } + }, + "cytoscape-dagre": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.5.0.tgz", + "integrity": "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==", + "requires": { + "dagre": "^0.8.5" + } + }, + "cytoscape-klay": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", + "integrity": "sha512-VwPj0VR25GPfy6qXVQRi/MYlZM/zkdvRhHlgqbM//lSvstgM6fhp3ik/uM8Wr8nlhskfqz/M1fIDmR6UckbS2A==", + "requires": { + "klayjs": "^0.4.1" + } + }, + "dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "requires": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -986,9 +1082,9 @@ } }, "dompurify": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.1.tgz", - "integrity": "sha512-ewwFzHzrrneRjxzmK6oVz/rZn9VWspGFRDb4/rRtIsM1n36t9AKma/ye8syCpcw+XJ25kOK/hOG7t1j2I2yBqA==" + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.5.tgz", + "integrity": "sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==" }, "ecc-jsbn": { "version": "0.1.2", @@ -1299,9 +1395,9 @@ "dev": true }, "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", "dev": true, "requires": { "reusify": "^1.0.4" @@ -1416,9 +1512,9 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -1433,6 +1529,11 @@ "assert-plus": "^1.0.0" } }, + "gintersect": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/gintersect/-/gintersect-0.1.0.tgz", + "integrity": "sha512-jps8Ckj6u8yLxOYzBVJbPqvRdeHOINQgRtufaLHkunwNQcSEdZU0ejPBapSimXJEQ9mdQW4hsEUN7DfJEcTvQQ==" + }, "github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -1491,6 +1592,20 @@ "get-intrinsic": "^1.1.3" } }, + "grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "requires": { + "lodash": "^4.17.15" + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -1550,6 +1665,11 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -1575,9 +1695,9 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "ignore": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.1.tgz", - "integrity": "sha512-d2qQLzTJ9WxQftPAuEQpSPmKqzxePjzVbpAVv62AQ64NTL+wR4JkrVqR/LqFsFEUsHDAiId52mJteHDFuDkElA==" + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==" }, "ignore-styles": { "version": "5.0.1", @@ -1763,6 +1883,11 @@ "verror": "1.10.0" } }, + "klayjs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.4.1.tgz", + "integrity": "sha512-WUNxuO7O79TEkxCj6OIaK5TJBkaWaR/IKNTakgV9PwDn+mrr63MLHed34AcE2yTaDntgO6l0zGFIzhcoTeroTA==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1792,7 +1917,7 @@ "lodash.omit": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", - "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=" + "integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==" }, "lodash.template": { "version": "4.5.0", @@ -1850,9 +1975,9 @@ } }, "marked": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.2.3.tgz", - "integrity": "sha512-slWRdJkbTZ+PjkyJnE30Uid64eHwbwa1Q25INCAYfZlK4o6ylagBy/Le9eWntqJFoFT93ikUKMv47GZ4gTwHkw==" + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==" }, "merge2": { "version": "1.4.1", @@ -2761,11 +2886,89 @@ "randexp": "0.4.6" } }, + "ngraph.centrality": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ngraph.centrality/-/ngraph.centrality-0.3.0.tgz", + "integrity": "sha512-Qmu9dDHJAx+GAW2AMqmhaub1rINS+fHZGZJ3zPI36ENAXmVNQ/Jkq79br1sg6NUHz/pRBT9MXMuwDyYKmMt8Mw==" + }, "ngraph.events": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.1.tgz", "integrity": "sha512-D4C+nXH/RFxioGXQdHu8ELDtC6EaCiNsZtih0IvyGN81OZSUby4jXoJ5+RNWasfsd0FnKxxpAROyUMzw64QNsw==" }, + "ngraph.expose": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/ngraph.expose/-/ngraph.expose-0.0.0.tgz", + "integrity": "sha512-Hr88MuhgoSLVGf2aaaXcKl22Rn95duWsjRcoeJMP9PtFmYHGFw/3ctDqBf5phnIyktm0P/Quxs5EGg6xgJcZAQ==" + }, + "ngraph.forcelayout": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-0.5.0.tgz", + "integrity": "sha512-qOd1S9unFLw313+l0M/Dk1MePLDUSl4h9RyOtAbo0CyeefnN4PICiRz0LOewR5WuFmQD0/RmZLpjTKu0H7LTKQ==", + "requires": { + "ngraph.events": "0.0.4", + "ngraph.physics.simulator": "^0.3.0" + }, + "dependencies": { + "ngraph.events": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-0.0.4.tgz", + "integrity": "sha512-SY7MdNQoy5KyaVxg03PYCnGF6J7l4p8lEdmYm/5oIqFAmLhg0BmzZzlRqobJ0nEPT6xZlonUQbvCcXtarPZNrg==" + } + } + }, + "ngraph.fromjson": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/ngraph.fromjson/-/ngraph.fromjson-0.1.9.tgz", + "integrity": "sha512-f3GLjbUq239wx4s5A0fDptj9dcNeaEIJU3gm74hWvYK7onD7sFtedP7jVHZA7UJ2FwkKgEhzbPeltv92ycuKZQ==", + "requires": { + "ngraph.graph": "0.0.14" + }, + "dependencies": { + "ngraph.events": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-0.0.3.tgz", + "integrity": "sha512-UrewofHOFk/05otBm9GD4DA3PTEY/yaElhCclmGC4IcmAYaSDRrC3lENQxJ00AzeBnz1GY2xH7Ct7AfIdhsdWA==" + }, + "ngraph.graph": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-0.0.14.tgz", + "integrity": "sha512-ERTLng4KrsGbR7iLZFvg5H+zJ7V+SY8RDqZKYCnOZib5W8M5LCvcil9/8eiJcTRUIPPXW3j8hqPCdLnBvgsn/A==", + "requires": { + "ngraph.events": "0.0.3" + } + } + } + }, + "ngraph.generators": { + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/ngraph.generators/-/ngraph.generators-0.0.19.tgz", + "integrity": "sha512-P3XqB1sH4zrzM6bMGTtuT/6K76Rnhf1qE8Zu7PkAvhQVCQzdLYiL2/8DwhcPLsetRJHNJv0uwpW9TpntBAqKrw==", + "requires": { + "ngraph.graph": "0.0.14", + "ngraph.random": "0.1.0" + }, + "dependencies": { + "ngraph.events": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-0.0.3.tgz", + "integrity": "sha512-UrewofHOFk/05otBm9GD4DA3PTEY/yaElhCclmGC4IcmAYaSDRrC3lENQxJ00AzeBnz1GY2xH7Ct7AfIdhsdWA==" + }, + "ngraph.graph": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-0.0.14.tgz", + "integrity": "sha512-ERTLng4KrsGbR7iLZFvg5H+zJ7V+SY8RDqZKYCnOZib5W8M5LCvcil9/8eiJcTRUIPPXW3j8hqPCdLnBvgsn/A==", + "requires": { + "ngraph.events": "0.0.3" + } + }, + "ngraph.random": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-0.1.0.tgz", + "integrity": "sha512-KXCfzk/ZB79BxQSWMvYPGayx3Mb+7n5GPnc8SW0rwysqRV/3QxEKrLU/UVC8eGjc2SYGofqX+uhUE6IXfqR5VA==" + } + } + }, "ngraph.graph": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-19.1.0.tgz", @@ -2774,11 +2977,59 @@ "ngraph.events": "^1.2.1" } }, + "ngraph.merge": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-0.0.1.tgz", + "integrity": "sha512-iXchI5xMjYzA96mee//O7I7gtd4cCakWaSTu11aMTxRDbvBK2qpDDytYg58jO3usAUkjFxBdy1gxYppKmBDuRQ==" + }, "ngraph.path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ngraph.path/-/ngraph.path-1.4.0.tgz", "integrity": "sha512-yJZay4tP0wcjqkkf8zlMQ/T+JOgU+EWfdE4w4TG8OS94B12J/+Z44UOYxVJErE8E6/wFunX1hMZEB1/GHsBYHg==" }, + "ngraph.physics.primitives": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ngraph.physics.primitives/-/ngraph.physics.primitives-0.0.7.tgz", + "integrity": "sha512-7jPm14fYcuJ9kytOVNOKxFy6r/Uu9Dnj++uT3iR9XkBcsBahn2xcYJkV6vF1bIb1fQ5XrDCRjRIOcMwEum6jwQ==" + }, + "ngraph.physics.simulator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ngraph.physics.simulator/-/ngraph.physics.simulator-0.3.0.tgz", + "integrity": "sha512-ObW+HL+hQBIIdc6xG/+qrLe8qv+Sf0X3lq/l2hsjFrIwWtpRKLrSvUUoXiNIeFqRmY/C+PkGo3U+XY523lJ+Fw==", + "requires": { + "ngraph.events": "0.0.3", + "ngraph.expose": "0.0.0", + "ngraph.merge": "0.0.1", + "ngraph.physics.primitives": "0.0.7", + "ngraph.quadtreebh": "0.0.4", + "ngraph.random": "0.0.1" + }, + "dependencies": { + "ngraph.events": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-0.0.3.tgz", + "integrity": "sha512-UrewofHOFk/05otBm9GD4DA3PTEY/yaElhCclmGC4IcmAYaSDRrC3lENQxJ00AzeBnz1GY2xH7Ct7AfIdhsdWA==" + } + } + }, + "ngraph.quadtreebh": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/ngraph.quadtreebh/-/ngraph.quadtreebh-0.0.4.tgz", + "integrity": "sha512-xTIkWGXt5Ajnoq9VOr0xDOI9ZL+q4sPhD0Z7vxvn4MCa+l0wf43rg0C7qv0t+RIOgbQBAp0xDpn568hpXAckJA==", + "requires": { + "ngraph.random": "0.0.1" + } + }, + "ngraph.random": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-0.0.1.tgz", + "integrity": "sha512-QPKU7ChXF/VrvMQxVo9aWcvXCXp98VfL4nKUteTW/olDqeUqQ61t7m+jvFb8Dj7kKvlKlnsbDA1aWLJGmm17XA==" + }, + "ngraph.tojson": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/ngraph.tojson/-/ngraph.tojson-0.1.4.tgz", + "integrity": "sha512-Ii2BTqi8zBRMLH8vTc8pMUKQFJaqbgttG9DKUaazoPVpwC/ww4jyTOHe2ZKaGGZRepnGLqSZ27wZUm7n8MjIgA==" + }, "node-abi": { "version": "3.26.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.26.0.tgz", @@ -2920,9 +3171,9 @@ "dev": true }, "pretty-bytes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.0.0.tgz", - "integrity": "sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.0.tgz", + "integrity": "sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==" }, "prism-media": { "version": "1.3.1", @@ -3212,6 +3463,14 @@ "is-arrayish": "^0.3.1" } }, + "simplesvg": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/simplesvg/-/simplesvg-0.0.10.tgz", + "integrity": "sha512-iCVx1A/kI4U3cGPRMRQaGLbIFNDXuB8rsaAsO2mM5wYFDs/MrfmHhrSCqNbOylgt9MhhZU3uMsSQnZM853kwXQ==", + "requires": { + "add-event-listener": "0.0.1" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3271,6 +3530,12 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, "source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -3549,9 +3814,9 @@ "dev": true }, "typescript": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", - "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, "uri-js": { @@ -3616,10 +3881,43 @@ "extsprintf": "^1.2.0" } }, + "vivagraphjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/vivagraphjs/-/vivagraphjs-0.12.0.tgz", + "integrity": "sha512-Air+vUHXAWj8NTWUnbU800yKC7SiHpCVwpKIPfDtr5436YoMd7cpg8blt6Fn9xarx+sz1osRxGHBHTaHvcsR6Q==", + "requires": { + "gintersect": "0.1.0", + "ngraph.centrality": "0.3.0", + "ngraph.events": "0.0.3", + "ngraph.forcelayout": "0.5.0", + "ngraph.fromjson": "0.1.9", + "ngraph.generators": "0.0.19", + "ngraph.graph": "0.0.14", + "ngraph.merge": "0.0.1", + "ngraph.random": "0.0.1", + "ngraph.tojson": "0.1.4", + "simplesvg": "0.0.10" + }, + "dependencies": { + "ngraph.events": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-0.0.3.tgz", + "integrity": "sha512-UrewofHOFk/05otBm9GD4DA3PTEY/yaElhCclmGC4IcmAYaSDRrC3lENQxJ00AzeBnz1GY2xH7Ct7AfIdhsdWA==" + }, + "ngraph.graph": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-0.0.14.tgz", + "integrity": "sha512-ERTLng4KrsGbR7iLZFvg5H+zJ7V+SY8RDqZKYCnOZib5W8M5LCvcil9/8eiJcTRUIPPXW3j8hqPCdLnBvgsn/A==", + "requires": { + "ngraph.events": "0.0.3" + } + } + } + }, "vue": { - "version": "2.6.10", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.10.tgz", - "integrity": "sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==" + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", + "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" }, "vue-eslint-parser": { "version": "7.11.0", @@ -3688,9 +3986,9 @@ } }, "vuetify": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.12.tgz", - "integrity": "sha512-qe3hcMpWmT1O15tp+p65lOS7UKZ/hQYQktCsw9iXx2u3RwVbX6GR82gY2iROrKsiAzYDvMgrYxWQwY/pUfkekw==" + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.15.tgz", + "integrity": "sha512-2a6sBSHzivXgi9pZMyHuzTgMyInCkj/BrVwTnoCa1Y/Dnfwj7lkWzgKQDScbGVK0q4vJ+YHoBBrLOmnhz1R0YA==" }, "vuetify-upload-button": { "version": "2.0.2", @@ -3763,18 +4061,18 @@ "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==" }, "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", "requires": { "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" + "xmlbuilder": "~11.0.0" } }, "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==" + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, "yallist": { "version": "4.0.0", diff --git a/app/package.json b/app/package.json index 7cee6eb3..e92fb1b9 100644 --- a/app/package.json +++ b/app/package.json @@ -20,35 +20,40 @@ "npm": "6.13.x" }, "dependencies": { - "@babel/runtime": "^7.20.6", + "@babel/runtime": "^7.21.5", "@chenfengyuan/vue-countdown": "^1.1.5", "@tozd/vue-observer-utils": "^0.5.0", - "aws-sdk": "^2.1262.0", + "aws-sdk": "^2.1373.0", "bcrypt": "^5.1.0", "chroma-js": "^2.4.2", "css-box-shadow": "^1.0.0-3", + "cytoscape": "^3.25.0", + "cytoscape-dagre": "^2.5.0", + "cytoscape-klay": "^3.1.4", + "dagre": "^0.8.5", "date-fns": "^1.30.1", "ddp-rate-limiter-mixin": "^1.1.10", "discord.js": "^12.5.3", - "dompurify": "^2.4.1", - "ignore": "^5.2.1", + "dompurify": "^2.4.5", + "ignore": "^5.2.4", "ignore-styles": "^5.0.1", "lodash": "^4.17.20", - "marked": "^4.2.3", + "marked": "^4.3.0", "meteor-node-stubs": "^1.2.5", "minify-css-string": "^1.0.0", "moo": "^0.5.2", "nearley": "^2.19.1", "ngraph.graph": "^19.1.0", "ngraph.path": "^1.4.0", - "pretty-bytes": "^6.0.0", + "pretty-bytes": "^6.1.0", "qrcode.vue": "^1.7.0", "request": "^2.88.2", "sharp": "^0.30.7", "simpl-schema": "^1.13.1", "source-map-support": "^0.5.21", "speakingurl": "^14.0.1", - "vue": "2.6.10", + "vivagraphjs": "^0.12.0", + "vue": "2.6.14", "vue-meteor-tracker": "^2.0.0", "vue-reactive-provide": "^0.3.0", "vue-router": "^3.6.5", @@ -58,14 +63,16 @@ "vuex": "^3.1.3" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^5.44.0", - "@typescript-eslint/parser": "^5.44.0", - "@vue/compiler-dom": "^3.2.45", + "@types/mocha": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^5.59.2", + "@typescript-eslint/parser": "^5.59.2", + "@vue/compiler-dom": "^3.3.4", + "@vue/runtime-dom": "^3.3.4", "chai": "^4.3.7", "eslint": "^7.32.0", "eslint-plugin-vue": "^7.20.0", "eslint-plugin-vuetify": "^1.1.0", - "typescript": "^4.9.3" + "typescript": "^5.0" }, "eslintConfig": { "extends": [ @@ -118,4 +125,4 @@ ] } } -} \ No newline at end of file +} diff --git a/app/packages/redis-oplog b/app/packages/redis-oplog new file mode 160000 index 00000000..83e302c1 --- /dev/null +++ b/app/packages/redis-oplog @@ -0,0 +1 @@ +Subproject commit 83e302c15456d6744047c50fc8d1add9e739b001 diff --git a/app/public/images/paragons/vibes.png b/app/public/images/paragons/vibes.png new file mode 100644 index 00000000..026cacac Binary files /dev/null and b/app/public/images/paragons/vibes.png differ diff --git a/app/redis-settings.json b/app/redis-settings.json new file mode 100644 index 00000000..45fc18d7 --- /dev/null +++ b/app/redis-settings.json @@ -0,0 +1,19 @@ +{ + "redisOplog": { + "redis": { + "port": 6379, + "host": "127.0.0.1" + }, + "retryIntervalMs": 1000, + "mutationDefaults": { + "optimistic": true, + "pushToRedis": true + }, + "cacheTimeout": 1800000, + "cacheTimer": 300000, + "secondaryReads": null, + "raceDetectionDelay": 1000, + "raceDetection": true, + "debug": false + } +} \ No newline at end of file diff --git a/app/server/main.js b/app/server/main.js index 642113e0..eddb6d67 100644 --- a/app/server/main.js +++ b/app/server/main.js @@ -5,6 +5,7 @@ import '/imports/server/rest/index.js'; import '/imports/server/config/accountsEmailConfig.js'; import '/imports/server/config/simpleSchemaDebug.js'; import '/imports/server/config/SyncedCronConfig.js'; +import '/imports/server/config/redisCaching.js'; import '/imports/server/publications/index.js'; import '/imports/server/cron/deleteSoftRemovedDocuments.js'; import '/imports/api/parenting/organizeMethods.js';