diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c795b054 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/app/.meteor/packages b/app/.meteor/packages index f87dac0b..dc62173e 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -3,26 +3,25 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -accounts-password@2.2.0 +accounts-password@2.3.1 random@1.2.0 underscore@1.0.10 dburles:mongo-collection-instances accounts-google@1.4.0 -email@2.2.0 +email@2.2.1 meteor-base@1.5.1 mobile-experience@1.1.0 -mongo@1.14.6 +mongo@1.16.0 session@1.2.0 tracker@1.2.0 logging@1.3.1 reload@1.3.1 -ejson@1.1.1 +ejson@1.1.2 check@1.3.1 -standard-minifier-js@2.8.0 +standard-minifier-js@2.8.1 shell-server@0.5.0 -ecmascript@0.16.1 +ecmascript@0.16.2 es5-shim@4.8.0 -percolate:synced-cron service-configuration@1.3.0 dynamic-import@0.7.2 ddp-rate-limiter@1.1.0 @@ -47,3 +46,6 @@ meteortesting:mocha ostrio:files simple:rest-bearer-token-parser simple:rest-json-error-handler +littledata:synced-cron +mdg:meteor-apm-agent +typescript@4.5.4 diff --git a/app/.meteor/release b/app/.meteor/release index b1b0cceb..1d2a6d0f 100644 --- a/app/.meteor/release +++ b/app/.meteor/release @@ -1 +1 @@ -METEOR@2.6.1 +METEOR@2.8.0 diff --git a/app/.meteor/versions b/app/.meteor/versions index b8171bc3..b9f87f06 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -1,7 +1,7 @@ -accounts-base@2.2.1 +accounts-base@2.2.4 accounts-google@1.4.0 -accounts-oauth@1.4.0 -accounts-password@2.2.0 +accounts-oauth@1.4.1 +accounts-password@2.3.1 accounts-patreon@0.1.0 akryum:npm-check@0.1.2 akryum:vue-component@0.15.2 @@ -12,13 +12,13 @@ aldeed:collection2@3.5.0 aldeed:schema-index@3.0.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.8.1 -babel-runtime@1.5.0 +babel-compiler@7.9.2 +babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze-tools@1.1.2 +blaze-tools@1.1.3 boilerplate-generator@1.7.1 -bozhao:link-accounts@2.4.0 +bozhao:link-accounts@2.6.1 caching-compiler@1.2.2 caching-html-compiler@1.2.1 callback-hook@1.4.0 @@ -27,57 +27,61 @@ coffeescript@2.4.1 coffeescript-compiler@2.4.1 dburles:mongo-collection-instances@0.3.6 ddp@1.4.0 -ddp-client@2.5.0 +ddp-client@2.6.0 ddp-common@1.4.0 ddp-rate-limiter@1.1.0 -ddp-server@2.5.0 +ddp-server@2.6.0 diff-sequence@1.1.1 dynamic-import@0.7.2 -ecmascript@0.16.1 +ecmascript@0.16.2 ecmascript-runtime@0.8.0 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 -ejson@1.1.1 -email@2.2.0 +ejson@1.1.2 +email@2.2.1 es5-shim@4.8.0 fetch@0.1.1 geojson-utils@1.0.10 -google-oauth@1.4.1 +google-oauth@1.4.2 hot-code-push@1.0.4 -html-tools@1.1.2 +html-tools@1.1.3 htmljs@1.1.1 http@2.0.0 id-map@1.1.1 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.0 +meteor@1.10.1 meteor-base@1.5.1 meteortesting:browser-tests@1.3.5 meteortesting:mocha@2.0.3 meteortesting:mocha-core@8.1.2 mikowals:batch-insert@1.3.0 -minifier-css@1.6.0 -minifier-js@2.7.3 -minimongo@1.8.0 +minifier-css@1.6.1 +minifier-js@2.7.5 +minimongo@1.9.0 mobile-experience@1.1.0 mobile-status-bar@1.1.0 -modern-browsers@0.1.7 -modules@0.18.0 -modules-runtime@0.12.0 -mongo@1.14.6 -mongo-decimal@0.1.2 +modern-browsers@0.1.8 +modules@0.19.0 +modules-runtime@0.13.0 +mongo@1.16.0 +mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -npm-mongo@4.3.1 -oauth@2.1.1 +mongo-livedata@1.0.12 +npm-mongo@4.9.0 +oauth@2.1.2 oauth2@1.3.1 ordered-dict@1.1.0 -ostrio:cookies@2.7.0 -ostrio:files@2.0.1 +ostrio:cookies@2.7.2 +ostrio:files@2.3.0 patreon-oauth@0.1.0 peerlibrary:assert@0.3.0 peerlibrary:check-extension@0.7.0 @@ -90,12 +94,11 @@ peerlibrary:reactive-publish@0.10.0 peerlibrary:server-autorun@0.8.0 peerlibrary:subscription-data@0.8.0 percolate:migrations@1.0.3 -percolate:synced-cron@1.3.2 promise@0.12.0 raix:eventemitter@1.0.0 random@1.2.0 rate-limit@1.0.9 -react-fast-refresh@0.2.2 +react-fast-refresh@0.2.3 reactive-dict@1.3.0 reactive-var@1.0.11 reload@1.3.1 @@ -111,14 +114,14 @@ 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.4.0 -spacebars-compiler@1.3.0 -standard-minifier-js@2.8.0 +socket-stream-client@0.5.0 +spacebars-compiler@1.3.1 +standard-minifier-js@2.8.1 static-html@1.3.2 -templating-tools@1.2.1 +templating-tools@1.2.2 tmeasday:check-npm-versions@1.0.2 tracker@1.2.0 -typescript@4.4.1 +typescript@4.5.4 underscore@1.0.10 url@1.3.2 webapp@1.13.1 diff --git a/app/client/head.html b/app/client/head.html index 85ef16df..ba29f029 100644 --- a/app/client/head.html +++ b/app/client/head.html @@ -2,38 +2,38 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + diff --git a/app/imports/api/creature/archive/ArchiveCreatureFiles.js b/app/imports/api/creature/archive/ArchiveCreatureFiles.js index 99f4f7f2..1b699c0b 100644 --- a/app/imports/api/creature/archive/ArchiveCreatureFiles.js +++ b/app/imports/api/creature/archive/ArchiveCreatureFiles.js @@ -1,4 +1,8 @@ import { createS3FilesCollection } from '/imports/api/files/s3FileStorage.js'; +import SimpleSchema from 'simpl-schema'; +import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; +import { CreaturePropertySchema } from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { CreatureSchema } from '/imports/api/creature/creatures/Creatures.js'; const ArchiveCreatureFiles = createS3FilesCollection({ collectionName: 'archiveCreatureFiles', @@ -11,7 +15,38 @@ const ArchiveCreatureFiles = createS3FilesCollection({ if (!/json/i.test(file.extension)){ return 'Please upload only a JSON file'; } + return true; + }, + onAfterUpload(file) { + if (Meteor.isServer) incrementFileStorageUsed(file.userId, file.size); } }); +let archiveSchema = new SimpleSchema({ + meta: { + type: Object, + blackbox: true, + }, + creature: CreatureSchema, + properties: { + type: Array, + }, + 'properties.$': CreaturePropertySchema, + experiences: { + type: Array, + }, + 'experiences.$': { + type: Object, + blackbox: true, + }, + logs: { + type: Array, + }, + 'logs.$': { + type: Object, + blackbox: true, + }, +}); + export default ArchiveCreatureFiles; +export { archiveSchema }; diff --git a/app/imports/api/creature/archive/ArchivedCreatures.js b/app/imports/api/creature/archive/ArchivedCreatures.js deleted file mode 100644 index 4a572482..00000000 --- a/app/imports/api/creature/archive/ArchivedCreatures.js +++ /dev/null @@ -1,57 +0,0 @@ -import SimpleSchema from 'simpl-schema'; - -// Archived creatures is an immutable collection of creatures that are no longer -// in use and can be safely archived by the mongoDB hosting service. -// It keeps the working datasets like creatureProperties much smaller -// than they would otherwise be. -let ArchivedCreatures = new Mongo.Collection('archivedCreatures'); - -// We use blackbox objects for everything: -// - saves time checking every object against a schema -// - doesn't accidentaly create indices defined in subschemas -// - The objects we are archiving have already been checked against their -// own schemas -let ArchivedCreatureSchema = new SimpleSchema({ - owner: { - type: String, - regEx: SimpleSchema.RegEx.Id, - // The primary index on this collection - index: 1, - }, - archiveDate: { - type: Date, - // Indexed so the archiving system can archive documents when they - // get to a certain age - index: 1, - }, - creature: { - type: Object, - blackbox: true, - }, - properties: { - type: Array, - }, - 'properties.$': { - type: Object, - blackbox: true, - }, - experiences: { - type: Array, - }, - 'experiences.$': { - type: Object, - blackbox: true, - }, - logs: { - type: Array, - }, - 'logs.$': { - type: Object, - blackbox: true, - }, -}); - -ArchivedCreatures.attachSchema(ArchivedCreatureSchema); - -import '/imports/api/creature/archive/methods/index.js'; -export default ArchivedCreatures; diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js index 308eaccb..92b6f3bd 100644 --- a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js +++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js @@ -9,7 +9,6 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import Experiences from '/imports/api/creature/experience/Experiences.js'; import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; -import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; export function getArchiveObj(creatureId){ // Build the archive document @@ -32,7 +31,7 @@ export function getArchiveObj(creatureId){ return archiveCreature; } -export function archiveCreature(creatureId, userId){ +export function archiveCreature(creatureId){ const archive = getArchiveObj(creatureId); const buffer = Buffer.from(JSON.stringify(archive, null, 2)); ArchiveCreatureFiles.write(buffer, { @@ -44,12 +43,11 @@ export function archiveCreature(creatureId, userId){ creatureId: archive.creature._id, creatureName: archive.creature.name, }, - }, (error, file) => { + }, (error) => { if (error){ throw error; } else { removeCreatureWork(creatureId); - incrementFileStorageUsed(userId, file.size); } }, true); } diff --git a/app/imports/api/creature/archive/methods/index.js b/app/imports/api/creature/archive/methods/index.js index f2c61b6d..491ba038 100644 --- a/app/imports/api/creature/archive/methods/index.js +++ b/app/imports/api/creature/archive/methods/index.js @@ -1,5 +1,3 @@ -// import '/imports/api/creature/archive/methods/archiveCreatures.js'; import '/imports/api/creature/archive/methods/archiveCreatureToFile.js'; -import '/imports/api/creature/archive/methods/restoreCreatures.js'; import '/imports/api/creature/archive/methods/restoreCreatureFromFile.js'; import '/imports/api/creature/archive/methods/removeArchiveCreature.js'; diff --git a/app/imports/api/creature/archive/methods/removeArchiveCreature.js b/app/imports/api/creature/archive/methods/removeArchiveCreature.js index 22bf858d..f4ba608b 100644 --- a/app/imports/api/creature/archive/methods/removeArchiveCreature.js +++ b/app/imports/api/creature/archive/methods/removeArchiveCreature.js @@ -1,14 +1,7 @@ -import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js'; import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; -import Experiences from '/imports/api/creature/experience/Experiences.js'; -import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; -import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; const removeArchiveCreature = new ValidatedMethod({ diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js index 9130390a..9479b0c7 100644 --- a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js +++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js @@ -10,13 +10,14 @@ import { removeCreatureWork } from '/imports/api/creature/creatures/methods/remo import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; +import verifyArchiveSafety from '/imports/api/creature/archive/methods/verifyArchiveSafety.js'; let migrateArchive; if (Meteor.isServer){ migrateArchive = require('/imports/migrations/server/migrateArchive.js').default; } -function restoreCreature(archive){ +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.') @@ -25,6 +26,19 @@ function restoreCreature(archive){ // Migrate and verify the archive meets the current schema migrateArchive(archive); + // Asset that the archive is safe + verifyArchiveSafety(archive); + + // Don't upload creatures twice + const existingCreature = Creatures.findOne(archive.creature._id, { + fields: { _id: 1 } + }); + 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; + // Insert the creature sub documents // They still have their original _id's Creatures.insert(archive.creature); @@ -78,7 +92,7 @@ const restoreCreaturefromFile = new ValidatedMethod({ if (Meteor.isServer){ // Read the file data const archive = await ArchiveCreatureFiles.readJSONFile(file); - restoreCreature(archive); + restoreCreature(archive, this.userId); } //Remove the archive once the restore succeeded ArchiveCreatureFiles.remove({ _id: fileId }); diff --git a/app/imports/api/creature/archive/methods/restoreCreatures.js b/app/imports/api/creature/archive/methods/restoreCreatures.js deleted file mode 100644 index 5da270e7..00000000 --- a/app/imports/api/creature/archive/methods/restoreCreatures.js +++ /dev/null @@ -1,77 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import { assertOwnership } from '/imports/api/sharing/sharingPermissions.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; -import Experiences from '/imports/api/creature/experience/Experiences.js'; -import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js'; -import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; - -export function restoreCreature(archiveId){ - // Get the archive - const archivedCreature = ArchivedCreatures.findOne(archiveId); - - // Insert the creature sub documents - // They still have their original _id's - Creatures.insert(archivedCreature.creature); - try { - // Add all the properties - if (archivedCreature.properties && archivedCreature.properties.length){ - CreatureProperties.batchInsert(archivedCreature.properties); - } - if (archivedCreature.experiences && archivedCreature.experiences.length){ - Experiences.batchInsert(archivedCreature.experiences); - } - if (archivedCreature.logs && archivedCreature.logs.length){ - CreatureLogs.batchInsert(archivedCreature.logs); - } - // Remove the archived creature - ArchivedCreatures.remove(archiveId); - } catch (e) { - // If the above fails, delete the inserted creature - removeCreatureWork(archivedCreature.creature._id); - throw e; - } - - // Do not recompute. The creature was in a computed and ordered state when - // we archived it, just restore everything as-is - - return archivedCreature.creature._id; -} - -const restoreCreatures = new ValidatedMethod({ - name: 'Creatures.methods.restoreCreatures', - validate: new SimpleSchema({ - archiveIds: { - type: Array, - max: 10, - }, - 'archiveIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - }).validator(), - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 1, - timeInterval: 5000, - }, - run({archiveIds}) { - for (let id of archiveIds){ - let archivedCreature = ArchivedCreatures.findOne(id, { - fields: {owner: 1} - }); - assertOwnership(archivedCreature, this.userId) - } - let creatureIds = []; - for (let id of archiveIds){ - let creatureId = restoreCreature(id); - creatureIds.push(creatureId); - } - return creatureIds; - }, -}); - -export default restoreCreatures; diff --git a/app/imports/api/creature/archive/methods/verifyArchiveSafety.js b/app/imports/api/creature/archive/methods/verifyArchiveSafety.js new file mode 100644 index 00000000..734b2582 --- /dev/null +++ b/app/imports/api/creature/archive/methods/verifyArchiveSafety.js @@ -0,0 +1,28 @@ +import { slice } from 'lodash'; +import PER_CREATURE_LOG_LIMIT from '/imports/api/creature/log/CreatureLogs.js'; + +export default function verifyArchiveSafety({ meta, creature, properties, experiences, logs }){ + const creatureId = creature._id; + + // Check lengths of arrays + if (logs.length > PER_CREATURE_LOG_LIMIT) { + logs = slice(logs, 0, PER_CREATURE_LOG_LIMIT); + } + + // Check that everything belongs to the right creature + logs.forEach(log => { + if (log.creatureId !== creatureId) { + throw new Meteor.Error('Malicious log', 'Log contains an entry for the wrong creature'); + } + }); + experiences.forEach(experience => { + if (experience.creatureId !== creatureId) { + throw new Meteor.Error('Malicious experience', 'Experiences contains an entry for the wrong creature'); + } + }); + properties.forEach(prop => { + if (prop.ancestors[0].id !== creatureId) { + throw new Meteor.Error('Malicious prop', 'Properties contains an entry for the wrong creature'); + } + }); +} diff --git a/app/imports/api/creature/creatureFolders/CreatureFolders.js b/app/imports/api/creature/creatureFolders/CreatureFolders.js index 56e3fd69..e0e784cc 100644 --- a/app/imports/api/creature/creatureFolders/CreatureFolders.js +++ b/app/imports/api/creature/creatureFolders/CreatureFolders.js @@ -4,25 +4,25 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let CreatureFolders = new Mongo.Collection('creatureFolders'); let creatureFolderSchema = new SimpleSchema({ - name: { - type: String, - trim: false, - optional: true, + name: { + type: String, + trim: false, + optional: true, max: STORAGE_LIMITS.name, - }, - creatures: { - type: Array, - defaultValue: [], - }, - 'creatures.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - owner: { - type: String, - regEx: SimpleSchema.RegEx.Id, + }, + creatures: { + type: Array, + defaultValue: [], + }, + 'creatures.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + owner: { + type: String, + regEx: SimpleSchema.RegEx.Id, index: 1, - }, + }, archived: { type: Boolean, optional: true, diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.js b/app/imports/api/creature/creatureProperties/CreatureProperties.js index b70d96d5..ff301a79 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.js +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.js @@ -18,23 +18,23 @@ let CreaturePropertySchema = new SimpleSchema({ type: String, optional: true, }, - type: { + type: { type: String, allowedValues: Object.keys(propertySchemasIndex), }, - tags: { - type: Array, - defaultValue: [], + tags: { + type: Array, + defaultValue: [], maxCount: STORAGE_LIMITS.tagCount, - }, - 'tags.$': { - type: String, + }, + 'tags.$': { + type: String, max: STORAGE_LIMITS.tagLength, - }, - disabled: { - type: Boolean, - optional: true, - }, + }, + disabled: { + type: Boolean, + optional: true, + }, icon: { type: storedIconsSchema, optional: true, @@ -82,28 +82,31 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ index: 1, removeBeforeCompute: true, }, + // When this is true on any property, the creature needs to be recomputed + dirty: { + type: Boolean, + // Default to true because new properties cause a recomputation + defaultValue: true, + optional: true, + }, }); CreaturePropertySchema.extend(DenormalisedOnlyCreaturePropertySchema); -for (let key in propertySchemasIndex){ - let schema = new SimpleSchema({}); - schema.extend(propertySchemasIndex[key]); - schema.extend(CreaturePropertySchema); +for (let key in propertySchemasIndex) { + let schema = new SimpleSchema({}); + schema.extend(propertySchemasIndex[key]); + schema.extend(CreaturePropertySchema); schema.extend(ColorSchema); - schema.extend(ChildSchema); - schema.extend(SoftRemovableSchema); - CreatureProperties.attachSchema(schema, { - selector: {type: key} - }); + schema.extend(ChildSchema); + schema.extend(SoftRemovableSchema); + CreatureProperties.attachSchema(schema, { + selector: { type: key } + }); } -import '/imports/api/creature/creatureProperties/methods/index.js'; -//import '/imports/api/creature/actions/doAction.js'; -//import '/imports/api/creature/actions/castSpellWithSlot.js'; - export default CreatureProperties; export { DenormalisedOnlyCreaturePropertySchema, - CreaturePropertySchema, + CreaturePropertySchema, }; diff --git a/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js b/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js index b9958fcb..d08c43bb 100644 --- a/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js +++ b/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js @@ -4,7 +4,6 @@ import SimpleSchema from 'simpl-schema'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const adjustQuantity = new ValidatedMethod({ name: 'creatureProperties.adjustQuantity', @@ -21,43 +20,40 @@ const adjustQuantity = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id, operation, value}) { + run({ _id, operation, value }) { // Permissions - let property = CreatureProperties.findOne(_id); + let property = CreatureProperties.findOne(_id); let rootCreature = getRootCreatureAncestor(property); - assertEditPermission(rootCreature, this.userId); + assertEditPermission(rootCreature, this.userId); // Do work - adjustQuantityWork({property, operation, value}); - - // Changing quantity does not change dependencies, but recomputing the - // inventory changes many deps at once, so recompute fully - computeCreature(rootCreature._id); + adjustQuantityWork({ property, operation, value }); }, }); -export function adjustQuantityWork({property, operation, value}){ +export function adjustQuantityWork({ property, operation, value }) { // Check if property has quantity let schema = CreatureProperties.simpleSchema(property); - if (!schema.allowsKey('quantity')){ + if (!schema.allowsKey('quantity')) { throw new Meteor.Error( 'Adjust quantity failed', `Property of type "${property.type}" doesn't have a quantity` ); } - if (operation === 'set'){ + if (operation === 'set') { CreatureProperties.update(property._id, { - $set: {quantity: value} + $set: { quantity: value, dirty: true } }, { selector: property }); - } else if (operation === 'increment'){ + } else if (operation === 'increment') { // value here is 'damage' value = -value; let currentQuantity = property.quantity; if (currentQuantity + value < 0) value = -currentQuantity; CreatureProperties.update(property._id, { - $inc: {quantity: value} + $inc: { quantity: value }, + $set: { dirty: true } }, { selector: property }); diff --git a/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js b/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js deleted file mode 100644 index 6428eeb3..00000000 --- a/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js +++ /dev/null @@ -1,53 +0,0 @@ -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import SimpleSchema from 'simpl-schema'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; -import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; - -const damagePropertiesByName = new ValidatedMethod({ - name: 'CreatureProperties.damagePropertiesByName', - validate: new SimpleSchema({ - creatureId: SimpleSchema.RegEx.Id, - variableName: { - type: String, - }, - operation: { - type: String, - allowedValues: ['set', 'increment'] - }, - value: Number, - }).validator(), - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 20, - timeInterval: 5000, - }, - run({creatureId, variableName, operation, value}) { - // Check permissions - let creature = Creatures.findOne(creatureId, { - fields: { - variables: 1, - owner: 1, - readers: 1, - writers: 1, - }, - }); - assertEditPermission(creature, this.userId); - CreatureProperties.find({ - 'ancestors.id': creatureId, - variableName, - removed: {$ne: false}, - inactive: {$ne: true}, - }).forEach(property => { - // Check if property can take damage - let schema = CreatureProperties.simpleSchema(property); - if (!schema.allowsKey('damage')) return; - // Damage the property - damagePropertyWork({property, operation, value}); - }); - } -}); - -export default damagePropertiesByName; diff --git a/app/imports/api/creature/creatureProperties/methods/damageProperty.js b/app/imports/api/creature/creatureProperties/methods/damageProperty.js index 573e004a..507d82bd 100644 --- a/app/imports/api/creature/creatureProperties/methods/damageProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/damageProperty.js @@ -2,9 +2,9 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import SimpleSchema from 'simpl-schema'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; -import { computeCreatureDependencyGroup } from '/imports/api/engine/computeCreature.js'; +import { applyTriggers } from '/imports/api/engine/actions/applyTriggers.js'; +import ActionContext from '/imports/api/engine/actions/ActionContext.js'; const damageProperty = new ValidatedMethod({ name: 'creatureProperties.damage', @@ -21,60 +21,119 @@ const damageProperty = new ValidatedMethod({ numRequests: 20, timeInterval: 5000, }, - run({_id, operation, value}) { - // Check permissions - let property = CreatureProperties.findOne(_id); - if (!property) throw new Meteor.Error( + run({ _id, operation, value }) { + + // Get action context + let prop = CreatureProperties.findOne(_id); + if (!prop) throw new Meteor.Error( 'Damage property failed', 'Property doesn\'t exist' ); - let rootCreature = getRootCreatureAncestor(property); - assertEditPermission(rootCreature, this.userId); - // Check if property can take damage - let schema = CreatureProperties.simpleSchema(property); - if (!schema.allowsKey('damage')){ - throw new Meteor.Error( - 'Damage property failed', - `Property of type "${property.type}" can't be damaged` - ); - } - let result = damagePropertyWork({property, operation, value}); - // Dependencies can't be changed through damage, only recompute deps - computeCreatureDependencyGroup(property); + const creatureId = prop.ancestors[0].id; + const actionContext = new ActionContext(creatureId, [creatureId], this); + + // Check permissions + assertEditPermission(actionContext.creature, this.userId); + + // Check if property can take damage + let schema = CreatureProperties.simpleSchema(prop); + if (!schema.allowsKey('damage')) { + throw new Meteor.Error( + 'Damage property failed', + `Property of type "${prop.type}" can't be damaged` + ); + } + + // Replace the prop by its actionContext counterpart if possible + if (prop.variableName) { + const actionContextProp = actionContext.scope[prop.variableName]; + if (actionContextProp?._id === prop._id) { + prop = actionContextProp; + } + } + + const result = damagePropertyWork({ prop, operation, value, actionContext }); + + // Insert the log + actionContext.writeLog(); return result; }, }); -export function damagePropertyWork({property, operation, value}){ - let damage, newValue; - if (operation === 'set'){ - const total = property.total || 0; +export function damagePropertyWork({ prop, operation, value, actionContext }) { + + // Save the value to the scope before applying the before triggers + if (operation === 'increment') { + if (value >= 0) { + actionContext.scope['$damage'] = value; + } else { + actionContext.scope['$healing'] = -value; + } + } else { + actionContext.scope['$set'] = value; + } + + applyTriggers(actionContext.triggers?.damageProperty?.before, prop, actionContext); + + // 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']; + } else { + value = -actionContext.scope['$healing']; + } + } else { + value = actionContext.scope['$set']; + } + + let damage, newValue, increment; + if (operation === 'set') { + const total = prop.total || 0; // Set represents what we want the value to be after damage // So we need the actual damage to get to that value damage = total - value; // Damage can't exceed total value - if (damage > total) damage = total; + if (damage > total && !prop.ignoreLowerLimit) damage = total; // Damage must be positive - if (damage < 0) damage = 0; - newValue = property.total - damage; - } else if (operation === 'increment'){ - let currentValue = property.value || 0; - let currentDamage = property.damage || 0; - let increment = value; + if (damage < 0 && !prop.ignoreUpperLimit) damage = 0; + newValue = prop.total - damage; + // Write the results + CreatureProperties.update(prop._id, { + $set: { damage, value: newValue, dirty: true } + }, { + selector: prop + }); + // Also write it straight to the prop so that it is updated in the actionContext + prop.damage = damage; + prop.value = newValue; + } else if (operation === 'increment') { + let currentValue = prop.value || 0; + let currentDamage = prop.damage || 0; + increment = value; // Can't increase damage above the remaining value - if (increment > currentValue) increment = currentValue; + if (increment > currentValue && !prop.ignoreLowerLimit) increment = currentValue; // Can't decrease damage below zero - if (-increment > currentDamage) increment = -currentDamage; + if (-increment > currentDamage && !prop.ignoreUpperLimit) increment = -currentDamage; damage = currentDamage + increment; - newValue = property.total - damage; + newValue = prop.total - damage; + // Write the results + CreatureProperties.update(prop._id, { + $inc: { damage: increment, value: -increment }, + $set: { dirty: true }, + }, { + selector: prop + }); + // Also write it straight to the prop so that it is updated in the actionContext + prop.damage += increment; + prop.value -= increment; } - // Write the results - CreatureProperties.update(property._id, { - $set: {damage, value: newValue} - }, { - selector: property - }); - return damage; + applyTriggers(actionContext.triggers?.damageProperty?.after, prop, actionContext); + + if (operation === 'set') { + return damage; + } else if (operation === 'increment') { + return increment; + } } export default damageProperty; diff --git a/app/imports/api/creature/creatureProperties/methods/dealDamage.js b/app/imports/api/creature/creatureProperties/methods/dealDamage.js deleted file mode 100644 index b93aa495..00000000 --- a/app/imports/api/creature/creatureProperties/methods/dealDamage.js +++ /dev/null @@ -1,72 +0,0 @@ -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import SimpleSchema from 'simpl-schema'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; -import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; - -const dealDamage = new ValidatedMethod({ - name: 'creatureProperties.dealDamage', - validate: new SimpleSchema({ - creatureId: SimpleSchema.RegEx.Id, - damageType: { - type: String, - }, - amount: Number, - }).validator(), - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 20, - timeInterval: 5000, - }, - run({creatureId, damageType, amount}) { - // permissions - let creature = Creatures.findOne(creatureId, { - fields: { - owner: 1, - readers: 1, - writers: 1, - }, - }); - assertEditPermission(creature, this.userId); - - const totalDamage = dealDamageWork({creature, damageType, amount}) - computeCreature(creatureId); - return totalDamage; - }, -}); - -export function dealDamageWork({creature, damageType, amount}){ - // Get all the health bars and do damage to them - let healthBars = CreatureProperties.find({ - 'ancestors.id': creature._id, - type: 'attribute', - attributeType:'healthBar', - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - sort: {order: -1}, - }); - //let multiplier = creature.damageMultipliers[damageType]; - //if (multiplier === undefined) multiplier = 1; - //let totalDamage = Math.floor(amount * multiplier); - const totalDamage = amount; - let damageLeft = totalDamage; - if (damageType === 'healing') damageLeft = -totalDamage; - let propertyIds = []; - healthBars.forEach(healthBar => { - if (damageLeft === 0) return; - let damageAdded = damagePropertyWork({ - property: healthBar, - operation: 'increment', - value: damageLeft, - }); - damageLeft -= damageAdded; - propertyIds.push(healthBar._id); - }); - return totalDamage; -} - -export default dealDamage; diff --git a/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js b/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js index 03c505c2..5a8c84fe 100644 --- a/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js @@ -5,13 +5,12 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import { - setLineageOfDocs, - renewDocIds + setLineageOfDocs, + renewDocIds } from '/imports/api/parenting/parenting.js'; import { reorderDocs } from '/imports/api/parenting/order.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; var snackbar; -if (Meteor.isClient){ +if (Meteor.isClient) { snackbar = require( '/imports/ui/components/snackbars/SnackbarQueue.js' ).snackbar @@ -32,7 +31,7 @@ const duplicateProperty = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id}) { + run({ _id }) { let property = CreatureProperties.findOne(_id); let creature = getRootCreatureAncestor(property); @@ -45,17 +44,17 @@ const duplicateProperty = new ValidatedMethod({ // Get all the descendants let nodes = CreatureProperties.find({ - 'ancestors.id': _id, - removed: {$ne: true}, - }, { + 'ancestors.id': _id, + removed: { $ne: true }, + }, { limit: DUPLICATE_CHILDREN_LIMIT + 1, - sort: {order: 1}, + sort: { order: 1 }, }).fetch(); // Alert the user if the limit was hit - if (nodes.length > DUPLICATE_CHILDREN_LIMIT){ + if (nodes.length > DUPLICATE_CHILDREN_LIMIT) { nodes.pop(); - if (Meteor.isClient){ + if (Meteor.isClient) { snackbar({ text: `Only the first ${DUPLICATE_CHILDREN_LIMIT} children were duplicated`, }); @@ -64,22 +63,25 @@ const duplicateProperty = new ValidatedMethod({ // re-map all the ancestors setLineageOfDocs({ - docArray: nodes, - newAncestry : [ + docArray: nodes, + newAncestry: [ ...property.ancestors, - {id: propertyId, collection: 'creatureProperties'} + { id: propertyId, collection: 'creatureProperties' } ], - oldParent : {id: _id, collection: 'creatureProperties'}, - }); + oldParent: { id: _id, collection: 'creatureProperties' }, + }); // Give the docs new IDs without breaking internal references - renewDocIds({docArray: nodes}); + renewDocIds({ docArray: nodes }); // Order the root node property.order += 0.5; + // Mark the sheet as needing recompute + property.dirty = true; + // Insert the properties - CreatureProperties.batchInsert([property, ...nodes]); + CreatureProperties.batchInsert([property, ...nodes]); // Tree structure changed by inserts, reorder the tree reorderDocs({ @@ -87,9 +89,6 @@ const duplicateProperty = new ValidatedMethod({ ancestorId: property.ancestors[0].id, }); - // Inserting a creature property invalidates dependencies: full recompute - computeCreature(creature._id); - return propertyId; }, }); diff --git a/app/imports/api/creature/creatureProperties/methods/equipItem.js b/app/imports/api/creature/creatureProperties/methods/equipItem.js index 24cb51fa..671908b3 100644 --- a/app/imports/api/creature/creatureProperties/methods/equipItem.js +++ b/app/imports/api/creature/creatureProperties/methods/equipItem.js @@ -4,15 +4,14 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { organizeDoc } from '/imports/api/parenting/organizeMethods.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import BUILT_IN_TAGS from '/imports/constants/BUILT_IN_TAGS.js'; import getParentRefByTag from '/imports/api/creature/creatureProperties/methods/getParentRefByTag.js'; // Equipping or unequipping an item will also change its parent const equipItem = new ValidatedMethod({ name: 'creatureProperties.equip', - validate({_id, equipped}){ - if (!_id) throw new Meteor.Error('No _id', '_id is required'); + validate({ _id, equipped }) { + if (!_id) throw new Meteor.Error('No _id', '_id is required'); if (equipped !== true && equipped !== false) { throw new Meteor.Error('No equipped', 'equipped is required to be true or false'); } @@ -22,20 +21,20 @@ const equipItem = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id, equipped}) { + run({ _id, equipped }) { let item = CreatureProperties.findOne(_id); if (item.type !== 'item') throw new Meteor.Error('wrong type', - 'Equip and unequip can only be performed on items'); + 'Equip and unequip can only be performed on items'); let creature = getRootCreatureAncestor(item); assertEditPermission(creature, this.userId); CreatureProperties.update(_id, { - $set: {equipped}, + $set: { equipped, dirty: true }, }, { - selector: {type: 'item'}, - }); + selector: { type: 'item' }, + }); let tag = equipped ? BUILT_IN_TAGS.equipment : BUILT_IN_TAGS.carried; let parentRef = getParentRefByTag(creature._id, tag); - if (!parentRef) parentRef = {id: creature._id, collection: 'creatures'}; + if (!parentRef) parentRef = { id: creature._id, collection: 'creatures' }; organizeDoc.call({ docRef: { @@ -46,8 +45,6 @@ const equipItem = new ValidatedMethod({ order: Number.MAX_SAFE_INTEGER, skipRecompute: true, }); - - computeCreature(creature._id); }, }); diff --git a/app/imports/api/creature/creatureProperties/methods/flipToggle.js b/app/imports/api/creature/creatureProperties/methods/flipToggle.js index e39d0c75..dbd814ff 100644 --- a/app/imports/api/creature/creatureProperties/methods/flipToggle.js +++ b/app/imports/api/creature/creatureProperties/methods/flipToggle.js @@ -3,28 +3,27 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const flipToggle = new ValidatedMethod({ name: 'creatureProperties.flipToggle', - validate({_id}){ - if (!_id) throw new Meteor.Error('No _id', '_id is required'); + validate({ _id }) { + if (!_id) throw new Meteor.Error('No _id', '_id is required'); }, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}) { + run({ _id }) { // Permission let property = CreatureProperties.findOne(_id, { - fields: {type: 1, ancestors: 1, enabled: 1, disabled: 1} + fields: { type: 1, ancestors: 1, enabled: 1, disabled: 1 } }); - if (property.type !== 'toggle'){ + if (property.type !== 'toggle') { throw new Meteor.Error('wrong property', 'This method can only be applied to toggles'); } - if (!property.enabled && !property.disabled){ + if (!property.enabled && !property.disabled) { throw new Meteor.Error('Computed toggle', 'Can\'t flip a toggle that is computed') } @@ -33,15 +32,15 @@ const flipToggle = new ValidatedMethod({ // Invert the current value, disabled is the canonical store of value const currentValue = !property.disabled; - CreatureProperties.update(_id, {$set: { - enabled: !currentValue, - disabled: currentValue, - }}, { - selector: {type: 'toggle'}, - }); - - // Updating a toggle is likely to change the whole tree, do a full recompute - computeCreature(rootCreature._id); + CreatureProperties.update(_id, { + $set: { + enabled: !currentValue, + disabled: currentValue, + dirty: true, + } + }, { + selector: { type: 'toggle' }, + }); }, }); diff --git a/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js index 1bba9421..d4670c95 100644 --- a/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js +++ b/app/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js @@ -15,9 +15,28 @@ export default function getSlotFillFilter({slot, libraryIds}){ slotFillerType: slot.slotType, }] }); + } else if (slot.type === 'class') { + filter.$and.push({ + $or: [{ + type: 'classLevel', + },{ + type: 'slotFiller', + slotFillerType: 'classLevel', + }] + }); + 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}; + } else { + filter.level = (slot.level || 0) + 1; + } } let tagsOr = []; - let tagsNor = []; + let tagsNin = []; if (slot.slotTags && slot.slotTags.length){ tagsOr.push({tags: {$all: slot.slotTags}}); } @@ -27,15 +46,15 @@ export default function getSlotFillFilter({slot, libraryIds}){ if (extra.operation === 'OR'){ tagsOr.push({tags: {$all: extra.tags}}); } else if (extra.operation === 'NOT'){ - tagsNor.push({tags: {$all: extra.tags}}); + tagsNin.push(...extra.tags); } }); } if (tagsOr.length){ - filter.$and.push({$or: tagsOr}); + filter.$or = tagsOr; } - if (tagsNor.length){ - filter.$and.push({$nor: tagsNor}); + if (tagsNin.length){ + filter.$and.push({tags: {$nin: tagsNin}}); } if (!filter.$and.length){ delete filter.$and; diff --git a/app/imports/api/creature/creatureProperties/methods/index.js b/app/imports/api/creature/creatureProperties/methods/index.js index 45a0e323..b71515e9 100644 --- a/app/imports/api/creature/creatureProperties/methods/index.js +++ b/app/imports/api/creature/creatureProperties/methods/index.js @@ -1,7 +1,5 @@ import '/imports/api/creature/creatureProperties/methods/adjustQuantity.js'; -import '/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js'; import '/imports/api/creature/creatureProperties/methods/damageProperty.js'; -import '/imports/api/creature/creatureProperties/methods/dealDamage.js'; import '/imports/api/creature/creatureProperties/methods/duplicateProperty.js'; import '/imports/api/creature/creatureProperties/methods/equipItem.js'; import '/imports/api/creature/creatureProperties/methods/insertProperty.js'; diff --git a/app/imports/api/creature/creatureProperties/methods/insertProperty.js b/app/imports/api/creature/creatureProperties/methods/insertProperty.js index 4535e1fe..2d872868 100644 --- a/app/imports/api/creature/creatureProperties/methods/insertProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/insertProperty.js @@ -5,7 +5,6 @@ import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/ge import SimpleSchema from 'simpl-schema'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { reorderDocs } from '/imports/api/parenting/order.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import { getAncestry } from '/imports/api/parenting/parenting.js'; import getParentRefByTag from '/imports/api/creature/creatureProperties/methods/getParentRefByTag.js'; import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; @@ -13,7 +12,7 @@ import { getHighestOrder } from '/imports/api/parenting/order.js'; const insertProperty = new ValidatedMethod({ name: 'creatureProperties.insert', - validate: new SimpleSchema({ + validate: new SimpleSchema({ creatureProperty: { type: Object, blackbox: true, @@ -25,25 +24,25 @@ const insertProperty = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({creatureProperty, parentRef}) { + run({ creatureProperty, parentRef }) { // get the new ancestry for the properties - let {parentDoc, ancestors} = getAncestry({parentRef}); + let { parentDoc, ancestors } = getAncestry({ parentRef }); - // Check permission to edit + // Check permission to edit let rootCreature; - if (parentRef.collection === 'creatures'){ + if (parentRef.collection === 'creatures') { rootCreature = parentDoc; - } else if (parentRef.collection === 'creatureProperties'){ + } else if (parentRef.collection === 'creatureProperties') { rootCreature = getRootCreatureAncestor(parentDoc); - } else { - throw `${parentRef.collection} is not a valid parent collection` - } + } else { + throw `${parentRef.collection} is not a valid parent collection` + } assertEditPermission(rootCreature, this.userId); creatureProperty.parent = parentRef; creatureProperty.ancestors = ancestors; - return insertPropertyWork({ + return insertPropertyWork({ property: creatureProperty, creature: rootCreature, }); @@ -76,31 +75,31 @@ const insertPropertyAsChildOfTag = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({creatureProperty, creatureId, tag, tagDefaultName}) { + run({ creatureProperty, creatureId, tag, tagDefaultName }) { let parentRef = getParentRefByTag(creatureId, tag); - if (!parentRef){ + if (!parentRef) { // Use the creature as the parent and mark that we need to insert the folder first later var insertFolderFirst = true; - parentRef = {id: creatureId, collection: 'creatures'}; + parentRef = { id: creatureId, collection: 'creatures' }; } // get the new ancestry for the properties - let {parentDoc, ancestors} = getAncestry({parentRef}); + let { parentDoc, ancestors } = getAncestry({ parentRef }); // Check permission to edit let rootCreature; - if (parentRef.collection === 'creatures'){ + if (parentRef.collection === 'creatures') { rootCreature = parentDoc; - } else if (parentRef.collection === 'creatureProperties'){ + } else if (parentRef.collection === 'creatureProperties') { rootCreature = getRootCreatureAncestor(parentDoc); - } else { - throw `${parentRef.collection} is not a valid parent collection` - } + } else { + throw `${parentRef.collection} is not a valid parent collection` + } assertEditPermission(rootCreature, this.userId); // Add the folder first if we need to - if (insertFolderFirst){ + if (insertFolderFirst) { let order = getHighestOrder({ collection: CreatureProperties, ancestorId: parentRef.id, @@ -114,7 +113,7 @@ const insertPropertyAsChildOfTag = new ValidatedMethod({ order, }); // Make the folder our new parent - let newParentRef = {id, collection: 'creatureProperties'}; + let newParentRef = { id, collection: 'creatureProperties' }; ancestors = [parentRef, newParentRef]; parentRef = newParentRef; creatureProperty.order = order + 1; @@ -123,23 +122,22 @@ const insertPropertyAsChildOfTag = new ValidatedMethod({ creatureProperty.parent = parentRef; creatureProperty.ancestors = ancestors; - return insertPropertyWork({ + return insertPropertyWork({ property: creatureProperty, creature: rootCreature, }); }, }); -export function insertPropertyWork({property, creature}){ +export function insertPropertyWork({ property, creature }) { delete property._id; + property.dirty = true; let _id = CreatureProperties.insert(property); // Tree structure changed by insert, reorder the tree reorderDocs({ collection: CreatureProperties, ancestorId: creature._id, }); - // Inserting a creature property invalidates dependencies: full recompute - computeCreature(creature._id); return _id; } diff --git a/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js b/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js index e73cbe33..641a9cfb 100644 --- a/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js +++ b/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js @@ -5,60 +5,59 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { - setLineageOfDocs, - getAncestry, - renewDocIds + 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 fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; const insertPropertyFromLibraryNode = new ValidatedMethod({ - name: 'creatureProperties.insertPropertyFromLibraryNode', - validate: new SimpleSchema({ + name: 'creatureProperties.insertPropertyFromLibraryNode', + validate: new SimpleSchema({ nodeIds: { type: Array, max: 20, }, - 'nodeIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - parentRef: { - type: RefSchema, - }, + 'nodeIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + parentRef: { + type: RefSchema, + }, order: { type: Number, optional: true, }, - }).validator(), + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({nodeIds, parentRef, order}) { - // get the new ancestry for the properties - let {parentDoc, ancestors} = getAncestry({parentRef}); + run({ nodeIds, parentRef, order }) { + // get the new ancestry for the properties + let { parentDoc, ancestors } = getAncestry({ parentRef }); - // Check permission to edit + // Check permission to edit let rootCreature; - if (parentRef.collection === 'creatures'){ + if (parentRef.collection === 'creatures') { rootCreature = parentDoc; - } else if (parentRef.collection === 'creatureProperties'){ + } else if (parentRef.collection === 'creatureProperties') { rootCreature = getRootCreatureAncestor(parentDoc); - } else { - throw `${parentRef.collection} is not a valid parent collection` - } + } else { + throw `${parentRef.collection} is not a valid parent collection` + } assertEditPermission(rootCreature, this.userId); // {libraryId: hasViewPermission} //let libraryPermissionMemoir = {}; let node; - nodeIds.forEach(nodeId => { + nodeIds.forEach(nodeId => { // TODO: Check library view permission for each node before starting node = insertPropertyFromNode(nodeId, ancestors, order); }); @@ -71,21 +70,18 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({ collection: CreatureProperties, ancestorId: rootCreature._id, }); - - // Inserting a creature property invalidates dependencies: full recompute - computeCreature(rootCreature._id); - // Return the docId of the last property, the inserted root property - return rootId; - }, + // Return the docId of the last property, the inserted root property + return rootId; + }, }); -function insertPropertyFromNode(nodeId, ancestors, order){ +function insertPropertyFromNode(nodeId, ancestors, order) { // Fetch the library node and its decendents, provided they have not been // removed // TODO: Check permission to read the library this node is in let node = LibraryNodes.findOne({ _id: nodeId, - removed: {$ne: true}, + removed: { $ne: true }, }); if (!node) { if (Meteor.isClient) return; @@ -99,7 +95,7 @@ function insertPropertyFromNode(nodeId, ancestors, order){ let oldParent = node.parent; let nodes = LibraryNodes.find({ 'ancestors.id': nodeId, - removed: {$ne: true}, + removed: { $ne: true }, }).fetch(); // Convert all references into actual nodes @@ -122,11 +118,11 @@ function insertPropertyFromNode(nodeId, ancestors, order){ // Give the docs new IDs without breaking internal references renewDocIds({ docArray: nodes, - collectionMap: {'libraryNodes': 'creatureProperties'} + collectionMap: { 'libraryNodes': 'creatureProperties' } }); // Order the root node - if (order === undefined){ + if (order === undefined) { setDocToLastOrder({ collection: CreatureProperties, doc: node, @@ -135,22 +131,30 @@ function insertPropertyFromNode(nodeId, ancestors, order){ node.order = order; } + // Mark all nodes as dirty + dirtyNodes(nodes); + // Insert the creature properties CreatureProperties.batchInsert(nodes); return node; } - -function storeLibraryNodeReferences(nodes){ +function storeLibraryNodeReferences(nodes) { nodes.forEach(node => { if (node.libraryNodeId) return; node.libraryNodeId = node._id; }); } +function dirtyNodes(nodes) { + nodes.forEach(node => { + node.dirty = true; + }); +} + // Covert node references into actual nodes // TODO: check permissions for each library a reference node references -function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){ +function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0) { depth += 1; // New nodes added this function let newNodes = []; @@ -161,9 +165,9 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){ if (node.type !== 'reference') return true; // We have gone too deep, keep the reference node as an error - if (depth >= 10){ + if (depth >= 10) { if (Meteor.isClient) console.warn('Reference depth limit exceeded'); - node.cache = {error: 'Reference depth limit exceeded'}; + node.cache = { error: 'Reference depth limit exceeded' }; return true; } @@ -173,17 +177,17 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){ referencedNode.order = node.order; // We are definitely replacing this node, so add it to the list visitedRefs.add(node._id); - } catch (e){ - node.cache = {error: e.reason || e.message || e.toString()}; + } catch (e) { + node.cache = { error: e.reason || e.message || e.toString() }; return true; } // Get all the descendants of the referenced node let descendents = LibraryNodes.find({ 'ancestors.id': referencedNode._id, - removed: {$ne: true}, + removed: { $ne: true }, }, { - sort: {order: 1}, + sort: { order: 1 }, }).fetch(); // We are adding the referenced node and its descendants @@ -191,20 +195,20 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){ // re-map all the ancestors to parent the new sub-tree into our existing // node tree - setLineageOfDocs({ - docArray: addedNodes, - newAncestry: node.ancestors, - oldParent: referencedNode.parent, - }); + setLineageOfDocs({ + docArray: addedNodes, + newAncestry: node.ancestors, + oldParent: referencedNode.parent, + }); // Filter all the looped references addedNodes = addedNodes.filter(addedNode => { // Add all non-reference nodes - if (addedNode.type !== 'reference'){ + if (addedNode.type !== 'reference') { return true; } // If this exact reference has already been resolved before, filter it out - if (visitedRefs.has(addedNode._id)){ + if (visitedRefs.has(addedNode._id)) { return false; } else { // Otherwise mark it as visited, and keep it diff --git a/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js b/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js index fa2acf78..4dd8245e 100644 --- a/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js @@ -3,34 +3,30 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const pullFromProperty = new ValidatedMethod({ - name: 'creatureProperties.pull', - validate: null, + name: 'creatureProperties.pull', + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, itemId}){ + run({ _id, path, itemId }) { // Permissions - let property = CreatureProperties.findOne(_id); + let property = CreatureProperties.findOne(_id); let rootCreature = getRootCreatureAncestor(property); assertEditPermission(rootCreature, this.userId); // Do work - CreatureProperties.update(_id, { - $pull: {[path.join('.')]: {_id: itemId}}, - }, { - selector: {type: property.type}, - getAutoValues: false, - }); - - // TODO figure out if this method can change deps or not - computeCreature(rootCreature._id); - // recomputePropertyDependencies(property); - } + CreatureProperties.update(_id, { + $pull: { [path.join('.')]: { _id: itemId } }, + $set: { dirty: true } + }, { + selector: { type: property.type }, + getAutoValues: false, + }); + } }); export default pullFromProperty; diff --git a/app/imports/api/creature/creatureProperties/methods/pushToProperty.js b/app/imports/api/creature/creatureProperties/methods/pushToProperty.js index e730065c..95735d9b 100644 --- a/app/imports/api/creature/creatureProperties/methods/pushToProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/pushToProperty.js @@ -3,20 +3,19 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import { get } from 'lodash'; const pushToProperty = new ValidatedMethod({ - name: 'creatureProperties.push', - validate: null, + name: 'creatureProperties.push', + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, value}){ + run({ _id, path, value }) { // Permissions - let property = CreatureProperties.findOne(_id); + let property = CreatureProperties.findOne(_id); let rootCreature = getRootCreatureAncestor(property); assertEditPermission(rootCreature, this.userId); @@ -26,10 +25,10 @@ const pushToProperty = new ValidatedMethod({ let schema = CreatureProperties.simpleSchema(property); let maxCount = schema.get(joinedPath, 'maxCount'); - if (Number.isFinite(maxCount)){ + if (Number.isFinite(maxCount)) { let array = get(property, path); let currentCount = array ? array.length : 0; - if (currentCount >= maxCount){ + if (currentCount >= maxCount) { throw new Meteor.Error( 'Array is full', `Cannot have more than ${maxCount} values` @@ -38,15 +37,13 @@ const pushToProperty = new ValidatedMethod({ } // Do work - CreatureProperties.update(_id, { - $push: {[joinedPath]: value}, - }, { - selector: {type: property.type}, - }); - - // TODO figure out if this method can change deps or not - computeCreature(rootCreature._id); - } + CreatureProperties.update(_id, { + $push: { [joinedPath]: value }, + $set: { dirty: true }, + }, { + selector: { type: property.type }, + }); + } }); export default pushToProperty; diff --git a/app/imports/api/creature/creatureProperties/methods/restoreProperty.js b/app/imports/api/creature/creatureProperties/methods/restoreProperty.js index b612e6c7..9e66448e 100644 --- a/app/imports/api/creature/creatureProperties/methods/restoreProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/restoreProperty.js @@ -5,30 +5,32 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { restore } from '/imports/api/parenting/softRemove.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const restoreProperty = new ValidatedMethod({ - name: 'creatureProperties.restore', - validate: new SimpleSchema({ - _id: SimpleSchema.RegEx.Id - }).validator(), + name: 'creatureProperties.restore', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}){ + run({ _id }) { // Permissions - let property = CreatureProperties.findOne(_id); + let property = CreatureProperties.findOne(_id); let rootCreature = getRootCreatureAncestor(property); assertEditPermission(rootCreature, this.userId); // Do work - restore({_id, collection: CreatureProperties}); - - // Changes dependency tree by restoring children - computeCreature(rootCreature._id); - } + restore({ + _id, + collection: CreatureProperties, + extraUpdates: { + $set: { dirty: true } + }, + }); + } }); export default restoreProperty; diff --git a/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js b/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js index c1e4baa3..57bbe6bd 100644 --- a/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js +++ b/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js @@ -4,7 +4,6 @@ import SimpleSchema from 'simpl-schema'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const selectAmmoItem = new ValidatedMethod({ name: 'creatureProperties.selectAmmoItem', @@ -18,34 +17,29 @@ const selectAmmoItem = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({actionId, itemId, itemConsumedIndex}) { + run({ actionId, itemId, itemConsumedIndex }) { // Permissions - let action = CreatureProperties.findOne(actionId); + let action = CreatureProperties.findOne(actionId); let rootCreature = getRootCreatureAncestor(action); - assertEditPermission(rootCreature, this.userId); + assertEditPermission(rootCreature, this.userId); // Check that this index has a document to edit let itemConsumed = action.resources.itemsConsumed[itemConsumedIndex]; - if (!itemConsumed){ + if (!itemConsumed) { throw new Meteor.Error('Resouce not found', 'Could not set ammo, because the ammo document was not found'); } let itemToLink = CreatureProperties.findOne(itemId); - if (!itemToLink){ + if (!itemToLink) { throw new Meteor.Error('Item not found', 'Could not set ammo: the item was not found'); } let path = `resources.itemsConsumed.${itemConsumedIndex}.itemId`; CreatureProperties.update(actionId, { - $set: {[path]: itemId} + $set: { [path]: itemId, dirty: true } }, { selector: action, }); - - // Changing the linked item does change the dependency tree - // TODO: We can predict exactly which deps will be affected instead of - // recomputing the entire creature - computeCreature(rootCreature._id); }, }); diff --git a/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js b/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js index 86df9247..a4240ac7 100644 --- a/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js @@ -5,30 +5,26 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { softRemove } from '/imports/api/parenting/softRemove.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const softRemoveProperty = new ValidatedMethod({ - name: 'creatureProperties.softRemove', - validate: new SimpleSchema({ - _id: SimpleSchema.RegEx.Id - }).validator(), + name: 'creatureProperties.softRemove', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}){ + run({ _id }) { // Permissions - let property = CreatureProperties.findOne(_id); + let property = CreatureProperties.findOne(_id); let rootCreature = getRootCreatureAncestor(property); assertEditPermission(rootCreature, this.userId); // Do work - softRemove({_id, collection: CreatureProperties}); - - // Changes dependency tree by removing children - computeCreature(rootCreature._id); - } + softRemove({ _id, collection: CreatureProperties }); + } }); export default softRemoveProperty; diff --git a/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js b/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js index d53ddc6e..4b606114 100644 --- a/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js +++ b/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js @@ -3,32 +3,31 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; const updateCreatureProperty = new ValidatedMethod({ name: 'creatureProperties.update', - validate({_id, path}){ - if (!_id) throw new Meteor.Error('No _id', '_id is required'); - // We cannot change these fields with a simple update - switch (path[0]){ - case 'type': + validate({ _id, path }) { + if (!_id) throw new Meteor.Error('No _id', '_id is required'); + // We cannot change these fields with a simple update + switch (path[0]) { + case 'type': case 'order': case 'parent': case 'ancestors': - case 'damage': - throw new Meteor.Error('Permission denied', - 'This property can\'t be updated directly'); - } + case 'damage': + throw new Meteor.Error('Permission denied', + 'This property can\'t be updated directly'); + } }, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, value}) { + run({ _id, path, value }) { // Permission let property = CreatureProperties.findOne(_id, { - fields: {type: 1, ancestors: 1} + fields: { type: 1, ancestors: 1 } }); let rootCreature = getRootCreatureAncestor(property); assertEditPermission(rootCreature, this.userId); @@ -36,18 +35,14 @@ const updateCreatureProperty = new ValidatedMethod({ let pathString = path.join('.'); let modifier; // unset empty values - if (value === null || value === undefined){ - modifier = {$unset: {[pathString]: 1}}; + if (value === null || value === undefined) { + modifier = { $unset: { [pathString]: 1 }, $set: { dirty: true } }; } else { - modifier = {$set: {[pathString]: value}}; + modifier = { $set: { [pathString]: value, dirty: true } }; } - CreatureProperties.update(_id, modifier, { - selector: {type: property.type}, - }); - - // Updating a property is likely to change dependencies, do a full recompute - // denormalised stats might change, so fetch the creature again - computeCreature(rootCreature._id); + CreatureProperties.update(_id, modifier, { + selector: { type: property.type }, + }); }, }); diff --git a/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js b/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js index cee1237d..a2190860 100644 --- a/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js +++ b/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js @@ -4,9 +4,9 @@ import computeCreature from '/imports/api/engine/computeCreature.js'; * Recomputes all ancestor creatures of this property */ export default function recomputeCreaturesByProperty(property){ - for (let ref of property.ancestors){ - if (ref.collection === 'creatures') { - computeCreature.call(ref.id); - } - } + for (let ref of property.ancestors){ + if (ref.collection === 'creatures') { + computeCreature.call(ref.id); + } + } } diff --git a/app/imports/api/creature/creatures/CreatureVariables.js b/app/imports/api/creature/creatures/CreatureVariables.js new file mode 100644 index 00000000..27fe0af7 --- /dev/null +++ b/app/imports/api/creature/creatures/CreatureVariables.js @@ -0,0 +1,21 @@ +//set up the collection for creature variables +let CreatureVariables = new Mongo.Collection('creatureVariables'); + +// Unique index on _creatureId +if (Meteor.isServer) { + CreatureVariables._ensureIndex({ _creatureId: 1 }, { unique: true }) +} + +/** No schema because the structure isn't known until compute time + * Expect documents to looke like: + * { + * _id: "nE8Ngd6K4L4jSxLY2", + * _creatureId: "nE8Ngd6K4L4jSxLY2", // indexed reference to the creature + * explicitlyDefinedVariableName: {...some creatureProperty} + * implicitVariableName: {value: 10}, + * undefinedVariableName: {}, + * } + * Where top level fields that don't start with `_` are variables on the sheet +**/ + +export default CreatureVariables; diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js index 1c118fee..f13932ee 100644 --- a/app/imports/api/creature/creatures/Creatures.js +++ b/app/imports/api/creature/creatures/Creatures.js @@ -8,21 +8,21 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let Creatures = new Mongo.Collection('creatures'); let CreatureSettingsSchema = new SimpleSchema({ - //slowed down by carrying too much? - useVariantEncumbrance: { - type: Boolean, - optional: true, - }, - //hide spellcasting tab - hideSpellcasting: { - type: Boolean, - optional: true, - }, - // Swap around the modifier and stat - swapStatAndModifier: { - type: Boolean, - optional: true, - }, + //slowed down by carrying too much? + useVariantEncumbrance: { + type: Boolean, + optional: true, + }, + //hide spellcasting tab + hideSpellcasting: { + type: Boolean, + optional: true, + }, + // Swap around the modifier and stat + swapStatAndModifier: { + type: Boolean, + optional: true, + }, // Hide all the unused stats hideUnusedStats: { type: Boolean, @@ -38,6 +38,11 @@ let CreatureSettingsSchema = new SimpleSchema({ type: Boolean, optional: true, }, + // Hide calculation errors + hideCalculationErrors: { + type: Boolean, + optional: true, + }, // How much each hitDice resets on a long rest hitDiceResetMultiplier: { type: Number, @@ -53,73 +58,99 @@ let CreatureSettingsSchema = new SimpleSchema({ }); let CreatureSchema = new SimpleSchema({ - // Strings - name: { - type: String, - defaultValue: '', - optional: true, + // Strings + name: { + type: String, + defaultValue: '', + optional: true, max: STORAGE_LIMITS.name, - }, - alignment: { - type: String, - optional: true, + }, + alignment: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - gender: { - type: String, - optional: true, + }, + gender: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - picture: { - type: String, - optional: true, + }, + picture: { + type: String, + optional: true, max: STORAGE_LIMITS.url, - }, + }, avatarPicture: { type: String, optional: true, max: STORAGE_LIMITS.url, }, - // Mechanics - deathSave: { - type: deathSaveSchema, - defaultValue: {}, - }, + + // Libraries + allowedLibraries: { + type: Array, + optional: true, + maxCount: 100, + }, + 'allowedLibraries.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + allowedLibraryCollections: { + type: Array, + optional: true, + maxCount: 100, + }, + 'allowedLibraryCollections.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + + // Mechanics + deathSave: { + type: deathSaveSchema, + defaultValue: {}, + }, // Stats that are computed and denormalised outside of recomputation denormalizedStats: { type: Object, defaultValue: {}, }, // Sum of all XP gained by this character - 'denormalizedStats.xp': { - type: SimpleSchema.Integer, - defaultValue: 0, - }, + 'denormalizedStats.xp': { + type: SimpleSchema.Integer, + defaultValue: 0, + }, // Sum of all levels granted by milestone XP 'denormalizedStats.milestoneLevels': { type: SimpleSchema.Integer, defaultValue: 0, }, + // Does the character need a recompute? + dirty: { + type: Boolean, + optional: true, + }, // Version of computation engine that was last used to compute this creature computeVersion: { - type: String, + type: String, optional: true, - }, - type: { - type: String, - defaultValue: 'pc', - allowedValues: ['pc', 'npc', 'monster'], - }, + }, + type: { + type: String, + defaultValue: 'pc', + allowedValues: ['pc', 'npc', 'monster'], + }, damageMultipliers: { type: Object, - blackbox: true, - defaultValue: {} + blackbox: true, + defaultValue: {} + }, + variables: { + type: Object, + blackbox: true, + defaultValue: {} }, - variables: { - type: Object, - blackbox: true, - defaultValue: {} - }, computeErrors: { type: Array, optional: true, @@ -130,7 +161,7 @@ let CreatureSchema = new SimpleSchema({ 'computeErrors.$.type': { type: String, }, - 'computeErrors.$.details' : { + 'computeErrors.$.details': { type: Object, blackbox: true, optional: true, @@ -147,11 +178,11 @@ let CreatureSchema = new SimpleSchema({ optional: true, }, - // Settings - settings: { - type: CreatureSettingsSchema, - defaultValue: {}, - }, + // Settings + settings: { + type: CreatureSettingsSchema, + defaultValue: {}, + }, }); CreatureSchema.extend(ColorSchema); @@ -160,8 +191,8 @@ CreatureSchema.extend(SharingSchema); Creatures.attachSchema(CreatureSchema); -import '/imports/api/creature/creatures/methods/index.js'; -import '/imports/api/engine/actions/doAction.js'; export default Creatures; export { CreatureSchema }; + +import '/imports/api/engine/actions/doAction.js'; diff --git a/app/imports/api/creature/creatures/defaultCharacterProperties.js b/app/imports/api/creature/creatures/defaultCharacterProperties.js index dd3d5499..e7f05a11 100644 --- a/app/imports/api/creature/creatures/defaultCharacterProperties.js +++ b/app/imports/api/creature/creatures/defaultCharacterProperties.js @@ -10,7 +10,7 @@ export default function defaultCharacterProperties(creatureId){ { type: 'propertySlot', name: 'Ruleset', - description: {text: 'Choose a starting point for your character, this will define the basic setup of your character sheet. Without a base, your sheet will be empty.'}, + description: {text: 'Choose a starting point for your character, this will define the basic setup of your character sheet. Without a base ruleset, your sheet will be empty.'}, slotTags: ['base'], tags: [], quantityExpected: {calculation: '1'}, diff --git a/app/imports/api/creature/creatures/methods/changeAllowedLibraries.js b/app/imports/api/creature/creatures/methods/changeAllowedLibraries.js new file mode 100644 index 00000000..05a8e9d6 --- /dev/null +++ b/app/imports/api/creature/creatures/methods/changeAllowedLibraries.js @@ -0,0 +1,90 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; +import SimpleSchema from 'simpl-schema'; +import simpleSchemaMixin from '/imports/api/creature/mixins/simpleSchemaMixin.js'; + +const changeAllowedLibraries = new ValidatedMethod({ + name: 'creatures.changeAllowedLibraries', + mixins: [RateLimiterMixin, simpleSchemaMixin], + schema: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + allowedLibraries: { + type: Array, + optional: true, + maxCount: 100, + }, + 'allowedLibraries.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + allowedLibraryCollections: { + type: Array, + optional: true, + maxCount: 100, + }, + 'allowedLibraryCollections.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }), + rateLimit: { + numRequests: 10, + timeInterval: 5000, + }, + run({ _id, allowedLibraries, allowedLibraryCollections }) { + let creature = Creatures.findOne(_id); + assertEditPermission(creature, this.userId); + let $set; + if (allowedLibraries) { + $set = { allowedLibraries } + } + if (allowedLibraryCollections) { + if (!$set) $set = {}; + $set.allowedLibraryCollections = allowedLibraryCollections; + } + if (!$set) return; + Creatures.update(_id, { $set }); + }, +}); + +const toggleAllUserLibraries = new ValidatedMethod({ + name: 'creatures.removeLibraryLimits', + mixins: [RateLimiterMixin, simpleSchemaMixin], + schema: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + value: { + type: Boolean, + }, + }), + rateLimit: { + numRequests: 10, + timeInterval: 5000, + }, + run({ _id, value }) { + if (value) { + Creatures.update(_id, { + $unset: { + allowedLibraryCollections: 1, + allowedLibraries: 1, + }, + }); + } else { + Creatures.update(_id, { + $set: { + allowedLibraryCollections: [], + allowedLibraries: [], + }, + }); + } + }, +}); + +export { changeAllowedLibraries, toggleAllUserLibraries }; diff --git a/app/imports/api/creature/creatures/methods/index.js b/app/imports/api/creature/creatures/methods/index.js index 42e9c99e..85aefc77 100644 --- a/app/imports/api/creature/creatures/methods/index.js +++ b/app/imports/api/creature/creatures/methods/index.js @@ -2,3 +2,4 @@ import '/imports/api/creature/creatures/methods/insertCreature.js'; import '/imports/api/creature/creatures/methods/removeCreature.js'; import '/imports/api/creature/creatures/methods/restCreature.js'; import '/imports/api/creature/creatures/methods/updateCreature.js'; +import '/imports/api/creature/creatures/methods/changeAllowedLibraries.js'; diff --git a/app/imports/api/creature/creatures/methods/insertCreature.js b/app/imports/api/creature/creatures/methods/insertCreature.js index 7dde2bbb..c978785b 100644 --- a/app/imports/api/creature/creatures/methods/insertCreature.js +++ b/app/imports/api/creature/creatures/methods/insertCreature.js @@ -1,57 +1,104 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import simpleSchemaMixin from '/imports/api/creature/mixins/simpleSchemaMixin.js'; +import Creatures, { CreatureSchema } from '/imports/api/creature/creatures/Creatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import defaultCharacterProperties from '/imports/api/creature/creatures/defaultCharacterProperties.js'; import insertPropertyFromLibraryNode from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js'; import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; +import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js'; +import getCreatureLibraryIds from '/imports/api/library/getCreatureLibraryIds.js'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { insertExperienceForCreature } from '/imports/api/creature/experience/Experiences.js'; +import SimpleSchema from 'simpl-schema'; const insertCreature = new ValidatedMethod({ - name: 'creatures.insertCreature', - - validate: null, - - mixins: [RateLimiterMixin], + mixins: [RateLimiterMixin, simpleSchemaMixin], + schema: CreatureSchema.pick( + 'name', + 'gender', + 'alignment', + 'allowedLibraries', + 'allowedLibraryCollections', + ).extend({ + 'startingLevel': { + type: SimpleSchema.Integer, + min: 0, + }, + }), rateLimit: { numRequests: 5, timeInterval: 5000, }, - run() { - if (!this.userId) { + run({ name, gender, alignment, startingLevel, + allowedLibraries, allowedLibraryCollections }) { + const userId = this.userId + if (!userId) { throw new Meteor.Error('Creatures.methods.insert.denied', - 'You need to be logged in to insert a creature'); + 'You need to be logged in to insert a creature'); } - assertHasCharactersSlots(this.userId); + assertHasCharactersSlots(userId); - // Create the creature document + // Create the creature document let creatureId = Creatures.insert({ - owner: this.userId, - }); - - // Insert the default properties - // Not batchInsert because we want the properties cleaned by the schema - let baseId; - defaultCharacterProperties(creatureId).forEach(prop => { - let id = CreatureProperties.insert(prop); - if (prop.name === 'Ruleset'){ - baseId = id; - } + owner: userId, + name, + gender, + alignment, + allowedLibraries, + allowedLibraryCollections, }); - if (Meteor.isServer){ - // Insert the 5e ruleset as the default base - insertPropertyFromLibraryNode.call({ - nodeIds: ['iHbhfcg3AL5isSWbw'], - parentRef: {id: baseId, collection: 'creatureProperties'}, - order: 0.5, + // Insert experience to get character to starting level + if (startingLevel) { + insertExperienceForCreature({ + experience: { + name: 'Starting level', + levels: startingLevel, + creatureId + }, + creatureId, + userId, }); } - return creatureId; + // Insert the default properties + // Not batchInsert because we want the properties cleaned by the schema + let baseId, rulesetSlot; + defaultCharacterProperties(creatureId).forEach(prop => { + let id = CreatureProperties.insert(prop); + if (prop.name === 'Ruleset') { + baseId = id; + rulesetSlot = prop; + } + }); + + // If the user only has a single ruleset subscribed, use it by default + if (Meteor.isServer) { + insertDefaultRuleset(creatureId, baseId, userId, rulesetSlot); + } + + return creatureId; }, }); +// If the user only has a single ruleset subscribed, insert it by default +function insertDefaultRuleset(creatureId, baseId, userId, slot) { + const libraryIds = getCreatureLibraryIds(creatureId, userId); + const filter = getSlotFillFilter({ slot, libraryIds }); + const fillCursor = LibraryNodes.find(filter, { fields: { _id: 1 } }); + const numRulesets = fillCursor.count(); + if (numRulesets === 1) { + const ruleset = fillCursor.fetch()[0] + insertPropertyFromLibraryNode.call({ + nodeIds: [ruleset._id], + parentRef: { id: baseId, collection: 'creatureProperties' }, + order: 0.5, + }); + } +} + export default insertCreature; diff --git a/app/imports/api/creature/creatures/methods/removeCreature.js b/app/imports/api/creature/creatures/methods/removeCreature.js index 66eaa0b4..d157f5d7 100644 --- a/app/imports/api/creature/creatures/methods/removeCreature.js +++ b/app/imports/api/creature/creatures/methods/removeCreature.js @@ -3,11 +3,13 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertOwnership } from '/imports/api/creature/creatures/creaturePermissions.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import Experiences from '/imports/api/creature/experience/Experiences.js'; function removeRelatedDocuments(creatureId){ + CreatureVariables.remove({_creatureId: creatureId}); CreatureProperties.remove({'ancestors.id': creatureId}); CreatureLogs.remove({creatureId}); Experiences.remove({creatureId}); diff --git a/app/imports/api/creature/creatures/methods/restCreature.js b/app/imports/api/creature/creatures/methods/restCreature.js index 07a607ee..49d228aa 100644 --- a/app/imports/api/creature/creatures/methods/restCreature.js +++ b/app/imports/api/creature/creatures/methods/restCreature.js @@ -1,13 +1,14 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; +import { union } from 'lodash'; +import ActionContext from '/imports/api/engine/actions/ActionContext.js'; +import { applyTriggers } from '/imports/api/engine/actions/applyTriggers.js'; const restCreature = new ValidatedMethod({ - name: 'creature.methods.longRest', + name: 'creature.methods.rest', validate: new SimpleSchema({ creatureId: { type: String, @@ -23,94 +24,121 @@ const restCreature = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({creatureId, restType}) { - let creature = Creatures.findOne(creatureId, { - fields: { - owner: 1, - writers: 1, - settings: 1, - } - }) ; - // Need edit permissions - assertEditPermission(creature, this.userId); + run({ creatureId, restType }) { + // Get action context + const actionContext = new ActionContext(creatureId, [creatureId], this); + // Check permissions + assertEditPermission(actionContext.creature, this.userId); - // Long rests reset short rest properties as well - let resetFilter; - if (restType === 'shortRest'){ - resetFilter = 'shortRest' - } else { - resetFilter = {$in: ['shortRest', 'longRest']} - } - // Only apply to active properties - let filter = { - 'ancestors.id': creatureId, - reset: resetFilter, - removed: {$ne: true}, - inactive: {$ne: true}, - }; - // update all attribute's damage - filter.type = 'attribute'; - CreatureProperties.update(filter, { - $set: {damage: 0} - }, { - selector: {type: 'attribute'}, - multi: true, + // Join, sort, and apply before triggers + const beforeTriggers = union( + actionContext.triggers.anyRest?.before, actionContext.triggers[restType]?.before + ).sort((a, b) => a.order - b.order); + applyTriggers(beforeTriggers, null, actionContext); + + // Rest + actionContext.addLog({ + name: restType === 'shortRest' ? 'Short rest' : 'Long rest', }); - // Update all action-like properties' usesUsed - filter.type = {$in: [ - 'action', - 'attack', - 'spell' - ]}; - CreatureProperties.update(filter, { - $set: {usesUsed: 0} - }, { - selector: {type: 'action'}, - multi: true, - }); - // Reset half hit dice on a long rest, starting with the highest dice - if (restType === 'longRest'){ - let hitDice = CreatureProperties.find({ - 'ancestors.id': creatureId, - type: 'attribute', - attributeType: 'hitDice', - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - fields: { - hitDiceSize: 1, - damage: 1, - value: 1, - } - }).fetch(); - // Use a collator to do sorting in natural order - let collator = new Intl.Collator('en', { - numeric: true, sensitivity: 'base' - }); - // Get the hit dice in decending order of hitDiceSize - let compare = (a, b) => collator.compare(b.hitDiceSize, a.hitDiceSize) - hitDice.sort(compare); - // Get the total number of hit dice that can be recovered this rest - let totalHd = hitDice.reduce((sum, hd) => sum + (hd.value || 0), 0); - let resetMultiplier = creature.settings.hitDiceResetMultiplier || 0.5; - let recoverableHd = Math.max(Math.floor(totalHd*resetMultiplier), 1); - // recover each hit dice in turn until the recoverable amount is used up - let amountToRecover, resultingDamage; - hitDice.forEach(hd => { - if (!recoverableHd) return; - amountToRecover = Math.min(recoverableHd, hd.damage || 0); - if (!amountToRecover) return; - recoverableHd -= amountToRecover; - resultingDamage = hd.damage - amountToRecover; - CreatureProperties.update(hd._id, { - $set: {damage: resultingDamage} - }, { - selector: {type: 'attribute'}, - }); - }); - } - computeCreature(creatureId); + doRestWork(restType, actionContext); + + // Join, sort, and apply after triggers + const afterTriggers = union( + actionContext.triggers.anyRest?.after, actionContext.triggers[restType]?.after + ).sort((a, b) => a.order - b.order); + applyTriggers(afterTriggers, null, actionContext); + + // Insert log + actionContext.writeLog(); }, }); +function doRestWork(restType, actionContext) { + const creatureId = actionContext.creature._id; + // Long rests reset short rest properties as well + let resetFilter; + if (restType === 'shortRest'){ + resetFilter = 'shortRest' + } else { + resetFilter = {$in: ['shortRest', 'longRest']} + } + // Only apply to active properties + let filter = { + 'ancestors.id': creatureId, + reset: resetFilter, + removed: { $ne: true }, + inactive: { $ne: true }, + }; + // update all attribute's damage + filter.type = 'attribute'; + CreatureProperties.update(filter, { + $set: { + damage: 0, + dirty: true, + } + }, { + selector: {type: 'attribute'}, + multi: true, + }); + // Update all action-like properties' usesUsed + filter.type = {$in: [ + 'action', + 'attack', + 'spell' + ]}; + CreatureProperties.update(filter, { + $set: { + usesUsed: 0, + dirty: true, + } + }, { + selector: {type: 'action'}, + multi: true, + }); + // Reset half hit dice on a long rest, starting with the highest dice + if (restType === 'longRest'){ + let hitDice = CreatureProperties.find({ + 'ancestors.id': creatureId, + type: 'attribute', + attributeType: 'hitDice', + removed: {$ne: true}, + inactive: {$ne: true}, + }, { + fields: { + hitDiceSize: 1, + damage: 1, + total: 1, + } + }).fetch(); + // Use a collator to do sorting in natural order + let collator = new Intl.Collator('en', { + numeric: true, sensitivity: 'base' + }); + // Get the hit dice in decending order of hitDiceSize + let compare = (a, b) => collator.compare(b.hitDiceSize, a.hitDiceSize) + hitDice.sort(compare); + // Get the total number of hit dice that can be recovered this rest + let totalHd = hitDice.reduce((sum, hd) => sum + (hd.total || 0), 0); + let resetMultiplier = actionContext.creature.settings.hitDiceResetMultiplier || 0.5; + let recoverableHd = Math.max(Math.floor(totalHd*resetMultiplier), 1); + // recover each hit dice in turn until the recoverable amount is used up + let amountToRecover, resultingDamage; + hitDice.forEach(hd => { + if (!recoverableHd) return; + amountToRecover = Math.min(recoverableHd, hd.damage || 0); + if (!amountToRecover) return; + recoverableHd -= amountToRecover; + resultingDamage = hd.damage - amountToRecover; + CreatureProperties.update(hd._id, { + $set: { + damage: resultingDamage, + dirty: true, + } + }, { + selector: {type: 'attribute'}, + }); + }); + } +} + export default restCreature; diff --git a/app/imports/api/creature/creatures/methods/updateCreature.js b/app/imports/api/creature/creatures/methods/updateCreature.js index 6530ccae..4da290bc 100644 --- a/app/imports/api/creature/creatures/methods/updateCreature.js +++ b/app/imports/api/creature/creatures/methods/updateCreature.js @@ -1,14 +1,14 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import {assertEditPermission} from '/imports/api/sharing/sharingPermissions.js'; +import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; const updateCreature = new ValidatedMethod({ name: 'creatures.update', - validate({_id, path}){ - if (!_id) return false; - // Allowed fields - let allowedFields = [ + validate({ _id, path }) { + if (!_id) return false; + // Allowed fields + let allowedFields = [ 'name', 'alignment', 'gender', @@ -17,26 +17,26 @@ const updateCreature = new ValidatedMethod({ 'color', 'settings', ]; - if (!allowedFields.includes(path[0])){ - throw new Meteor.Error('Creatures.methods.update.denied', - 'This field can\'t be updated using this method'); - } + if (!allowedFields.includes(path[0])) { + throw new Meteor.Error('Creatures.methods.update.denied', + 'This field can\'t be updated using this method'); + } }, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, value}) { - let creature = Creatures.findOne(_id); + run({ _id, path, value }) { + let creature = Creatures.findOne(_id); assertEditPermission(creature, this.userId); - if (value === undefined || value === null){ + if (value === undefined || value === null) { Creatures.update(_id, { - $unset: {[path.join('.')]: 1}, + $unset: { [path.join('.')]: 1 }, }); } else { Creatures.update(_id, { - $set: {[path.join('.')]: value}, + $set: { [path.join('.')]: value }, }); } }, diff --git a/app/imports/api/creature/experience/Experiences.js b/app/imports/api/creature/experience/Experiences.js index 7c0bb002..d1c5c527 100644 --- a/app/imports/api/creature/experience/Experiences.js +++ b/app/imports/api/creature/experience/Experiences.js @@ -3,23 +3,22 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let Experiences = new Mongo.Collection('experiences'); let ExperienceSchema = new SimpleSchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - // The amount of XP this experience gives - xp: { - type: SimpleSchema.Integer, - optional: true, + }, + // The amount of XP this experience gives + xp: { + type: SimpleSchema.Integer, + optional: true, min: 0, - }, + }, // Setting levels instead of value grants whole levels levels: { type: SimpleSchema.Integer, @@ -27,17 +26,17 @@ let ExperienceSchema = new SimpleSchema({ min: 0, index: 1, }, - // The real-world date that it occured, usually sorted by date - date: { - type: Date, - autoValue: function() { - // If the date isn't set, set it to now - if (!this.isSet) { - return new Date(); - } - }, + // The real-world date that it occured, usually sorted by date + date: { + type: Date, + autoValue: function () { + // If the date isn't set, set it to now + if (!this.isSet) { + return new Date(); + } + }, index: 1, - }, + }, creatureId: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -47,21 +46,21 @@ let ExperienceSchema = new SimpleSchema({ Experiences.attachSchema(ExperienceSchema); -const insertExperienceForCreature = function({experience, creatureId, userId}){ - assertEditPermission(creatureId, userId); - if (experience.xp){ - Creatures.update(creatureId, {$inc: { - 'denormalizedStats.xp': experience.xp - }}); +const insertExperienceForCreature = function ({ experience, creatureId }) { + if (experience.xp) { + Creatures.update(creatureId, { + $inc: { 'denormalizedStats.xp': experience.xp }, + $set: { dirty: true }, + }); } if (experience.levels) { - Creatures.update(creatureId, {$inc: { - 'denormalizedStats.milestoneLevels': experience.levels - }}); + Creatures.update(creatureId, { + $inc: { 'denormalizedStats.milestoneLevels': experience.levels }, + $set: { dirty: true }, + }); } experience.creatureId = creatureId; let id = Experiences.insert(experience); - computeCreature(creatureId); return id; }; @@ -85,15 +84,16 @@ const insertExperience = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({experience, creatureIds}) { + run({ experience, creatureIds }) { let userId = this.userId; if (!userId) { throw new Meteor.Error('Experiences.methods.insert.denied', - 'You need to be logged in to insert an experience'); + 'You need to be logged in to insert an experience'); } let insertedIds = []; creatureIds.forEach(creatureId => { - let id = insertExperienceForCreature({experience, creatureId, userId}); + assertEditPermission(creatureId, userId); + let id = insertExperienceForCreature({ experience, creatureId, userId }); insertedIds.push(id); }); return insertedIds; @@ -113,29 +113,30 @@ const removeExperience = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({experienceId}) { + run({ experienceId }) { let userId = this.userId; if (!userId) { throw new Meteor.Error('Experiences.methods.remove.denied', - 'You need to be logged in to remove an experience'); + 'You need to be logged in to remove an experience'); } let experience = Experiences.findOne(experienceId); if (!experience) return; let creatureId = experience.creatureId assertEditPermission(creatureId, userId); - if (experience.xp){ - Creatures.update(creatureId, {$inc: { - 'denormalizedStats.xp': -experience.xp - }}); + if (experience.xp) { + Creatures.update(creatureId, { + $inc: { 'denormalizedStats.xp': -experience.xp }, + $set: { dirty: true }, + }); } if (experience.levels) { - Creatures.update(creatureId, {$inc: { - 'denormalizedStats.milestoneLevels': -experience.levels - }}); + Creatures.update(creatureId, { + $inc: { 'denormalizedStats.milestoneLevels': -experience.levels }, + $set: { dirty: true }, + }); } experience.creatureId = creatureId; let numRemoved = Experiences.remove(experienceId); - computeCreature(creatureId); return numRemoved; }, }); @@ -153,11 +154,11 @@ const recomputeExperiences = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({creatureId}) { + run({ creatureId }) { let userId = this.userId; if (!userId) { throw new Meteor.Error('Experiences.methods.recompute.denied', - 'You need to be logged in to recompute a creature\'s experiences'); + 'You need to be logged in to recompute a creature\'s experiences'); } assertEditPermission(creatureId, userId); @@ -166,18 +167,20 @@ const recomputeExperiences = new ValidatedMethod({ Experiences.find({ creatureId }, { - fields: {xp: 1, levels: 1} + fields: { xp: 1, levels: 1 } }).forEach(experience => { xp += experience.xp || 0; milestoneLevels += experience.levels || 0; }); - Creatures.update(creatureId, {$set: { - 'denormalizedStats.xp': xp, - 'denormalizedStats.milestoneLevels': milestoneLevels - }}); - computeCreature(creatureId); + Creatures.update(creatureId, { + $set: { + 'denormalizedStats.xp': xp, + 'denormalizedStats.milestoneLevels': milestoneLevels, + dirty: true, + } + }); }, }); export default Experiences; -export { ExperienceSchema, insertExperience, removeExperience, recomputeExperiences }; +export { ExperienceSchema, insertExperience, insertExperienceForCreature, removeExperience, recomputeExperiences }; diff --git a/app/imports/api/creature/journal/JournalEntry.js b/app/imports/api/creature/journal/JournalEntry.js index 43d5ecc2..74b885ea 100644 --- a/app/imports/api/creature/journal/JournalEntry.js +++ b/app/imports/api/creature/journal/JournalEntry.js @@ -2,33 +2,33 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let ExperienceSchema = new SimpleSchema({ - title: { - type: String, - optional: true, + title: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - // Potentially long description of the event - description: { - type: String, - optional: true, + }, + // Potentially long description of the event + description: { + type: String, + optional: true, max: STORAGE_LIMITS.description, - }, - // The real-world date that it occured - date: { - type: Date, - autoValue: function() { - // If the date isn't set, set it to now - if (!this.isSet) { - return new Date(); - } - }, - }, - // The date in-world of this event - worldDate: { - type: String, - optional: true, + }, + // The real-world date that it occured + date: { + type: Date, + autoValue: function () { + // If the date isn't set, set it to now + if (!this.isSet) { + return new Date(); + } + }, + }, + // The date in-world of this event + worldDate: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, // Tags to better find this entry later tags: { type: Array, diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js index bd1495c8..522d1f2e 100644 --- a/app/imports/api/creature/log/CreatureLogs.js +++ b/app/imports/api/creature/log/CreatureLogs.js @@ -1,17 +1,18 @@ import SimpleSchema from 'simpl-schema'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables.js'; import LogContentSchema from '/imports/api/creature/log/LogContentSchema.js'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import {assertEditPermission} from '/imports/api/creature/creatures/creaturePermissions.js'; -import {assertUserInTabletop} from '/imports/api/tabletop/methods/shared/tabletopPermissions.js'; +import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; +import { assertUserInTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions.js'; -import {parse, prettifyParseError} from '/imports/parser/parser.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'; -if (Meteor.isServer){ +if (Meteor.isServer) { var sendWebhookAsCreature = require('/imports/server/discord/sendWebhook.js').sendWebhookAsCreature; } @@ -26,17 +27,17 @@ let CreatureLogSchema = new SimpleSchema({ 'content.$': { type: LogContentSchema, }, - // The real-world date that it occured, usually sorted by date - date: { - type: Date, - autoValue: function() { - // If the date isn't set, set it to now - if (!this.isSet) { - return new Date(); - } - }, + // The real-world date that it occured, usually sorted by date + date: { + type: Date, + autoValue: function () { + // If the date isn't set, set it to now + if (!this.isSet) { + return new Date(); + } + }, index: 1, - }, + }, creatureId: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -57,23 +58,23 @@ let CreatureLogSchema = new SimpleSchema({ CreatureLogs.attachSchema(CreatureLogSchema); -function removeOldLogs(creatureId){ +function removeOldLogs(creatureId) { // Find the first log that is over the limit let firstExpiredLog = CreatureLogs.find({ creatureId }, { - sort: {date: -1}, + sort: { date: -1 }, skip: PER_CREATURE_LOG_LIMIT, }); if (!firstExpiredLog) return; // Remove all logs older than the one over the limit CreatureLogs.remove({ creatureId, - date: {$lte: firstExpiredLog.date}, + date: { $lte: firstExpiredLog.date }, }); } -function logToMessageData(log){ +function logToMessageData(log) { let embed = { fields: [], }; @@ -85,8 +86,8 @@ function logToMessageData(log){ return { embeds: [embed] }; } -function logWebhook({log, creature}){ - if (Meteor.isServer){ +function logWebhook({ log, creature }) { + if (Meteor.isServer) { sendWebhookAsCreature({ creature, data: logToMessageData(log), @@ -101,23 +102,25 @@ const insertCreatureLog = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - validate: new SimpleSchema({ - log: CreatureLogSchema.omit('date'), - }).validator(), - run({log}){ + validate: new SimpleSchema({ + log: CreatureLogSchema.omit('date'), + }).validator(), + run({ log }) { const creatureId = log.creatureId; - const creature = Creatures.findOne(creatureId, {fields: { - readers: 1, - writers: 1, - owner: 1, - 'settings.discordWebhook': 1, - name: 1, - avatarPicture: 1, - tabletop: 1, - }}); + const creature = Creatures.findOne(creatureId, { + fields: { + readers: 1, + writers: 1, + owner: 1, + 'settings.discordWebhook': 1, + name: 1, + avatarPicture: 1, + tabletop: 1, + } + }); assertEditPermission(creature, this.userId); // Build the new log - let id = insertCreatureLogWork({log, creature, method: this}) + let id = insertCreatureLogWork({ log, creature, method: this }) return id; }, }); @@ -129,35 +132,35 @@ const insertTabletopLog = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - validate: new SimpleSchema({ - log: CreatureLogSchema.omit('date'), - }).validator(), - run({log}){ + validate: new SimpleSchema({ + log: CreatureLogSchema.omit('date'), + }).validator(), + run({ log }) { const tabletopId = log.tabletopId; assertUserInTabletop(tabletopId, this.userId); // Build the new log - let id = insertCreatureLogWork({log, method: this}) + let id = insertCreatureLogWork({ log, method: this }) return id; }, }); -export function insertCreatureLogWork({log, creature, method}){ +export function insertCreatureLogWork({ log, creature, method }) { // Build the new log - if (typeof log === 'string'){ - log = {content: [{value: log}]}; + if (typeof log === 'string') { + log = { content: [{ value: log }] }; } if (!log.content?.length) return; log.date = new Date(); if (creature) log.tabletopId = creature.tabletop; // Insert it let id = CreatureLogs.insert(log); - if (Meteor.isServer){ + if (Meteor.isServer) { method?.unblock(); - if (creature){ + if (creature) { removeOldLogs(creature._id); - logWebhook({log, creature}); + logWebhook({ log, creature }); } - if (log.tabletopId){ + if (log.tabletopId) { // Todo remove old tabletop logs // Log webhook if it's different to creature webhook } @@ -166,9 +169,9 @@ export function insertCreatureLogWork({log, creature, method}){ } -function equalIgnoringWhitespace(a, b){ +function equalIgnoringWhitespace(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return a === b; - return a.replace(/\s/g,'') === b.replace(/\s/g, ''); + return a.replace(/\s/g, '') === b.replace(/\s/g, ''); } const logRoll = new ValidatedMethod({ @@ -178,39 +181,41 @@ const logRoll = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - validate: new SimpleSchema({ - roll: { - type: String, - }, - creatureId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - }).validator(), - run({roll, creatureId}){ - const creature = Creatures.findOne(creatureId, {fields: { - variables: 1, - readers: 1, - writers: 1, - owner: 1, - 'settings.discordWebhook': 1, - name: 1, - avatarPicture: 1, - }}); + validate: new SimpleSchema({ + roll: { + type: String, + }, + creatureId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + run({ roll, creatureId }) { + const creature = Creatures.findOne(creatureId, { + fields: { + readers: 1, + writers: 1, + owner: 1, + 'settings.discordWebhook': 1, + name: 1, + avatarPicture: 1, + } + }); assertEditPermission(creature, this.userId); + const variables = CreatureVariables.findOne({ _creatureId: creatureId }); let logContent = [] let parsedResult = undefined; try { parsedResult = parse(roll); - } catch (e){ + } catch (e) { let error = prettifyParseError(e); - logContent.push({name: 'Parse Error', value: error}); + logContent.push({ name: 'Parse Error', value: error }); } if (parsedResult) try { let { result: compiled, context - } = resolve('compile', parsedResult, creature.variables); + } = resolve('compile', parsedResult, variables); const compiledString = toString(compiled); if (!equalIgnoringWhitespace(compiledString, roll)) logContent.push({ value: roll @@ -218,19 +223,19 @@ const logRoll = new ValidatedMethod({ logContent.push({ value: compiledString }); - let {result: rolled} = resolve('roll', compiled, creature.variables, context); + let { result: rolled } = resolve('roll', compiled, variables, context); let rolledString = toString(rolled); if (rolledString !== compiledString) logContent.push({ value: rolledString }); - let {result} = resolve('reduce', rolled, creature.variables, context); + let { result } = resolve('reduce', rolled, variables, context); let resultString = toString(result); if (resultString !== rolledString) logContent.push({ value: resultString }); - } catch (e){ + } catch (e) { console.error(e); - logContent = [{name: 'Calculation error'}]; + logContent = [{ name: 'Calculation error' }]; } const log = { content: logContent, @@ -238,11 +243,11 @@ const logRoll = new ValidatedMethod({ date: new Date(), }; - let id = insertCreatureLogWork({log, creature, method: this}); + let id = insertCreatureLogWork({ log, creature, method: this }); return id; }, }); export default CreatureLogs; -export { CreatureLogSchema, insertCreatureLog, logRoll, insertTabletopLog}; +export { CreatureLogSchema, insertCreatureLog, logRoll, insertTabletopLog, PER_CREATURE_LOG_LIMIT }; diff --git a/app/imports/api/docs/Docs.js b/app/imports/api/docs/Docs.js new file mode 100644 index 00000000..91961b16 --- /dev/null +++ b/app/imports/api/docs/Docs.js @@ -0,0 +1,3 @@ +if (Meteor.isServer) throw 'Client side only collection, don\'t import on server'; +const Docs = new Mongo.Collection('docs'); +export default Docs; diff --git a/app/imports/api/engine/actions/ActionContext.js b/app/imports/api/engine/actions/ActionContext.js new file mode 100644 index 00000000..9c5e2e50 --- /dev/null +++ b/app/imports/api/engine/actions/ActionContext.js @@ -0,0 +1,78 @@ +import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs.js'; +import { + getCreature, getVariables, getPropertiesOfType +} from '/imports/api/engine/loadCreatures.js'; +import { groupBy, remove } from 'lodash'; + +export default class ActionContext{ + constructor(creatureId, targetIds = [], method) { + // Get the creature + this.creature = getCreature(creatureId) + + if (!this.creature) { + throw new Meteor.Error('No Creature', `No creature could be found with id: ${creatureId}`) + } + // Create a log + this.log = CreatureLogSchema.clean({ + creatureId: creatureId, + creatureName: this.creature.name, + }); + + // Get the variables of the acting creature + this.creature.variables = getVariables(creatureId); + delete this.creature.variables._id; + delete this.creature.variables._creatureId; + // Alias as scope + this.scope = this.creature.variables; + + // Get the targets and their variables + this.targets = []; + targetIds.forEach(targetId => { + let target; + if (targetId === creatureId) { + target = this.creature; + } else { + target = getCreature(targetId); + target.variables = getVariables(targetId); + delete target.variables._id; + delete target.variables._creatureId; + } + this.targets.push(target); + }); + + // Store a reference to the method for inserting the log + this.method = method; + + // Get triggers + this.triggers = getPropertiesOfType(creatureId, 'trigger'); + // Remove deleted or inactive triggers + remove(this.triggers, trigger => trigger.removed || trigger.inactive); + // Sort triggers by order + this.triggers.sort((a, b) => a.order - b.order); + // Group the triggers into triggers.. or + // triggers.doActionProperty.. + this.triggers = groupBy(this.triggers, 'event'); + for (let event in this.triggers) { + if (event === 'doActionProperty') { + this.triggers[event] = groupBy(this.triggers[event], 'actionPropertyType'); + for (let propertyType in this.triggers[event]) { + this.triggers[event][propertyType] = groupBy(this.triggers[event][propertyType], 'timing'); + } + } else { + this.triggers[event] = groupBy(this.triggers[event], 'timing'); + } + } + } + addLog(content) { + if (content.name || content.value){ + this.log.content.push(content); + } + } + writeLog() { + insertCreatureLogWork({ + log: this.log, + creature: this.creature, + method: this.method, + }); + } +} \ No newline at end of file diff --git a/app/imports/api/engine/actions/applyProperty.js b/app/imports/api/engine/actions/applyProperty.js index 32542390..4dc1cb79 100644 --- a/app/imports/api/engine/actions/applyProperty.js +++ b/app/imports/api/engine/actions/applyProperty.js @@ -2,7 +2,9 @@ import action from './applyPropertyByType/applyAction.js'; import adjustment from './applyPropertyByType/applyAdjustment.js'; import branch from './applyPropertyByType/applyBranch.js'; import buff from './applyPropertyByType/applyBuff.js'; +import buffRemover from './applyPropertyByType/applyBuffRemover.js'; import damage from './applyPropertyByType/applyDamage.js'; +import folder from './applyPropertyByType/applyFolder.js'; import note from './applyPropertyByType/applyNote.js'; import roll from './applyPropertyByType/applyRoll.js'; import savingThrow from './applyPropertyByType/applySavingThrow.js'; @@ -13,7 +15,9 @@ const applyPropertyByType = { adjustment, branch, buff, + buffRemover, damage, + folder, note, roll, savingThrow, @@ -21,7 +25,7 @@ const applyPropertyByType = { toggle, }; -export default function applyProperty(node, opts, ...rest){ - opts.scope[`#${node.node.type}`] = node.node; - return applyPropertyByType[node.node.type]?.(node, opts, ...rest); +export default function applyProperty(node, actionContext, ...rest) { + actionContext.scope[`#${node.node.type}`] = node.node; + applyPropertyByType[node.node.type]?.(node, actionContext, ...rest); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index 0366bf49..8d6bd185 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js @@ -6,23 +6,24 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js'; import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyAction(node, {creature, targets, scope, log}){ +export default function applyAction(node, actionContext) { + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - if (prop.target === 'self') targets = [creature]; + if (prop.target === 'self') actionContext.targets = [actionContext.creature]; + const targets = actionContext.targets; // Log the name and summary let content = { name: prop.name }; if (prop.summary?.text){ - recalculateInlineCalculations(prop.summary, scope, log); + recalculateInlineCalculations(prop.summary, actionContext); content.value = prop.summary.value; } - if (content.name || content.value){ - log.content.push(content); - } + if (!prop.silent) actionContext.addLog(content); // Spend the resources - const failed = spendResources({prop, log, scope}); + const failed = spendResources(prop, actionContext); if (failed) return; const attack = prop.attackRoll || prop.attackRollBonus; @@ -31,28 +32,29 @@ export default function applyAction(node, {creature, targets, scope, log}){ if (attack && attack.calculation){ if (targets.length){ targets.forEach(target => { - applyAttackToTarget({attack, target, scope, log}); + applyAttackToTarget({attack, target, actionContext}); // Apply the children, but only to the current target - applyChildren(node, {creature, targets: [target], scope, log}); + actionContext.targets = [target]; + applyChildren(node, actionContext); }); } else { - applyAttackWithoutTarget({attack, scope, log}); - applyChildren(node, {creature, targets, scope, log}); + applyAttackWithoutTarget({attack, actionContext}); + applyChildren(node, actionContext); } } else { - applyChildren(node, {creature, targets, scope, log}); + applyChildren(node, actionContext); } } -function applyAttackWithoutTarget({attack, scope, log}){ - delete scope['$attackHit']; - delete scope['$attackMiss']; - delete scope['$criticalHit']; - delete scope['$criticalMiss']; - delete scope['$attackRoll']; - - recalculateCalculation(attack, scope, log); +function applyAttackWithoutTarget({attack, actionContext}){ + 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; let { resultPrefix, result, @@ -72,14 +74,15 @@ function applyAttackWithoutTarget({attack, scope, log}){ scope['$attackMiss'] = {value: true}; } - log.content.push({ + actionContext.addLog({ name, value: `${resultPrefix}\n**${result}**`, inline: true, }); } -function applyAttackToTarget({attack, target, scope, log}){ +function applyAttackToTarget({attack, target, actionContext}){ + const scope = actionContext.scope; delete scope['$attackHit']; delete scope['$attackMiss']; delete scope['$criticalHit']; @@ -87,7 +90,7 @@ function applyAttackToTarget({attack, target, scope, log}){ delete scope['$attackDiceRoll']; delete scope['$attackRoll']; - recalculateCalculation(attack, scope, log); + recalculateCalculation(attack, actionContext); let { resultPrefix, @@ -108,7 +111,7 @@ function applyAttackToTarget({attack, target, scope, log}){ name += ' (Disadvantage)'; } - log.content.push({ + actionContext.addLog({ name, value: `${resultPrefix}\n**${result}**`, inline: true, @@ -119,11 +122,11 @@ function applyAttackToTarget({attack, target, scope, log}){ scope['$attackHit'] = {value: true}; } } else { - log.content.push({ + actionContext.addLog({ name: 'Error', value:'Target has no `armor`', }); - log.content.push({ + actionContext.addLog({ name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit', value: `${resultPrefix}\n**${result}**`, inline: true, @@ -177,14 +180,15 @@ function applyCrits(value, scope){ return {criticalHit, criticalMiss}; } -function applyChildren(node, args){ - node.children.forEach(child => applyProperty(child, args)); +function applyChildren(node, actionContext) { + applyNodeTriggers(node, 'after', actionContext); + node.children.forEach(child => applyProperty(child, actionContext)); } -function spendResources({prop, log, scope}){ +function spendResources(prop, actionContext){ // Check Uses if (prop.usesLeft <= 0){ - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Error', value: `${prop.name || 'action'} does not have enough uses left`, }); @@ -192,7 +196,7 @@ function spendResources({prop, log, scope}){ } // Resources if (prop.insufficientResources){ - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Error', value: 'This creature doesn\'t have sufficient resources to perform this action', }); @@ -204,7 +208,7 @@ function spendResources({prop, log, scope}){ let gainLog = []; try { prop.resources.itemsConsumed.forEach(itemConsumed => { - recalculateCalculation(itemConsumed.quantity, scope, log); + recalculateCalculation(itemConsumed.quantity, actionContext); if (!itemConsumed.itemId){ throw 'No ammo was selected for this prop'; } @@ -235,7 +239,7 @@ function spendResources({prop, log, scope}){ } }); } catch (e){ - log.content.push({ + actionContext.addLog({ name: 'Error', value: e, }); @@ -253,7 +257,7 @@ function spendResources({prop, log, scope}){ }, { selector: prop }); - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Uses left', value: prop.usesLeft - 1, inline: true, @@ -262,18 +266,19 @@ function spendResources({prop, log, scope}){ // Damage stats prop.resources.attributesConsumed.forEach(attConsumed => { - recalculateCalculation(attConsumed.quantity, scope, log); + recalculateCalculation(attConsumed.quantity, actionContext); if (!attConsumed.quantity?.value) return; - let stat = scope[attConsumed.variableName]; + let stat = actionContext.scope[attConsumed.variableName]; if (!stat){ spendLog.push(stat.name + ': ' + ' not found'); return; } damagePropertyWork({ - property: stat, + prop: stat, operation: 'increment', value: attConsumed.quantity.value, + actionContext, }); if (attConsumed.quantity.value > 0){ spendLog.push(stat.name + ': ' + attConsumed.quantity.value); @@ -283,12 +288,12 @@ function spendResources({prop, log, scope}){ }); // Log all the spending - if (gainLog.length) log.content.push({ + if (gainLog.length && !prop.silent) actionContext.addLog({ name: 'Gained', value: gainLog.join('\n'), inline: true, }); - if (spendLog.length) log.content.push({ + if (spendLog.length && !prop.silent) actionContext.addLog({ name: 'Spent', value: spendLog.join('\n'), inline: true, diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js b/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js index b920283b..ede94665 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js @@ -1,41 +1,42 @@ import applyProperty from '../applyProperty.js'; import recalculateCalculation from './shared/recalculateCalculation.js'; import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyAdjustment(node, { - creature, targets, scope, log -}){ +export default function applyAdjustment(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - const damageTargets = prop.target === 'self' ? [creature] : targets; + const damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; if (!prop.amount) { - return applyChildren(node, {creature, targets, scope, log}); + return applyChildren(node, actionContext); } // Evaluate the amount - recalculateCalculation(prop.amount, scope, log); + recalculateCalculation(prop.amount, actionContext); const value = +prop.amount.value; if (!isFinite(value)) { - return applyChildren(node, {creature, targets, scope, log}); + return applyChildren(node, actionContext); } if (damageTargets?.length) { damageTargets.forEach(target => { let stat = target.variables[prop.stat]; if (!stat?.type) { - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Error', value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set` }); - return applyChildren(node, {creature, targets, scope, log}); + return applyChildren(node, actionContext); } damagePropertyWork({ - property: stat, + prop: stat, operation: prop.operation, - value: value, + value, + actionContext, }); - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Attribute damage', value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + ` ${value}`, @@ -43,7 +44,7 @@ export default function applyAdjustment(node, { }); }); } else { - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: 'Attribute damage', value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + ` ${value}`, @@ -51,9 +52,10 @@ export default function applyAdjustment(node, { }); } - return applyChildren(node, {creature, targets, scope, log}); + return applyChildren(node, actionContext); } -function applyChildren(node, args){ - node.children.forEach(child => applyProperty(child, args)); +function applyChildren(node, actionContext){ + applyNodeTriggers(node, 'after', actionContext); + node.children.forEach(child => applyProperty(child, actionContext)); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js index 6a46e07c..ce918c4a 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js @@ -1,26 +1,27 @@ import applyProperty from '../applyProperty.js'; 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, { - creature, targets, scope, log -}){ +export default function applyBranch(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const applyChildren = function(){ - node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + 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){ case 'if': - recalculateCalculation(prop.condition, scope, log); + recalculateCalculation(prop.condition, actionContext); if (prop.condition?.value) applyChildren(); break; case 'index': if (node.children.length){ - recalculateCalculation(prop.condition, scope, log); + recalculateCalculation(prop.condition, actionContext); if (!isFinite(prop.condition?.value)) { - log.content.push({ + actionContext.addLog({ name: 'Branch Error', value: 'Index did not resolve into a valid number' }); @@ -29,49 +30,47 @@ export default function applyBranch(node, { let index = Math.floor(prop.condition?.value); if (index < 1) index = 1; if (index > node.children.length) index = node.children.length; - applyProperty(node.children[index - 1], { - creature, targets, scope, log - }); + applyNodeTriggers(node, 'after', actionContext); + applyProperty(node.children[index - 1], actionContext); } break; case 'hit': if (scope['$attackHit']?.value){ - if (!targets.length) log.content.push({value: '**On hit**'}); + if (!targets.length && !prop.silent) actionContext.addLog({value: '**On hit**'}); applyChildren(); } break; case 'miss': if (scope['$attackMiss']?.value){ - if (!targets.length) log.content.push({value: '**On miss**'}); + if (!targets.length && !prop.silent) actionContext.addLog({value: '**On miss**'}); applyChildren(); } break; case 'failedSave': if (scope['$saveFailed']?.value){ - if (!targets.length) log.content.push({value: '**On failed save**'}); + if (!targets.length && !prop.silent) actionContext.addLog({value: '**On failed save**'}); applyChildren(); } break; case 'successfulSave': if (scope['$saveSucceeded']?.value){ - if (!targets.length) log.content.push({value: '**On save**',}); + if (!targets.length && !prop.silent) actionContext.addLog({value: '**On save**',}); applyChildren(); } break; case 'random': if (node.children.length){ let index = rollDice(1, node.children.length)[0] - 1; - applyProperty(node.children[index], { - creature, targets, scope, log - }); + applyNodeTriggers(node, 'after', actionContext); + applyProperty(node.children[index], actionContext); } break; case 'eachTarget': - if (targets.length){ + if (targets.length) { targets.forEach(target => { - node.children.forEach(child => applyProperty(child, { - creature, targets: [target], scope, log - })); + applyNodeTriggers(node, 'after', actionContext); + actionContext.targets = [target] + node.children.forEach(child => applyProperty(child, actionContext)); }); } else { applyChildren(); diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js index 66857f6c..4e68eabe 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js @@ -1,8 +1,8 @@ import { - setLineageOfDocs, - renewDocIds + setLineageOfDocs, + renewDocIds } from '/imports/api/parenting/parenting.js'; -import {setDocToLastOrder} from '/imports/api/parenting/order.js'; +import { setDocToLastOrder } from '/imports/api/parenting/order.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'; @@ -12,21 +12,30 @@ import symbol from '/imports/parser/parseTree/symbol.js'; import logErrors from './shared/logErrors.js'; import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs.js'; import cyrb53 from '/imports/api/engine/computation/utility/cyrb53.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; +import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js'; -export default function applyBuff(node, {creature, targets, scope, log}){ +export default function applyBuff(node, actionContext) { + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - let buffTargets = prop.target === 'self' ? [creature] : targets; + let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; // Then copy the decendants of the buff to the targets let propList = [prop]; - function addChildrenToPropList(children){ + function addChildrenToPropList(children, { skipCrystalize } = {}) { children.forEach(child => { + if (skipCrystalize) child.node._skipCrystalize = true; propList.push(child.node); - addChildrenToPropList(child.children); + // recursively add the child's children, but don't crystalize nested buffs + addChildrenToPropList(child.children, { + skipCrystalize: skipCrystalize || child.node.type === 'buff' + }); }); } addChildrenToPropList(node.children); - crystalizeVariables({propList, scope, log}); + if (!prop.skipCrystalization) { + crystalizeVariables({ propList, actionContext }); + } let oldParent = { id: prop.parent.id, @@ -37,10 +46,10 @@ export default function applyBuff(node, {creature, targets, scope, log}){ copyNodeListToTarget(propList, target, oldParent); //Log the buff - if (prop.name || prop.description?.value){ - if (target._id === creature._id){ + if ((prop.name || prop.description?.value) && !prop.silent) { + if (target._id === actionContext.creature._id) { // Targeting self - log.content.push({ + actionContext.addLog({ name: prop.name, value: prop.description?.value, }); @@ -58,12 +67,13 @@ export default function applyBuff(node, {creature, targets, scope, log}){ } } }); + applyNodeTriggers(node, 'after', actionContext); // Don't apply the children of the buff, they get copied to the target instead } -function copyNodeListToTarget(propList, target, oldParent){ - let ancestry = [{collection: 'creatures', id: target._id}]; +function copyNodeListToTarget(propList, target, oldParent) { + let ancestry = [{ collection: 'creatures', id: target._id }]; setLineageOfDocs({ docArray: propList, newAncestry: ancestry, @@ -83,9 +93,14 @@ function copyNodeListToTarget(propList, target, oldParent){ * Replaces all variables with their resolved values * except variables of the form `$target.thing.total` become `thing.total` */ -function crystalizeVariables({propList, scope, log}){ +function crystalizeVariables({ propList, actionContext }) { propList.forEach(prop => { - computedSchemas[prop.type].computedFields().forEach( calcKey => { + if (prop._skipCrystalize) { + delete prop._skipCrystalize; + return; + } + // Iterate through all the calculations and crystalize them + computedSchemas[prop.type].computedFields().forEach(calcKey => { applyFnToKey(prop, calcKey, (prop, key) => { const calcObj = get(prop, key); if (!calcObj?.parseNode) return; @@ -95,16 +110,16 @@ function crystalizeVariables({propList, scope, log}){ node.parseType !== 'accessor' && node.parseType !== 'symbol' ) return node; // Handle variables - if (node.name === '$target'){ + if (node.name === '$target') { // strip $target - if (node.parseType === 'accessor'){ + if (node.parseType === 'accessor') { node.name = node.path.shift(); - if (!node.path.length){ - return symbol.create({name: node.name}) + if (!node.path.length) { + return symbol.create({ name: node.name }) } } else { // Can't strip symbols - log.content.push({ + actionContext.addLog({ name: 'Error', value: 'Variable `$target` should not be used without a property: $target.property', }); @@ -112,8 +127,8 @@ function crystalizeVariables({propList, scope, log}){ return node; } else { // Resolve all other variables - const {result, context} = resolve('reduce', node, scope); - logErrors(context.errors, log); + const { result, context } = resolve('reduce', node, actionContext.scope); + logErrors(context.errors, actionContext); return result; } }); @@ -121,5 +136,36 @@ function crystalizeVariables({propList, scope, log}){ calcObj.hash = cyrb53(calcObj.calculation); }); }); + // For each key in the schema + computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => { + // That ends in .inlineCalculations + applyFnToKey(prop, calcKey, (prop, key) => { + const inlineCalcObj = get(prop, key); + if (!inlineCalcObj) return; + + // If there is no text, skip + if (!inlineCalcObj.text) { + return; + } + + // Replace all the existing calculations + let index = -1; + inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => { + index += 1; + return `{${inlineCalcObj.inlineCalculations[index].calculation}}`; + }); + + // Set the value to the uncomputed string + inlineCalcObj.value = inlineCalcObj.text; + + // Write a new hash + const inlineCalcHash = cyrb53(inlineCalcObj.text); + if (inlineCalcHash === inlineCalcObj.hash) { + // Skip if nothing changed + return; + } + inlineCalcObj.hash = inlineCalcHash; + }); + }); }); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js new file mode 100644 index 00000000..86b60949 --- /dev/null +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js @@ -0,0 +1,101 @@ +import { findLast, difference, intersection, filter } from 'lodash'; +import applyProperty from '../applyProperty.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; +import { getProperyAncestors, getPropertiesOfType } from '/imports/api/engine/loadCreatures.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { softRemove } from '/imports/api/parenting/softRemove.js'; +import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags.js'; + +export default function applyBuffRemover(node, actionContext) { + // Apply triggers + applyNodeTriggers(node, 'before', actionContext); + + const prop = node.node; + + // Log Name + if (prop.name && !prop.silent){ + actionContext.addLog({ name: prop.name }); + } + + // Remove buffs + if (prop.targetParentBuff) { + // Remove nearest ancestor buff + const ancestors = getProperyAncestors(actionContext.creature._id, prop._id); + const nearestBuff = findLast(ancestors, ancestor => ancestor.type === 'buff'); + if (!nearestBuff) { + actionContext.addLog({ + name: 'Error', + value: 'Buff remover does not have a parent buff to remove', + }); + return; + } + removeBuff(nearestBuff, actionContext, prop); + } else { + // Get all the buffs targeted by tags + const allBuffs = getPropertiesOfType(actionContext.creature._id, 'buff'); + const targetedBuffs = filter(allBuffs, buff => { + if (buff.inactive) return false; + if (buffRemoverMatchTags(prop, buff)) return true; + }); + // Remove the buffs + if (prop.removeAll) { + // Remove all matching buffs + targetedBuffs.forEach(buff => { + removeBuff(buff, actionContext, prop); + }); + } else { + // Sort in reverse order + targetedBuffs.sort((a, b) => b.order - a.order); + // Remove the one with the highest order + const buff = targetedBuffs[0]; + if (buff) { + removeBuff(buff, actionContext, prop); + } + } + } + + // Apply triggers + applyNodeTriggers(node, 'after', actionContext); + // Apply children + node.children.forEach(child => applyProperty(child, actionContext)); +} + +function removeBuff(buff, actionContext, prop) { + if (!prop.silent) actionContext.addLog({ + name: 'Removed', + value: `${buff.name || 'Buff'}` + }); + softRemove({ _id: buff._id, collection: CreatureProperties }); +} + +function buffRemoverMatchTags(buffRemover, prop) { + let matched = false; + const propTags = getEffectivePropTags(prop); + // Check the target tags + if ( + !buffRemover.targetTags?.length || + difference(buffRemover.targetTags, propTags).length === 0 + ) { + matched = true; + } + // Check the extra tags + buffRemover.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; + } + } + }); + return matched; +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index 6be9e28d..b1f15793 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -1,28 +1,30 @@ -import { some, intersection, difference } from 'lodash'; +import { some, intersection, difference, remove, includes } from 'lodash'; import applyProperty from '../applyProperty.js'; -import { dealDamageWork } from '/imports/api/creature/creatureProperties/methods/dealDamage.js'; import {insertCreatureLog} from '/imports/api/creature/log/CreatureLogs.js'; import resolve, { Context, toString } from '/imports/parser/resolve.js'; import logErrors from './shared/logErrors.js'; import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js'; +import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; +import { + getPropertiesOfType +} from '/imports/api/engine/loadCreatures.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyDamage(node, { - creature, targets, scope, log -}){ +export default function applyDamage(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const applyChildren = function(){ - node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + applyNodeTriggers(node, 'after', actionContext); + node.children.forEach(child => applyProperty(child, actionContext)); }; const prop = node.node; + const scope = actionContext.scope; // Skip if there is no parse node to work with - if (!prop.amount.parseNode) return; + if (!prop.amount?.parseNode) return; // Choose target - - let damageTargets = prop.target === 'self' ? [creature] : targets; + let damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; // Determine if the hit is critical let criticalHit = scope['$criticalHit']?.value && prop.damageType !== 'healing' // Can't critically heal @@ -37,19 +39,19 @@ export default function applyDamage(node, { const logName = prop.damageType === 'healing' ? 'Healing' : 'Damage'; // roll the dice only and store that string - applyEffectsToCalculationParseNode(prop.amount, log); + applyEffectsToCalculationParseNode(prop.amount, actionContext.log); const {result: rolled} = resolve('roll', prop.amount.parseNode, scope, context); if (rolled.parseType !== 'constant'){ logValue.push(toString(rolled)); } - logErrors(context.errors, log); + logErrors(context.errors, actionContext); // Reset the errors so we don't log the same errors twice context.errors = []; // Resolve the roll to a final value const {result: reduced} = resolve('reduce', rolled, scope, context); - logErrors(context.errors, log); + logErrors(context.errors, actionContext); // Store the result if (reduced.parseType === 'constant'){ @@ -69,6 +71,15 @@ export default function applyDamage(node, { // Round the damage to a whole number damage = Math.floor(damage); + // Convert extra damage into the stored type + if (prop.damageType === 'extra' && scope['$lastDamageType']) { + prop.damageType = scope['$lastDamageType']; + } + // Store current damage type + if (prop.damageType !== 'healing') { + scope['$lastDamageType'] = prop.damageType; + } + // Memoise the damage suffix for the log let suffix = (criticalHit ? ' critical ' : ' ') + prop.damageType + @@ -86,15 +97,17 @@ export default function applyDamage(node, { logValue }); + actionContext.target = [target]; // Deal the damage to the target - let damageDealt = dealDamageWork({ - creature: target, + let damageDealt = dealDamage({ + target, damageType: prop.damageType, amount: damage, + actionContext }); // Log the damage done - if (target._id === creature._id){ + if (target._id === actionContext.creature._id){ // Target is same as self, log damage as such logValue.push(`**${damageDealt}** ${suffix} to self`); } else { @@ -114,7 +127,7 @@ export default function applyDamage(node, { // There are no targets, just log the result logValue.push(`**${damage}** ${suffix}`); } - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: logName, value: logValue.join('\n'), inline: true, @@ -133,21 +146,21 @@ function applyDamageMultipliers({target, damage, damageProp, logValue}){ if ( multiplier.immunity && - some(multiplier.immunities, multiplierAppliesTo(damageProp)) + some(multiplier.immunities, multiplierAppliesTo(damageProp, 'immunity')) ){ logValue.push(`Immune to ${damageTypeText}`); return 0; } else { if ( multiplier.resistance && - some(multiplier.resistances, multiplierAppliesTo(damageProp)) + some(multiplier.resistances, multiplierAppliesTo(damageProp, 'resistance')) ){ logValue.push(`Resistant to ${damageTypeText}`); damage = Math.floor(damage / 2); } if ( multiplier.vulnerability && - some(multiplier.vulnerabilities, multiplierAppliesTo(damageProp)) + some(multiplier.vulnerabilities, multiplierAppliesTo(damageProp, 'vulnerability')) ){ logValue.push(`Vulnerable to ${damageTypeText}`); damage = Math.floor(damage * 2); @@ -156,8 +169,11 @@ function applyDamageMultipliers({target, damage, damageProp, logValue}){ return damage; } -function multiplierAppliesTo(damageProp){ +function multiplierAppliesTo(damageProp, multiplierType){ return multiplier => { + // Apply the default 'ignore x' tags + if (includes(damageProp.tags, `ignore ${multiplierType}`)) return false; + const hasRequiredTags = difference( multiplier.includeTags, damageProp.tags ).length === 0; @@ -169,3 +185,59 @@ function multiplierAppliesTo(damageProp){ return hasRequiredTags && hasNoExcludedTags; } } + +function dealDamage({target, damageType, amount, actionContext}){ + // Get all the health bars and do damage to them + 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) + ); + + // Sort healthbars by damage/healing order or tree order as a fallback + healthBars.sort((a, b) => { + let diff; + if (amount >= 0) { + diff = a.healthBarDamageOrder - b.healthBarDamageOrder; + } else { + diff = a.healthBarHealingOrder - b.healthBarHealingOrder; + } + if (Number.isFinite(diff)) { + return diff; + } else { + return a.order - b.order; + } + }); + + // Deal the damage to each healthbar in order until all damage is done + const totalDamage = amount; + let damageLeft = totalDamage; + if (damageType === 'healing') damageLeft = -totalDamage; + healthBars.forEach(healthBar => { + if (damageLeft === 0) return; + // Replace the healthbar by the one in the action context if we can + // The damagePropertyWork function bashes the prop with the damage + // So we can use the new value in later action properties + if (healthBar.variableName) { + const targetHealthBar = target.variables[healthBar.variableName]; + if (targetHealthBar?._id === healthBar._id) { + healthBar = targetHealthBar; + } + } + // Do the damage + let damageAdded = damagePropertyWork({ + prop: healthBar, + operation: 'increment', + value: damageLeft, + actionContext + }); + damageLeft -= damageAdded; + }); + return totalDamage; +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js b/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js new file mode 100644 index 00000000..0965f56d --- /dev/null +++ b/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js @@ -0,0 +1,11 @@ +import recalculateInlineCalculations from './shared/recalculateInlineCalculations.js'; +import applyProperty from '../applyProperty.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; + +export default function applyFolder(node, actionContext) { + // Apply triggers + applyNodeTriggers(node, 'before', actionContext); + applyNodeTriggers(node, 'after', actionContext); + // Apply children + node.children.forEach(child => applyProperty(child, actionContext)); +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyNote.js b/app/imports/api/engine/actions/applyPropertyByType/applyNote.js index 2d460ee2..0d5f9e84 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyNote.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyNote.js @@ -1,25 +1,27 @@ import recalculateInlineCalculations from './shared/recalculateInlineCalculations.js'; import applyProperty from '../applyProperty.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyNote(node, {creature, targets, scope, log}){ +export default function applyNote(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; // Log Name, summary let content = { name: prop.name }; if (prop.summary?.text){ - recalculateInlineCalculations(prop.summary, scope, log); + recalculateInlineCalculations(prop.summary, actionContext); content.value = prop.summary.value; } if (content.name || content.value){ - log.content.push(content); + actionContext.addLog(content); } // Log description if (prop.description?.text){ - recalculateInlineCalculations(prop.description, scope, log); - log.content.push({value: prop.description.value}); + recalculateInlineCalculations(prop.description, actionContext); + actionContext.addLog({value: prop.description.value}); } + // Apply triggers + applyNodeTriggers(node, 'after', actionContext); // Apply children - node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + node.children.forEach(child => applyProperty(child, actionContext)); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js index 8d923e4e..7d860948 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js @@ -2,33 +2,34 @@ import applyProperty from '../applyProperty.js'; import logErrors from './shared/logErrors.js'; import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js'; import resolve, { toString } from '/imports/parser/resolve.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyRoll(node, {creature, targets, scope, log}){ +export default function applyRoll(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; const applyChildren = function(){ - node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + applyNodeTriggers(node, 'after', actionContext); + node.children.forEach(child => applyProperty(child, actionContext)); }; if (prop.roll?.calculation){ const logValue = []; // roll the dice only and store that string - applyEffectsToCalculationParseNode(prop.roll, log); - const {result: rolled, context} = resolve('roll', prop.roll.parseNode, scope); + applyEffectsToCalculationParseNode(prop.roll, actionContext); + const {result: rolled, context} = resolve('roll', prop.roll.parseNode, actionContext.scope); if (rolled.parseType !== 'constant'){ logValue.push(toString(rolled)); } - logErrors(context.errors, log); + logErrors(context.errors, actionContext); // Reset the errors so we don't log the same errors twice context.errors = []; // Resolve the roll to a final value - const {result: reduced} = resolve('reduce', rolled, scope, context); - logErrors(context.errors, log); + const {result: reduced} = resolve('reduce', rolled, actionContext.scope, context); + logErrors(context.errors, actionContext); // Store the result if (reduced.parseType === 'constant'){ @@ -45,11 +46,11 @@ export default function applyRoll(node, {creature, targets, scope, log}){ } const value = reduced.value; - scope[prop.variableName] = value; + actionContext.scope[prop.variableName] = value; logValue.push(`**${value}**`); if (!prop.silent){ - log.content.push({ + actionContext.addLog({ name: prop.name, value: logValue.join('\n'), inline: true, diff --git a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js index 503442ab..a5aa4bae 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js @@ -2,38 +2,38 @@ import rollDice from '/imports/parser/rollDice.js'; import recalculateCalculation from './shared/recalculateCalculation.js'; import applyProperty from '../applyProperty.js'; import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applySavingThrow(node, {creature, targets, scope, log}){ +export default function applySavingThrow(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - let saveTargets = prop.target === 'self' ? [creature] : targets; + let saveTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; - recalculateCalculation(prop.dc, scope, log); + recalculateCalculation(prop.dc, actionContext); const dc = (prop.dc?.value); if (!isFinite(dc)){ - log.content.push({ + actionContext.addLog({ name: 'Error', value: 'Saving throw requires a DC', }); - return node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + return node.children.forEach(child => applyProperty(child, actionContext)); } - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: prop.name, value: `DC **${dc}**`, inline: true, }); + const scope = actionContext.scope; // 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}; - return node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + scope['$saveSucceeded'] = { value: true }; + applyNodeTriggers(node, 'after', actionContext); + return node.children.forEach(child => applyProperty(child, actionContext)); } // Each target makes the saving throw @@ -43,16 +43,16 @@ export default function applySavingThrow(node, {creature, targets, scope, log}){ delete scope['$saveDiceRoll']; delete scope['$saveRoll']; - const applyChildren = function(){ - node.children.forEach(child => applyProperty(child, { - creature, targets: [target], scope, log - })); + const applyChildren = function () { + applyNodeTriggers(node, 'after', actionContext); + actionContext.targets = [target] + node.children.forEach(child => applyProperty(child, actionContext)); }; const save = target.variables[prop.stat]; if (!save){ - log.content.push({ + actionContext.addLog({ name: 'Saving throw error', value: 'No saving throw found: ' + prop.stat, }); @@ -94,7 +94,7 @@ export default function applySavingThrow(node, {creature, targets, scope, log}){ } else { scope['$saveFailed'] = {value: true}; } - log.content.push({ + if (!prop.silent) actionContext.addLog({ name: saveSuccess ? 'Successful save' : 'Failed save', value: resultPrefix + '\n**' + result + '**', inline: true, diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js b/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js index 5162c41f..be80b012 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js @@ -1,14 +1,13 @@ import applyProperty from '../applyProperty.js'; import recalculateCalculation from './shared/recalculateCalculation.js'; +import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyToggle(node, { - creature, targets, scope, log -}){ +export default function applyToggle(node, actionContext){ + applyNodeTriggers(node, 'before', actionContext); const prop = node.node; - recalculateCalculation(prop.condition, scope, log); + recalculateCalculation(prop.condition, actionContext); if (prop.condition?.value) { - return node.children.forEach(child => applyProperty(child, { - creature, targets, scope, log - })); + applyNodeTriggers(node, 'after', actionContext); + return node.children.forEach(child => applyProperty(child, actionContext)); } } diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js b/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js index be625eea..0ada4ea1 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js @@ -2,7 +2,7 @@ import operator from '/imports/parser/parseTree/operator.js'; import { parse } from '/imports/parser/parser.js'; import logErrors from './logErrors.js'; -export default function applyEffectsToCalculationParseNode(calcObj, log){ +export default function applyEffectsToCalculationParseNode(calcObj, actionContext){ if (!calcObj.effects) return; calcObj.effects.forEach(effect => { if (effect.operation !== 'add') return; @@ -18,7 +18,7 @@ export default function applyEffectsToCalculationParseNode(calcObj, log){ fn: 'add' }); } catch (e){ - logErrors([e], log) + logErrors([e], actionContext) } }); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js b/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js index 219fddd5..9ea760e6 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js @@ -1,7 +1,7 @@ -export default function logErrors(errors, log){ +export default function logErrors(errors, actionContext){ errors?.forEach(error => { if (error.type !== 'info'){ - log.content.push({name: 'Error', value: error.message}); + actionContext.addLog({name: 'Error', value: error.message}); } }); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js index a10340be..2f484567 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js @@ -2,10 +2,10 @@ import evaluateCalculation from '/imports/api/engine/computation/utility/evaluat import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js'; import logErrors from './logErrors.js'; -export default function recalculateCalculation(calc, scope, log, context){ +export default function recalculateCalculation(calc, actionContext, context){ if (!calc?.parseNode) return; calc._parseLevel = 'reduce'; - applyEffectsToCalculationParseNode(calc, log); - evaluateCalculation(calc, scope, context); - logErrors(calc.errors, log); + applyEffectsToCalculationParseNode(calc, actionContext); + evaluateCalculation(calc, actionContext.scope, context); + logErrors(calc.errors, actionContext); } diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js index e4b49be0..1a2b1cb7 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js @@ -1,12 +1,12 @@ import embedInlineCalculations from '/imports/api/engine/computation/utility/embedInlineCalculations.js'; import recalculateCalculation from './recalculateCalculation.js' -export default function recalculateInlineCalculations(inlineCalcObj, scope, log){ +export default function recalculateInlineCalculations(inlineCalcObj, actionContext){ // Skip if there are no calculations if (!inlineCalcObj?.inlineCalculations?.length) return; // Recalculate each calculation with the current scope inlineCalcObj.inlineCalculations.forEach(calc => { - recalculateCalculation(calc, scope, log); + recalculateCalculation(calc, actionContext); }); // Embed the new calculated values embedInlineCalculations(inlineCalcObj); diff --git a/app/imports/api/engine/actions/applyTriggers.js b/app/imports/api/engine/actions/applyTriggers.js new file mode 100644 index 00000000..8fd6a536 --- /dev/null +++ b/app/imports/api/engine/actions/applyTriggers.js @@ -0,0 +1,111 @@ +import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js'; +import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js'; +import { getPropertyDecendants } from '/imports/api/engine/loadCreatures.js'; +import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js'; +import applyProperty from '/imports/api/engine/actions/applyProperty.js'; +import { difference, intersection } from 'lodash'; +import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags.js'; + +export function applyNodeTriggers(node, timing, actionContext) { + const prop = node.node; + const type = prop.type; + const triggers = actionContext.triggers?.doActionProperty?.[type]?.[timing]; + if (triggers) { + triggers.forEach(trigger => { + applyTrigger(trigger, prop, actionContext); + }); + } +} + +export function applyTriggers(triggers = [], prop, actionContext) { + // Apply the triggers + triggers.forEach(trigger => { + applyTrigger(trigger, prop, actionContext) + }); +} + +export function applyTrigger(trigger, prop, actionContext) { + // If there is a prop we are applying the trigger from, + // don't fire if the tags don't match + if (prop && !triggerMatchTags(trigger, prop)) { + return; + } + + // Prevent trigger from firing if it's inactive + if (trigger.inactive) { + return; + } + + // Prevent triggers from firing if their condition is false + if (trigger.condition?.parseNode) { + recalculateCalculation(trigger.condition, actionContext); + if (!trigger.condition.value) return; + } + + // Prevent triggers from firing themselves in a loop + if (trigger.firing) { + /* + log.content.push({ + name: trigger.name || 'Trigger', + value: 'Trigger can\'t fire itself', + inline: true, + }); + */ + return; + } + trigger.firing = true; + + // Fire the trigger + const content = { + name: trigger.name || 'Trigger', + value: trigger.description, + inline: false, + } + if (trigger.description?.text){ + recalculateInlineCalculations(trigger.description, actionContext); + content.value = trigger.description.value; + } + if(!trigger.silent) actionContext.addLog(content); + + // Get all the trigger's properties and apply them + const properties = getPropertyDecendants(actionContext.creature._id, trigger._id); + properties.sort((a, b) => a.order - b.order); + const propertyForest = nodeArrayToTree(properties); + propertyForest.forEach(node => { + applyProperty(node, actionContext); + }); + + trigger.firing = false; +} + +function triggerMatchTags(trigger, prop) { + let matched = false; + const propTags = getEffectivePropTags(prop); + // Check the target tags + if ( + !trigger.targetTags?.length || + difference(trigger.targetTags, propTags).length === 0 + ) { + 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; + } + } + }); + return matched; +} diff --git a/app/imports/api/engine/actions/doAction.js b/app/imports/api/engine/actions/doAction.js index e72e43bc..57c7beac 100644 --- a/app/imports/api/engine/actions/doAction.js +++ b/app/imports/api/engine/actions/doAction.js @@ -1,14 +1,15 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs.js'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js'; +import { + getProperyAncestors, getPropertyDecendants +} from '/imports/api/engine/loadCreatures.js'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import applyProperty from './applyProperty.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; +import ActionContext from '/imports/api/engine/actions/ActionContext.js'; const doAction = new ValidatedMethod({ name: 'creatureProperties.doAction', @@ -35,51 +36,33 @@ const doAction = new ValidatedMethod({ numRequests: 10, timeInterval: 5000, }, - run({actionId, targetIds = [], scope}) { + run({ actionId, targetIds = [], scope }) { + // Get action context let action = CreatureProperties.findOne(actionId); - // Check permissions - let creature = getRootCreatureAncestor(action); + const creatureId = action.ancestors[0].id; + const actionContext = new ActionContext(creatureId, targetIds, this); - assertEditPermission(creature, this.userId); - - // Get all the targets and make sure we can edit them - let targets = []; - targetIds.forEach(targetId => { - let target = Creatures.findOne(targetId); + // Check permissions + assertEditPermission(actionContext.creature, this.userId); + actionContext.targets.forEach(target => { assertEditPermission(target, this.userId); - targets.push(target); }); - // Fetch all the action's ancestor creatureProperties - const ancestorIds = []; - action.ancestors.forEach(ref => { - if (ref.collection === 'creatureProperties') { - ancestorIds.push(ref.id); - } - }); + const ancestors = getProperyAncestors(creatureId, action._id); + ancestors.sort((a, b) => a.order - b.order); - // Get cursor of ancestors - const ancestors = CreatureProperties.find({ - _id: {$in: ancestorIds}, - }, { - sort: {order: 1}, - }); - - // Get cursor of the properties - const properties = CreatureProperties.find({ - $or: [{_id: action._id}, {'ancestors.id': action._id}], - removed: {$ne: true}, - }, { - sort: {order: 1}, - }); + const properties = getPropertyDecendants(creatureId, action._id); + properties.push(action); + properties.sort((a, b) => a.order - b.order); // Do the action - doActionWork({creature, targets, properties, ancestors, method: this, methodScope: scope}); + doActionWork({ properties, ancestors, actionContext, methodScope: scope }); // Recompute all involved creatures - computeCreature(creature._id); - targets.forEach(target => { - computeCreature(target._id); + Creatures.update({ + _id: { $in: [creatureId, ...targetIds] } + }, { + $set: { dirty: true }, }); }, }); @@ -87,41 +70,28 @@ const doAction = new ValidatedMethod({ export default doAction; export function doActionWork({ - creature, targets, properties, ancestors, method, methodScope = {}, log -}){ + properties, ancestors, actionContext, methodScope = {}, +}) { // get the docs const ancestorScope = getAncestorScope(ancestors); const propertyForest = nodeArrayToTree(properties); - if (propertyForest.length !== 1){ + if (propertyForest.length !== 1) { throw new Meteor.Error(`The action has ${propertyForest.length} top level properties, expected 1`); } - // Create the log - if (!log) log = CreatureLogSchema.clean({ - creatureId: creature._id, - creatureName: creature.name, - }); + // Include the ancestry and method scope in the context scope + Object.assign(actionContext.scope, ancestorScope, methodScope); // Apply the top level property, it is responsible for applying its children // recursively - const scope = { - ...creature.variables, - ...ancestorScope, - ...methodScope - } - applyProperty(propertyForest[0], { - creature, - targets, - scope, - log, - }); + applyProperty(propertyForest[0], actionContext); // Insert the log - insertCreatureLogWork({log, creature, method}); + actionContext.writeLog(); } // Assumes ancestors are in tree order already -function getAncestorScope(ancestors){ +function getAncestorScope(ancestors) { let scope = {}; ancestors.forEach(prop => { scope[`#${prop.type}`] = prop; diff --git a/app/imports/api/engine/actions/doAction.test.js b/app/imports/api/engine/actions/doAction.test.js index 19560704..56c365cb 100644 --- a/app/imports/api/engine/actions/doAction.test.js +++ b/app/imports/api/engine/actions/doAction.test.js @@ -1,11 +1,53 @@ import '/imports/api/simpleSchemaConfig.js'; //import testTypes from './testTypes/index.js'; import { doActionWork } from './doAction.js'; -import createAction from './tests/createAction.testFn.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){ + let schema = CreatureProperties.simpleSchema(prop); + return schema.clean(prop); +} + +function cleanCreature(creature){ + let schema = Creatures.simpleSchema(creature); + return schema.clean(creature); +} + +// Fake ActionContext to test actions with +const creatureId = 'actionTestCreatureId'; +const creatureName = 'Action Test Creature'; +const testActionContext = { + creature: cleanCreature({ + _id: creatureId, + }), + log: CreatureLogSchema.clean({ + creatureId: creatureId, + creatureName: creatureName, + }), + scope: {}, + addLog(content) { + if (content.name || content.value){ + this.log.content.push(content); + } + }, + writeLog: () => { }, +} + +const action = cleanProp({ + type: 'action', +}); +const actionAncestors = []; describe('Do Action', function(){ it('Does an empty action', function(){ - doActionWork(createAction({properties: [{type: 'action'}]})); + doActionWork({ + properties: [action], + ancestors: actionAncestors, + actionContext: testActionContext, + methodScope: {}, + }); }); //testTypes.forEach(test => it(test.text, test.fn)); }); diff --git a/app/imports/api/engine/actions/doCastSpell.js b/app/imports/api/engine/actions/doCastSpell.js index 890c4879..f512e4ed 100644 --- a/app/imports/api/engine/actions/doCastSpell.js +++ b/app/imports/api/engine/actions/doCastSpell.js @@ -1,14 +1,15 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import { + getProperyAncestors, getPropertyDecendants +} from '/imports/api/engine/loadCreatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; import { doActionWork } from '/imports/api/engine/actions/doAction.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; -import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs.js'; +import ActionContext from '/imports/api/engine/actions/ActionContext.js'; const doAction = new ValidatedMethod({ name: 'creatureProperties.doCastSpell', @@ -19,6 +20,10 @@ const doAction = new ValidatedMethod({ regEx: SimpleSchema.RegEx.Id, optional: true, }, + ritual: { + type: Boolean, + optional: true, + }, targetIds: { type: Array, defaultValue: [], @@ -40,101 +45,90 @@ const doAction = new ValidatedMethod({ numRequests: 10, timeInterval: 5000, }, - run({spellId, slotId, targetIds = [], scope = {}}) { + run({ spellId, slotId, ritual, targetIds = [], scope = {} }) { + // Get action context let spell = CreatureProperties.findOne(spellId); - // Check permissions - let creature = getRootCreatureAncestor(spell); + const creatureId = spell.ancestors[0].id; + const actionContext = new ActionContext(creatureId, targetIds, this); - assertEditPermission(creature, this.userId); - - // Get all the targets and make sure we can edit them - let targets = []; - targetIds.forEach(targetId => { - let target = Creatures.findOne(targetId); + // Check permissions + assertEditPermission(actionContext.creature, this.userId); + actionContext.targets.forEach(target => { assertEditPermission(target, this.userId); - targets.push(target); }); - // Fetch all the action's ancestor creatureProperties - const ancestorIds = []; - spell.ancestors.forEach(ref => { - if (ref.collection === 'creatureProperties') { - ancestorIds.push(ref.id); - } - }); + const ancestors = getProperyAncestors(creatureId, spell._id); + ancestors.sort((a, b) => a.order - b.order); - // Get cursor of ancestors - const ancestors = CreatureProperties.find({ - _id: {$in: ancestorIds}, - }, { - sort: {order: 1}, - }); - - // Get cursor of the properties - const properties = CreatureProperties.find({ - $or: [{_id: spell._id}, {'ancestors.id': spell._id}], - removed: {$ne: true}, - }, { - sort: {order: 1}, - }); + const properties = getPropertyDecendants(creatureId, spell._id); + properties.push(spell); + properties.sort((a, b) => a.order - b.order); // Spend the appropriate slot let slotLevel = spell.level || 0; let slot; - if (slotId && !spell.castWithoutSpellSlots){ + + // If a spell requires a slot, make sure a slot is spent + if (spell.level && !spell.castWithoutSpellSlots && !(ritual && spell.ritual)) { slot = CreatureProperties.findOne(slotId); - if (!slot){ + if (!slot) { throw new Meteor.Error('No slot', 'Slot not found to cast spell'); } - if (!slot.value){ + if (!slot.value) { throw new Meteor.Error('No slot', 'Slot depleted'); } - if (slot.attributeType !== 'spellSlot'){ + if (slot.attributeType !== 'spellSlot') { throw new Meteor.Error('Not a slot', 'The given property is not a valid spell slot'); } - if (!slot.spellSlotLevel?.value){ + if (!slot.spellSlotLevel?.value) { throw new Meteor.Error('No slot level', 'Slot does not have a spell slot level'); } - if (slot.spellSlotLevel.value < spell.level){ + if (slot.spellSlotLevel.value < spell.level) { throw new Meteor.Error('Slot too small', 'Slot is not large enough to cast spell'); } slotLevel = slot.spellSlotLevel.value; damagePropertyWork({ - property: slot, + prop: slot, operation: 'increment', value: 1, + actionContext, }); } - scope['slotLevel'] = slotLevel; - // Post the slot level spent to the log - const log = CreatureLogSchema.clean({ - creatureId: creature._id, - creatureName: creature.name, - }); - if (slot?.spellSlotLevel?.value){ - log.content.push({ + if (slot?.spellSlotLevel?.value) { + actionContext.addLog({ name: `Casting using a level ${slotLevel} spell slot` }); } else if (slotLevel) { - log.content.push({ - name: `Casting at level ${slotLevel}` - }); + if (ritual) { + actionContext.addLog({ + name: `Ritual casting at level ${slotLevel}` + }); + } else { + actionContext.addLog({ + name: `Casting at level ${slotLevel}` + }); + } } - // Do the action - doActionWork({creature, targets, properties, ancestors, method: this, methodScope: scope, log}); + actionContext.scope['slotLevel'] = slotLevel; - // Recompute all involved creatures - computeCreature(creature._id); - targets.forEach(target => { - computeCreature(target._id); + // Do the action + doActionWork({ + properties, ancestors, actionContext, methodScope: scope, + }); + + // Force the characters involved to recalculate + Creatures.update({ + _id: { $in: [creatureId, ...targetIds] } + }, { + $set: { dirty: true }, }); }, }); diff --git a/app/imports/api/engine/actions/doCheck.js b/app/imports/api/engine/actions/doCheck.js index ce66db02..a5bc653b 100644 --- a/app/imports/api/engine/actions/doCheck.js +++ b/app/imports/api/engine/actions/doCheck.js @@ -1,13 +1,13 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs.js'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; import rollDice from '/imports/parser/rollDice.js'; import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; +import { applyTriggers } from '/imports/api/engine/actions/applyTriggers.js'; +import ActionContext from '/imports/api/engine/actions/ActionContext.js'; +import evaluateCalculation from '/imports/api/engine/computation/utility/evaluateCalculation.js'; const doCheck = new ValidatedMethod({ name: 'creatureProperties.doCheck', @@ -23,53 +23,48 @@ const doCheck = new ValidatedMethod({ numRequests: 10, timeInterval: 5000, }, - run({propId, scope}) { + run({ propId, scope }) { const prop = CreatureProperties.findOne(propId); - const creature = getRootCreatureAncestor(prop); + const creatureId = prop.ancestors[0].id; + const actionContext = new ActionContext(creatureId, [creatureId], this); + Object.assign(actionContext.scope, scope); // Check permissions - assertEditPermission(creature, this.userId); + assertEditPermission(actionContext.creature, this.userId); // Do the check - doCheckWork({creature, prop, method: this, methodScope: scope}); - - // Recompute all involved creatures - computeCreature(creature._id); + doCheckWork({ prop, actionContext }); }, }); export default doCheck; -export function doCheckWork({ - creature, prop, method, methodScope = {} -}){ - // Create the log - let log = CreatureLogSchema.clean({ - creatureId: creature._id, - creatureName: creature.name, - }); +export function doCheckWork({ prop, actionContext }) { - rollCheck({prop, log, methodScope}); + applyTriggers(actionContext.triggers.check?.before, prop, actionContext); + rollCheck(prop, actionContext); + applyTriggers(actionContext.triggers.check?.after, prop, actionContext); // Insert the log - insertCreatureLogWork({log, creature, method}); + actionContext.writeLog(); } -function rollCheck({prop, log, methodScope}){ +function rollCheck(prop, actionContext) { + const scope = actionContext.scope; // get the modifier for the roll let rollModifier; let logName = `${prop.name} check`; - if (prop.type === 'skill'){ + if (prop.type === 'skill') { rollModifier = prop.value; - if (prop.skillType === 'save'){ - if (prop.name.match(/save/i)){ + if (prop.skillType === 'save') { + if (prop.name.match(/save/i)) { logName = prop.name; } else { logName = prop.name ? `${prop.name} save` : 'Saving Throw'; } } - } else if (prop.type === 'attribute'){ - if (prop.attributeType === 'ability'){ + } else if (prop.type === 'attribute') { + if (prop.attributeType === 'ability') { rollModifier = prop.modifier; } else { rollModifier = prop.value; @@ -78,10 +73,14 @@ function rollCheck({prop, log, methodScope}){ throw (`${prop.type} not supported for checks`); } - const rollModifierText = numberToSignedString(rollModifier, true); + let rollModifierText = numberToSignedString(rollModifier, true); + + const { effectBonus, effectString } = applyUnresolvedEffects(prop, scope) + rollModifierText += effectString; + rollModifier += effectBonus; let value, values, resultPrefix; - if (methodScope['$checkAdvantage'] === 1){ + if (scope['$checkAdvantage'] === 1) { logName += ' (Advantage)'; const [a, b] = rollDice(2, 20); if (a >= b) { @@ -91,7 +90,7 @@ function rollCheck({prop, log, methodScope}){ value = b; resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `; } - } else if (methodScope['$checkAdvantage'] === -1){ + } else if (scope['$checkAdvantage'] === -1) { logName += ' (Disadvantage)'; const [a, b] = rollDice(2, 20); if (a <= b) { @@ -107,8 +106,29 @@ function rollCheck({prop, log, methodScope}){ resultPrefix = `1d20 [ ${value} ] ${rollModifierText} = ` } const result = (value + rollModifier) || 0; - log.content.push({ + scope['$checkDiceRoll'] = value; + scope['$checkRoll'] = result; + scope['$checkModifier'] = rollModifier; + actionContext.addLog({ name: logName, value: `${resultPrefix} **${result}**`, }); } + +function applyUnresolvedEffects(prop, scope) { + let effectBonus = 0; + let effectString = ''; + if (!prop.effects) { + return { effectBonus, effectString }; + } + prop.effects.forEach(effect => { + if (!effect.amount?.parseNode) return; + if (effect.operation !== 'add') return; + effect.amount._parseLevel = 'reduce'; + evaluateCalculation(effect.amount, scope); + if (typeof effect.amount?.value !== 'number') return; + effectBonus += effect.amount.value; + effectString += ` ${effect.amount.value < 0 ? '-' : '+'} [${effect.amount.calculation}] ${Math.abs(effect.amount.value)}` + }); + return { effectBonus, effectString }; +} diff --git a/app/imports/api/engine/actions/tests/createAction.testFn.js b/app/imports/api/engine/actions/tests/createAction.testFn.js deleted file mode 100644 index 84ffa907..00000000 --- a/app/imports/api/engine/actions/tests/createAction.testFn.js +++ /dev/null @@ -1,26 +0,0 @@ -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; - -export default function createAction({ - creature = {_id: 'creatureId'}, - targets = [], - properties = [], - ancestors = [], - method -} = {}){ - properties = properties.map(cleanProp); - ancestors = ancestors.map(cleanProp); - creature = cleanCreature(creature); - ancestors = ancestors.map(cleanCreature); - return {creature, targets, properties, ancestors, method}; -} - -function cleanProp(prop){ - let schema = CreatureProperties.simpleSchema(prop); - return schema.clean(prop); -} - -function cleanCreature(creature){ - let schema = Creatures.simpleSchema(creature); - return schema.clean(creature); -} diff --git a/app/imports/api/engine/actions/tests/testTypes/index.testFn.js b/app/imports/api/engine/actions/tests/testTypes/index.testFn.js deleted file mode 100644 index e8a4b486..00000000 --- a/app/imports/api/engine/actions/tests/testTypes/index.testFn.js +++ /dev/null @@ -1,6 +0,0 @@ -import applyAction from './applyAction.testFn.js'; - -export default [{ - text: 'Applies actions', - fn: applyAction, -},]; diff --git a/app/imports/api/engine/computation/CreatureComputation.js b/app/imports/api/engine/computation/CreatureComputation.ts similarity index 52% rename from app/imports/api/engine/computation/CreatureComputation.js rename to app/imports/api/engine/computation/CreatureComputation.ts index 90e28f44..75ec51b3 100644 --- a/app/imports/api/engine/computation/CreatureComputation.js +++ b/app/imports/api/engine/computation/CreatureComputation.ts @@ -1,16 +1,34 @@ import { EJSON } from 'meteor/ejson'; -import createGraph from 'ngraph.graph'; +import createGraph, { Graph } from 'ngraph.graph'; +import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags.js'; + +interface CreatureProperty { + _id: string; + type: string; +} export default class CreatureComputation { - constructor(properties){ + originalPropsById: object; + propsById: object; + propsWithTag: object; + scope: object; + props: Array; + dependencyGraph: Graph; + errors: Array; + creature: object; + variables: object; + + constructor(properties: Array, creature: object, variables: object) { // Set up fields - this.originalPropsById = {}; - this.propsById = {}; + this.originalPropsById = {}; + this.propsById = {}; this.propsWithTag = {}; this.scope = {}; - this.props = properties; + this.props = properties; this.dependencyGraph = createGraph(); this.errors = []; + this.creature = creature; + this.variables = variables; // Store properties for easy access later properties.forEach(prop => { @@ -20,28 +38,15 @@ export default class CreatureComputation { // Store by id this.propsById[prop._id] = prop; - // Store tags - const storePropOnTag = (prop, tag) => { + // Store sets of ids in each tag + getEffectivePropTags(prop).forEach(tag => { if (!tag) return; - if (this.propsWithTag[tag]){ + if (this.propsWithTag[tag]) { this.propsWithTag[tag].push(prop._id); } else { this.propsWithTag[tag] = [prop._id]; } - } - // Store sets of ids in each tag - if (prop.tags){ - prop.tags.forEach(tag => { - storePropOnTag(prop, tag); - }); - } - // Store tags for the property type - storePropOnTag(prop, `#${prop.type}`); - // Store tags for some string properties - storePropOnTag(prop, prop.damageType); - storePropOnTag(prop, prop.skillType); - storePropOnTag(prop, prop.attributeType); - storePropOnTag(prop, prop.reset); + }); // Store the prop in the dependency graph this.dependencyGraph.addNode(prop._id, prop); diff --git a/app/imports/api/engine/computation/buildComputation/computeInactiveStatus.js b/app/imports/api/engine/computation/buildComputation/computeInactiveStatus.js index b794a61e..5282e93e 100644 --- a/app/imports/api/engine/computation/buildComputation/computeInactiveStatus.js +++ b/app/imports/api/engine/computation/buildComputation/computeInactiveStatus.js @@ -29,11 +29,12 @@ function childrenActive(prop){ // Children of disabled properties are always inactive if (prop.disabled) return false; switch (prop.type){ - // Only equipped items have active children - case 'item': return !!prop.equipped; - // The children of actions are always inactive + // Only equipped items with non-zero quantity have active children + case 'item': return !!prop.equipped && prop.quantity !== 0; + // The children of actions, spells, and triggers are always inactive case 'action': return false; case 'spell': return false; + case 'trigger': return false; // The children of notes are always inactive case 'note': return false; // Other children are active diff --git a/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js index 13549e73..b7a98595 100644 --- a/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js +++ b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js @@ -10,8 +10,8 @@ export default function computeToggleDependencies(node, dependencyGraph){ prop.enabled ) return; walkDown(node.children, child => { - child.node._computationDetails.toggleAncestors.push(prop); // The child nodes depend on the toggle condition compuation + 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 848cf1d3..44fc93e9 100644 --- a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js +++ b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js @@ -1,4 +1,4 @@ -import { get, intersection, difference } from 'lodash'; +import { get, intersection, difference, union } from 'lodash'; const linkDependenciesByType = { action: linkAction, @@ -14,6 +14,7 @@ const linkDependenciesByType = { effect: linkEffects, proficiency: linkProficiencies, roll: linkRoll, + pointBuy: linkPointBuy, propertySlot: linkSlot, skill: linkSkill, spell: linkAction, @@ -105,7 +106,8 @@ function linkBuff(dependencyGraph, prop){ dependOnCalc({dependencyGraph, prop, key: 'duration'}); } -function linkClassLevel(dependencyGraph, prop){ +function linkClassLevel(dependencyGraph, prop) { + if (prop.inactive) return; // The variableName of the prop depends on the prop if (prop.variableName && prop.level){ dependencyGraph.addLink(prop.variableName, prop._id, 'classLevel'); @@ -121,17 +123,33 @@ function linkDamage(dependencyGraph, prop){ dependOnCalc({dependencyGraph, prop, key: 'amount'}); } -function linkEffects(dependencyGraph, prop, computation){ +function linkEffects(dependencyGraph, prop, computation) { // The effect depends on its amount calculation - dependOnCalc({dependencyGraph, prop, key: 'amount'}); + dependOnCalc({ dependencyGraph, prop, key: 'amount' }); + // Inactive effects aren't going to impact their targeted stats + if (prop.inactive) return; // The stats depend on the effect - if (prop.targetByTags){ + if (prop.inactive) { + // Inactive effects apply to no stats + return; + } else if (prop.targetByTags){ getEffectTagTargets(prop, computation).forEach(targetId => { const targetProp = computation.propsById[targetId]; - const key = prop.targetField || getDefaultCalculationField(targetProp); - const calcObj = get(targetProp, key); - if (calcObj && calcObj.calculation){ - dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id , 'effect'); + 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 effect on its variable name + dependencyGraph.addLink(targetProp.variableName, prop._id, 'effect'); + } 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 , 'effect'); + } } }); } else { @@ -144,16 +162,18 @@ function linkEffects(dependencyGraph, prop, computation){ // Returns an array of IDs of the properties the effect targets function getEffectTagTargets(effect, computation){ - const targets = getTargetListFromTags(effect.targetTags, computation); - const notIds = []; + let targets = getTargetListFromTags(effect.targetTags, computation); + let notIds = []; if (effect.extraTags){ effect.extraTags.forEach(ex => { - if (ex.operation === 'OR'){ - targets.push(...getTargetListFromTags(ex.tags, computation)); + if (ex.operation === 'OR') { + targets = union(targets, getTargetListFromTags(ex.tags, computation)); } else if (ex.operation === 'NOT'){ ex.tags.forEach(tag => { const idList = computation.propsWithTag[tag]; - if (idList) notIds.push(...computation.propsWithTag[tag]) + if (idList) { + notIds = union(notIds, computation.propsWithTag[tag]); + } }); } }); @@ -165,8 +185,8 @@ function getTargetListFromTags(tags, computation){ const targetTagIdLists = []; if (!tags) return []; tags.forEach(tag => { - const idList = computation.propsWithTag[tag]; - if (idList) targetTagIdLists.push(idList); + const idList = computation.propsWithTag[tag] || []; + targetTagIdLists.push(idList); }); const targets = intersection(...targetTagIdLists); return targets; @@ -208,13 +228,14 @@ function linkRoll(dependencyGraph, prop){ } function linkVariableName(dependencyGraph, prop){ - // The variableName of the prop depends on the prop - if (prop.variableName){ + // The variableName of the prop depends on the prop if the prop is active + if (prop.variableName && !prop.inactive){ dependencyGraph.addLink(prop.variableName, prop._id, 'definition'); } } -function linkDamageMultiplier(dependencyGraph, prop){ +function linkDamageMultiplier(dependencyGraph, prop) { + if (prop.inactive) return; prop.damageTypes.forEach(damageType => { // Remove all non-letter characters from the damage name const damageName = damageType.replace(/[^a-z]/gi, '') @@ -222,8 +243,31 @@ function linkDamageMultiplier(dependencyGraph, prop){ }); } +function linkPointBuy(dependencyGraph, prop){ + dependOnCalc({ dependencyGraph, prop, key: 'min' }); + dependOnCalc({ dependencyGraph, prop, key: 'max' }); + dependOnCalc({ dependencyGraph, prop, key: 'cost' }); + dependOnCalc({ dependencyGraph, prop, key: 'total' }); + prop.values?.forEach(row => { + // Wrap the document in a new object so we don't bash it unintentionally + const pointBuyRow = { + ...row, + type: 'pointBuyRow', + tableName: prop.name, + tableId: prop._id, + } + dependencyGraph.addNode(row._id, pointBuyRow); + linkVariableName(dependencyGraph, pointBuyRow); + dependOnCalc({ dependencyGraph, pointBuyRow, key: 'row.min' }); + dependOnCalc({ dependencyGraph, pointBuyRow, key: 'row.max' }); + dependOnCalc({ dependencyGraph, pointBuyRow, key: 'row.cost' }); + }); + if (prop.inactive) return; +} + function linkProficiencies(dependencyGraph, prop){ // The stats depend on the proficiency + if (prop.inactive) return; prop.stats.forEach(statName => { if (!statName) return; dependencyGraph.addLink(statName, prop._id, prop.type); @@ -235,6 +279,10 @@ function linkSavingThrow(dependencyGraph, prop){ } function linkSkill(dependencyGraph, prop){ + // Depends on base value + dependOnCalc({ dependencyGraph, prop, key: 'baseValue' }); + // Link dependents + if (prop.inactive) return; linkVariableName(dependencyGraph, prop); // The prop depends on the variable references as the ability if (prop.ability){ @@ -242,9 +290,6 @@ function linkSkill(dependencyGraph, prop){ } // Skills depend on the creature's proficiencyBonus dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus'); - - // Depends on base value - dependOnCalc({dependencyGraph, prop, key: 'baseValue'}); } function linkSlot(dependencyGraph, prop){ diff --git a/app/imports/api/engine/computation/buildCreatureComputation.js b/app/imports/api/engine/computation/buildCreatureComputation.js index 8e81e314..a736354c 100644 --- a/app/imports/api/engine/computation/buildCreatureComputation.js +++ b/app/imports/api/engine/computation/buildCreatureComputation.js @@ -1,8 +1,7 @@ import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js'; -import CreatureProperties, - { DenormalisedOnlyCreaturePropertySchema as denormSchema } +import { DenormalisedOnlyCreaturePropertySchema as denormSchema } from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import { getProperties, getCreature, getVariables } from '/imports/api/engine/loadCreatures.js'; import computedOnlySchemas from '/imports/api/properties/computedOnlyPropertySchemasIndex.js'; import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex.js'; import linkInventory from './buildComputation/linkInventory.js'; @@ -13,7 +12,7 @@ import computeToggleDependencies from './buildComputation/computeToggleDependenc import linkCalculationDependencies from './buildComputation/linkCalculationDependencies.js'; import linkTypeDependencies from './buildComputation/linkTypeDependencies.js'; import computeSlotQuantityFilled from './buildComputation/computeSlotQuantityFilled.js'; -import CreatureComputation from './CreatureComputation.js'; +import CreatureComputation from './CreatureComputation.ts'; import removeSchemaFields from './buildComputation/removeSchemaFields.js'; /** @@ -32,29 +31,15 @@ import removeSchemaFields from './buildComputation/removeSchemaFields.js'; export default function buildCreatureComputation(creatureId){ const creature = getCreature(creatureId); + const variables = getVariables(creatureId); const properties = getProperties(creatureId); - const computation = buildComputationFromProps(properties, creature); + const computation = buildComputationFromProps(properties, creature, variables); return computation; } -function getProperties(creatureId){ - return CreatureProperties.find({ - 'ancestors.id': creatureId, - 'removed': {$ne: true}, - }, { - sort: {order: 1} - }).fetch(); -} +export function buildComputationFromProps(properties, creature, variables){ -function getCreature(creatureId){ - return Creatures.findOne(creatureId, { - denormalizedStats: 1, - }); -} - -export function buildComputationFromProps(properties, creature){ - - const computation = new CreatureComputation(properties); + const computation = new CreatureComputation(properties, creature, variables); // Dependency graph where edge(a, b) means a depends on b // The graph includes all dependencies even of inactive properties // such that any properties changing without changing their dependencies @@ -81,8 +66,10 @@ export function buildComputationFromProps(properties, creature){ // Process the properties one by one properties.forEach(prop => { + // The prop has been processed, it's no longer dirty + delete prop.dirty; - let computedSchema = computedOnlySchemas[prop.type]; + const computedSchema = computedOnlySchemas[prop.type]; removeSchemaFields([computedSchema, denormSchema], prop); // Add a place to store all the computation details @@ -102,6 +89,10 @@ export function buildComputationFromProps(properties, creature){ // Walk the property trees computing things that need to be inherited walkDown(forest, node => { computeInactiveStatus(node); + }); + // Inactive status must be complete for the whole tree before toggle deps + // are calculated + walkDown(forest, node => { computeToggleDependencies(node, dependencyGraph); computeSlotQuantityFilled(node, dependencyGraph); }); @@ -114,5 +105,6 @@ export function buildComputationFromProps(properties, creature){ linkTypeDependencies(dependencyGraph, prop, computation); linkCalculationDependencies(dependencyGraph, prop, computation); }); + return computation; } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType.js b/app/imports/api/engine/computation/computeComputation/computeByType.js index b6ccff38..7597b976 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType.js @@ -2,6 +2,7 @@ import _variable from './computeByType/computeVariable.js'; import action from './computeByType/computeAction.js'; import attribute from './computeByType/computeAttribute.js'; import skill from './computeByType/computeSkill.js'; +import pointBuy from './computeByType/computePointBuy.js'; import propertySlot from './computeByType/computeSlot.js'; import container from './computeByType/computeContainer.js'; import _calculation from './computeByType/computeCalculation.js'; @@ -13,6 +14,7 @@ export default Object.freeze({ attribute, container, skill, + pointBuy, propertySlot, spell: action, }); diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computePointBuy.js b/app/imports/api/engine/computation/computeComputation/computeByType/computePointBuy.js new file mode 100644 index 00000000..c2cc7606 --- /dev/null +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computePointBuy.js @@ -0,0 +1,53 @@ +import { has } from 'lodash'; +import evaluateCalculation from '../../utility/evaluateCalculation.js'; + +export default function computePointBuy(computation, node) { + const prop = node.data; + const tableMin = prop.min?.value || null; + const tableMax = prop.max?.value || null; + prop.spent = 0; + prop.values?.forEach(row => { + // Clean up added properties + // delete row.tableId; + // delete row.tableName; + // delete row.type; + + row.spent = 0; + if (row.value === undefined) return; + const min = has(row, 'min.value') ? row.min.value : tableMin; + const max = has(row, 'max.value') ? row.max.value : tableMax; + const costFunction = EJSON.clone(row.cost || prop.cost); + if (costFunction) costFunction.parseLevel = 'reduce'; + + // Check min and max + if (min !== null && row.value < min) { + row.value = min; + } + if (max !== null && row.value > max) { + row.value = max; + } + // Evaluate the cost function + if (!costFunction) return; + evaluateCalculation(costFunction, { ...computation.scope, value: row.value }); + // Write calculation errors + costFunction.errors?.forEach(error => { + if (error?.message) { + row.errors = row.errors || []; + error.message = 'Cost calculation error.\n' + error.message; + row.errors.push(error); + } + }); + if (Number.isFinite(costFunction.value)) { + row.spent = costFunction.value; + prop.spent += costFunction.value; + } + }); + prop.pointsLeft = (prop.total?.value || 0) - (prop.spent || 0); + if (prop.spent > prop.total?.value) { + prop.errors = prop.errors || []; + prop.errors.push({ + type: 'pointBuyError', + message: 'Spent more than total points available', + }); + } +} diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js index 2e79d18a..9243adb4 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js @@ -4,7 +4,7 @@ // by computeVariableAsSkill export default function computeSkill(computation, node){ const prop = node.data; - prop.proficiency = prop.baseProficiency; + prop.proficiency = prop.baseProficiency || 0; let profBonus = computation.scope['proficiencyBonus']?.value || 0; // Multiply the proficiency bonus by the actual proficiency if(prop.proficiency === 0.49){ diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDefinition.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDefinition.js index 66fbe79a..d665b284 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDefinition.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDefinition.js @@ -8,7 +8,13 @@ export default function aggregateDefinition({node, linkedNode, link}){ // get current defining prop const definingProp = node.data.definingProp; // Find the last defining prop - if (!definingProp || prop.order > definingProp.order){ + if ( + !definingProp || + prop.type !== 'pointBuyRow' && ( + definingProp.type === 'pointBuyRow' || + prop.order > definingProp.order + ) + ) { // override the current defining prop overrideProp(definingProp, node); // set this prop as the new defining prop @@ -18,9 +24,32 @@ export default function aggregateDefinition({node, linkedNode, link}){ } // Aggregate the base value due to the defining properties - const propBaseValue = prop.baseValue?.value; + let propBaseValue = prop.baseValue?.value; + // Point buy rows use prop.value instead of prop.baseValue + if (prop.type === 'pointBuyRow') { + propBaseValue = prop.value; + } if (propBaseValue === undefined) return; + // Store a summary of the definition as a base value effect + node.data.effects = node.data.effects || []; + if (prop.type === 'pointBuyRow') { + node.data.effects.push({ + _id: prop.tableId, + name: prop.tableName, + operation: 'base', + amount: { value: propBaseValue }, + type: 'pointBuy', + }); + } else { + node.data.effects.push({ + _id: prop._id, + name: prop.name, + operation: 'base', + amount: { value: propBaseValue }, + type: prop.type, + }); + } if (node.data.baseValue === undefined || propBaseValue > node.data.baseValue){ node.data.baseValue = propBaseValue; } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateEffect.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateEffect.js index 8cf47d36..3b1b987e 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateEffect.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateEffect.js @@ -1,3 +1,5 @@ +import { pick } from 'lodash'; + export default function aggregateEffect({node, linkedNode, link}){ if (link.data !== 'effect') return; // store the effect aggregator, its presence indicates that the variable is @@ -19,11 +21,23 @@ export default function aggregateEffect({node, linkedNode, link}){ // Store a summary of the effect itself node.data.effects = node.data.effects || []; + // Store either just + let effectAmount; + if (!linkedNode.data.amount) { + effectAmount = undefined; + } else if (typeof linkedNode.data.amount.value === 'string') { + effectAmount = pick(linkedNode.data.amount, [ + 'calculation', 'parseNode', 'parseError', 'value' + ]); + } else { + effectAmount = pick(linkedNode.data.amount, ['value']); + } node.data.effects.push({ _id: linkedNode.data._id, name: linkedNode.data.name, operation: linkedNode.data.operation, - amount: linkedNode.data.amount && {value: linkedNode.data.amount.value}, + amount: effectAmount, + type: linkedNode.data.type, // ancestors: linkedNode.data.ancestors, }); @@ -32,7 +46,7 @@ export default function aggregateEffect({node, linkedNode, link}){ // Get the result of the effect const result = linkedNode.data.amount?.value; // Skip aggregating if the result is not resolved completely - if (typeof result === 'string') return; + if (typeof result === 'string' || result === undefined) return; // Aggregate the effect based on its operation switch(linkedNode.data.operation){ case 'base': diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js index 61e081f8..13dcc7f2 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js @@ -7,19 +7,19 @@ import getAggregatorResult from './getAggregatorResult.js'; export default function computeImplicitVariable(node){ const prop = {}; - // Combine damage multipliers - if (node.data.immunity){ - prop.immunity = node.data.immunity; - prop.immunities = node.data.immunities; - } - if (node.data.resistance){ - prop.resistance = node.data.resistance; - prop.resistances = node.data.resistances; - } - if (node.data.vulnerability){ - prop.vulnerability = node.data.vulnerability; - prop.vulnerabilities = node.data.vulnerabilities; - } + // Combine damage multipliers + if (node.data.immunity){ + prop.immunity = node.data.immunity; + prop.immunities = node.data.immunities; + } + if (node.data.resistance){ + prop.resistance = node.data.resistance; + prop.resistances = node.data.resistances; + } + if (node.data.vulnerability){ + prop.vulnerability = node.data.vulnerability; + prop.vulnerabilities = node.data.vulnerabilities; + } const result = getAggregatorResult(node); if (result !== undefined){ 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 6b9c3222..01d34101 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js @@ -33,6 +33,9 @@ export default function computeVariableAsSkill(computation, node, prop){ const aggregator = node.data.effectAggregator; const aggregatorBase = aggregator?.base || 0; + // Store effects + prop.effects = node.data.effects; + // If there is no aggregator, determine if the prop can hide, then exit if (!aggregator){ prop.hide = statBase === undefined && @@ -84,7 +87,7 @@ function aggregateAbilityEffects({computation, skillNode, abilityNode}){ // to a skill from its ability if (link.data === 'effect'){ if (![ - 'advantage', 'disadvantage', 'passiveAdd', 'fail' + 'advantage', 'disadvantage', 'passiveAdd', 'fail', 'conditional' ].includes(linkedNode.data.operation)){ return; } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js index 3201009b..3a9f7316 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js @@ -29,7 +29,7 @@ export default function getAggregatorResult(node){ if (aggregator.set !== undefined) { result = aggregator.set; } - if (!node.definingProp?.decimal && Number.isFinite(result)){ + if (!node.data.definingProp?.decimal && Number.isFinite(result)){ result = Math.floor(result); } else if (Number.isFinite(result)){ result = stripFloatingPointOddities(result); diff --git a/app/imports/api/engine/computation/computeComputation/computeToggles.js b/app/imports/api/engine/computation/computeComputation/computeToggles.js index 3a49fe53..caa310d2 100644 --- a/app/imports/api/engine/computation/computeComputation/computeToggles.js +++ b/app/imports/api/engine/computation/computeComputation/computeToggles.js @@ -4,7 +4,7 @@ export default function evaluateToggles(computation, node){ let toggles = prop._computationDetails?.toggleAncestors; if (!toggles) return; toggles.forEach(toggle => { - if (prop.inactive || !toggle.condition) return; + if (!toggle.condition) return; if (!toggle.condition.value){ prop.inactive = true; prop.deactivatedByToggle = true; diff --git a/app/imports/api/engine/computation/computeCreatureComputation.js b/app/imports/api/engine/computation/computeCreatureComputation.js index be866e1b..7cd4b41c 100644 --- a/app/imports/api/engine/computation/computeCreatureComputation.js +++ b/app/imports/api/engine/computation/computeCreatureComputation.js @@ -51,17 +51,30 @@ function compute(computation, node){ function pushDependenciesToStack(nodeId, graph, stack, computation){ graph.forEachLinkedNode(nodeId, linkedNode => { - if (linkedNode._visitedChildren && !linkedNode._visited){ - const pather = path.nba(graph, { - oriented: true - }); - const loop = pather.find(nodeId, nodeId); - computation.errors.push({ - type: 'dependencyLoop', - details: { - nodes: loop.map(node => node.id) - }, - }); + if (linkedNode._visitedChildren && !linkedNode._visited) { + // This is a dependency loop, find a path from the node to itself + // and store that path as a dependency loop error + const pather = path.nba(graph, { oriented: true }); + let loop = []; + // Pather doesn't like going from a node to iteself, so find all the + // paths going from the next node back to the original node + // and return the shortest one + graph.forEachLinkedNode(nodeId, nextNode => { + const newLoop = pather.find(nextNode.id, nodeId); + if (!newLoop.length) return; + if (!loop.length || newLoop.length < loop.length - 1) { + loop = [linkedNode, ...newLoop]; + } + }, true); + + if (loop.length) { + computation.errors.push({ + type: 'dependencyLoop', + details: { + nodes: loop.map(node => node.id) + }, + }); + } } stack.push(linkedNode); }, true); diff --git a/app/imports/api/engine/computation/utility/getEffectivePropTags.js b/app/imports/api/engine/computation/utility/getEffectivePropTags.js new file mode 100644 index 00000000..f6fe47e6 --- /dev/null +++ b/app/imports/api/engine/computation/utility/getEffectivePropTags.js @@ -0,0 +1,19 @@ +export default function getEffectivePropTags(prop) { + if (!prop.tags) return []; + const tags = [...prop.tags]; + // Tags for the property type, separate #damage from #healing + if (prop.type === 'damage' && prop.damageType === 'healing') { + tags.push('#healing'); + } else { + tags.push(`#${prop.type}`); + } + + // Tags for some string properties + if (prop.variableName) tags.push(prop.variableName); + if (prop.damageType) tags.push(prop.damageType); + if (prop.skillType) tags.push(prop.skillType); + if (prop.actionType) tags.push(prop.actionType); + if (prop.attributeType) tags.push(prop.attributeType); + if (prop.reset) tags.push(prop.reset); + return tags; +} diff --git a/app/imports/api/engine/computation/utility/walkdown.js b/app/imports/api/engine/computation/utility/walkdown.js index e0c4728a..0efad425 100644 --- a/app/imports/api/engine/computation/utility/walkdown.js +++ b/app/imports/api/engine/computation/utility/walkdown.js @@ -2,7 +2,7 @@ export default function walkDown(tree, callback){ let stack = [...tree]; while(stack.length){ let node = stack.pop(); - callback(node); + callback(node, stack); stack.push(...node.children); } } diff --git a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js index cd0372cb..fcad024c 100644 --- a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js +++ b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js @@ -21,6 +21,7 @@ export default function writeAlteredProperties(computation){ 'deactivatedByAncestor', 'deactivatedByToggle', 'damage', + 'dirty', ...schema.objectKeys(), ]; op = addChangedKeysToOp(op, keys, original, changed); @@ -28,13 +29,14 @@ export default function writeAlteredProperties(computation){ bulkWriteOperations.push(op); } }); - writePropertiesSequentially(bulkWriteOperations); + bulkWriteProperties(bulkWriteOperations); + //if (bulkWriteOperations.length) console.log(`Wrote ${bulkWriteOperations.length} props`); } 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){ + for (let key of keys) { if (!EJSON.equals(original[key], changed[key])){ if (!op) op = newOperation(original._id, changed.type); let value = changed[key]; @@ -79,10 +81,10 @@ function addUnsetOp(op, key){ } } -// We use this instead of bulkWriteProperties because it functions with latency -// compensation without needing to roll back changes, which causes multiple -// expensive redraws of the character sheet -function writePropertiesSequentially(bulkWriteOps){ +// If we re-enable client-side sheet recalculation, this needs to be run on +// both client and server to preserve latency compensation. Bulkwrite breaks +// latency compensation and causes flickering +function writePropertiesSequentially(bulkWriteOps) { bulkWriteOps.forEach(op => { let updateOneOrMany = op.updateOne || op.updateMany; CreatureProperties.update(updateOneOrMany.filter, updateOneOrMany.update, { @@ -101,7 +103,7 @@ function writePropertiesSequentially(bulkWriteOps){ function bulkWriteProperties(bulkWriteOps){ if (!bulkWriteOps.length) return; // bulkWrite is only available on the server - if (Meteor.isServer){ + if (Meteor.isServer) { CreatureProperties.rawCollection().bulkWrite( bulkWriteOps, {ordered : false}, diff --git a/app/imports/api/engine/computation/writeComputation/writeScope.js b/app/imports/api/engine/computation/writeComputation/writeScope.js index 3661df4a..d96c2bfe 100644 --- a/app/imports/api/engine/computation/writeComputation/writeScope.js +++ b/app/imports/api/engine/computation/writeComputation/writeScope.js @@ -1,10 +1,61 @@ +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import { EJSON } from 'meteor/ejson'; + +export default function writeScope(creatureId, computation) { + if (!creatureId) throw 'creatureId is required'; + const scope = computation.scope; + let variables = computation.variables; + if (!variables) { + CreatureVariables.insert({ _creatureId: creatureId }); + variables = {}; + } + delete variables._id; + delete variables._creatureId; + + let $set, $unset; -export default function writeScope(creatureId, scope){ - // Remove large properties that aren't likely to be accessed for (const key in scope){ + // 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 = {}; + /* Log detailed diffs + const diff = omitBy(variables[key], (v, k) => EJSON.equals(scope[key][k], v)); + for (let subkey in diff) { + console.log(`${key}.${subkey}: ${variables[key][subkey]} => ${scope[key][subkey]}`) + } + */ + // Set the changed key in the creature variables + $set[key] = scope[key]; + } + } + + // Remove all the keys that no longer exist in scope + for (const key in variables) { + if (!scope[key]) { + if (!$unset) $unset = {}; + $unset[key] = 1; + } + } + + if ($set || $unset) { + const update = {}; + if ($set) update.$set = $set; + if ($unset) update.$unset = $unset; + CreatureVariables.update({_creatureId: creatureId}, update); + } + if (computation.creature?.dirty) { + Creatures.update({_id: creatureId}, {$unset: { dirty: 1 }}); } - Creatures.update(creatureId, {$set: {variables: scope}}); } diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.js index a45188bf..d0a3d469 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.js @@ -6,33 +6,32 @@ import writeErrors from './computation/writeComputation/writeErrors.js'; export default function computeCreature(creatureId){ if (Meteor.isClient) return; + // console.log('compute ' + creatureId); const computation = buildCreatureComputation(creatureId); + computeComputation(computation, creatureId); +} + +function computeComputation(computation, creatureId) { try { computeCreatureComputation(computation); writeAlteredProperties(computation); - writeScope(creatureId, computation.scope); + writeScope(creatureId, computation); } catch (e){ const errorText = e.reason || e.message || e.toString(); computation.errors.push({ type: 'crash', - details: {error: errorText}, + details: { error: errorText }, }); const logError = { creatureId, computeError: errorText, }; - if (e.stack){ + if (e.stack) { logError.location = e.stack.split('\n')[1]; } console.error(logError); + throw e; } finally { writeErrors(creatureId, computation.errors); } } - -// For now just recompute the whole creature, TODO only recompute a single -// connected section of the depdendency graph -export function computeCreatureDependencyGroup(property){ - let creatureId = property.ancestors[0].id; - computeCreature(creatureId); -} diff --git a/app/imports/api/engine/loadCreatures.js b/app/imports/api/engine/loadCreatures.js new file mode 100644 index 00000000..aa036a37 --- /dev/null +++ b/app/imports/api/engine/loadCreatures.js @@ -0,0 +1,303 @@ +import { debounce } from 'lodash'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import computeCreature from './computeCreature'; + +const COMPUTE_DEBOUNCE_TIME = 100; // ms +export const loadedCreatures = new Map(); // creatureId => {creature, properties, etc.} + +export function loadCreature(creatureId, subscription) { + if (!creatureId) throw 'creatureId is required'; + let creature = loadedCreatures.get(creatureId); + if (loadedCreatures.has(creatureId)) { + creature.subs.add(subscription); + } else { + creature = new LoadedCreature(subscription, creatureId); + loadedCreatures.set(creatureId, creature); + } + subscription.onStop(() => { + unloadCreature(creatureId, subscription); + }); +} + +function unloadCreature(creatureId, subscription) { + if (!creatureId) throw 'creatureId is required'; + const creature = loadedCreatures.get(creatureId); + if (!creature) return; + creature.subs.delete(subscription); + if (creature.subs.size === 0) { + creature.stop(); + loadedCreatures.delete(creatureId); + } +} + +export function getSingleProperty(creatureId, propertyId) { + if (loadedCreatures.has(creatureId)) { + const creature = loadedCreatures.get(creatureId); + const property = creature.properties.get(propertyId); + const cloneProp = EJSON.clone(property); + return cloneProp; + } + // console.time(`Cache miss on creature properties: ${creatureId}`) + const prop = CreatureProperties.findOne({ + _id: propertyId, + 'ancestors.id': creatureId, + 'removed': {$ne: true}, + }, { + sort: { order: 1 }, + }); + // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); + return prop; +} + +export function getProperties(creatureId) { + if (loadedCreatures.has(creatureId)) { + const creature = loadedCreatures.get(creatureId); + const props = Array.from(creature.properties.values()); + const cloneProps = EJSON.clone(props); + return cloneProps + } + // console.time(`Cache miss on creature properties: ${creatureId}`) + const props = CreatureProperties.find({ + 'ancestors.id': creatureId, + 'removed': {$ne: true}, + }, { + sort: { order: 1 }, + }).fetch(); + // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); + return props; +} + +export function getPropertiesOfType(creatureId, propType) { + if (loadedCreatures.has(creatureId)) { + const creature = loadedCreatures.get(creatureId); + const props = [] + for (const prop of creature.properties.values()){ + if (prop.type === propType) { + props.push(prop); + } + } + const cloneProps = EJSON.clone(props); + return cloneProps + } + // console.time(`Cache miss on creature properties: ${creatureId}`) + const props = CreatureProperties.find({ + 'ancestors.id': creatureId, + 'removed': { $ne: true }, + 'type': propType, + }, { + sort: { order: 1 }, + }).fetch(); + // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); + return props; +} + +export function getCreature(creatureId) { + if (loadedCreatures.has(creatureId)) { + const loadedCreature = loadedCreatures.get(creatureId); + const creature = loadedCreature.creature; + if (creature) { + const cloneCreature = EJSON.clone(creature); + return cloneCreature; + } + } + // console.time(`Cache miss on Creature: ${creatureId}`); + const creature = Creatures.findOne(creatureId); + // console.timeEnd(`Cache miss on Creature: ${creatureId}`); + return creature; +} + +export function getVariables(creatureId) { + if (loadedCreatures.has(creatureId)) { + const loadedCreature = loadedCreatures.get(creatureId); + const variables = loadedCreature.variables; + if (variables) { + const cloneVarables = EJSON.clone(variables); + return cloneVarables; + } + } + // console.time(`Cache miss on variables: ${creatureId}`); + const variables = CreatureVariables.findOne({_creatureId: creatureId}); + // console.timeEnd(`Cache miss on variables: ${creatureId}`); + return variables; +} + +export function getProperyAncestors(creatureId, propertyId) { + const prop = getSingleProperty(creatureId, propertyId); + if (!prop) return []; + const ancestorIds = []; + prop.ancestors.forEach(ref => { + if (ref.collection === 'creatureProperties') { + ancestorIds.push(ref.id); + } + }); + if (loadedCreatures.has(creatureId)) { + // Get the ancestor properties from the cache + const creature = loadedCreatures.get(creatureId); + const props = []; + ancestorIds.forEach(id => { + const prop = creature.properties.get(id); + if (prop) { + props.push(prop); + } + }); + const cloneProps = EJSON.clone(props); + return cloneProps + } else { + // Fetch from database + return CreatureProperties.find({ + _id: { $in: ancestorIds }, + removed: {$ne: true}, + }, { + sort: { order: 1 }, + }).fetch(); + } +} + +export function getPropertyDecendants(creatureId, propertyId) { + const property = getSingleProperty(creatureId, propertyId); + if (!property) return []; + // This prop will always appear at the same position in the ancestor array + // of its decendants, so only check there + const expectedAncestorPostition = property.ancestors.length; + if (loadedCreatures.has(creatureId)) { + const creature = loadedCreatures.get(creatureId); + const props = []; + for(const prop of creature.properties.values()){ + if (prop.ancestors[expectedAncestorPostition]?.id === propertyId) { + props.push(prop); + } + } + const cloneProps = EJSON.clone(props); + return cloneProps + } else { + return CreatureProperties.find({ + 'ancestors.id': propertyId, + removed: { $ne: true }, + }, { + sort: { order: 1 }, + }).fetch(); + } +} + +class LoadedCreature { + constructor(sub, creatureId) { + // This may be called from a subscription, but we don't want the observers + // to be destroyed with it, so use a non-reactive context to observe + // the required documents + const self = this; + Tracker.nonreactive(() => { + self.subs = new Set([sub]); + + const compute = debounce(Meteor.bindEnvironment(() => { + computeCreature(creatureId); + }), COMPUTE_DEBOUNCE_TIME); + + self.properties = new Map(); + // Observe all creature properties which are needed for computation + self.propertyObserver = CreatureProperties.find({ + 'ancestors.id': creatureId, + removed: { $ne: true }, + }, { + sort: { order: 1 }, + }).observeChanges({ + added(id, fields) { + fields._id = id; + self.addProperty(fields); + if (fields.dirty) compute(); + }, + changed(id, fields) { + self.changeProperty(id, fields); + if (fields.dirty) compute(); + }, + removed(id) { + self.removeProperty(id); + compute(); + }, + }); + + // Observe the creature itself + self.creatureObserver = Creatures.find({ + _id: creatureId, + }).observeChanges({ + added(id, fields) { + fields._id = id; + self.addCreature(fields) + if (fields.dirty) compute(); + }, + changed(id, fields) { + self.changeCreature(id, fields); + if (fields.dirty) compute(); + }, + removed(id) { + self.removeCreature(id); + }, + }); + + // Observe the creature's variables + self.variablesObserver = CreatureVariables.find({ + _creatureId: creatureId, + }, { + fields: { _creatureId: 0}, + }).observeChanges({ + added(id, fields) { + fields._id = id; + self.addVariables(fields) + }, + changed(id, fields) { + self.changeVariables(id, fields); + }, + removed(id) { + self.removeVariables(id); + }, + }); + }); + } + stop() { + this.propertyObserver.stop(); + this.creatureObserver.stop(); + this.variablesObserver.stop(); + } + addProperty(prop) { + this.properties.set(prop._id, prop); + } + changeProperty(id, fields) { + LoadedCreature.changeMap(id, fields, this.properties); + } + removeProperty(id) { + this.properties.delete(id) + } + addCreature(creature) { + this.creature = creature; + } + changeCreature(id, fields) { + LoadedCreature.changeDoc(this.creature, fields); + } + removeCreature() { + delete this.creature; + } + addVariables(variables) { + this.variables = variables; + } + changeVariables(id, fields) { + LoadedCreature.changeDoc(this.variables, fields); + } + removeVariables() { + delete this.variables; + } + static changeMap(id, fields, map) { + const doc = map.get(id); + LoadedCreature.changeDoc(doc, fields); + } + static changeDoc(doc, fields) { + if (!doc) return; + for (let key in fields) { + if (key === undefined) { + delete doc[key]; + } else { + doc[key] = fields[key]; + } + } + } +} diff --git a/app/imports/api/files/UserImages.js b/app/imports/api/files/UserImages.js index ead7271b..fad4576b 100644 --- a/app/imports/api/files/UserImages.js +++ b/app/imports/api/files/UserImages.js @@ -9,9 +9,10 @@ const UserImages = createS3FilesCollection({ return 'Please upload with size equal or less than 10MB'; } // Allow common image extensions - if (/gif|png|jpe?g|webp/i.test(file.extension || '')) { + if (!/gif|png|jpe?g|webp/i.test(file.extension || '')) { return 'Please upload an image file only'; } + return true } }); diff --git a/app/imports/api/files/s3FileStorage.js b/app/imports/api/files/s3FileStorage.js index e26e6ac5..3c3c540e 100644 --- a/app/imports/api/files/s3FileStorage.js +++ b/app/imports/api/files/s3FileStorage.js @@ -1,5 +1,4 @@ // https://github.com/VeliovGroup/Meteor-Files/blob/master/docs/aws-s3-integration.md - import { Meteor } from 'meteor/meteor'; import { each, clone } from 'lodash'; import { Random } from 'meteor/random'; @@ -37,8 +36,9 @@ if (Meteor.isServer && Meteor.settings.useS3) { secretAccessKey: s3Conf.secret, endpoint: s3Conf.endpoint, sslEnabled: true, // optional + maxRetries: 10, httpOptions: { - timeout: 6000, + timeout: 12000, agent: false } }); @@ -47,14 +47,18 @@ if (Meteor.isServer && Meteor.settings.useS3) { collectionName, storagePath, onBeforeUpload, - debug = Meteor.isProduction, + onAfterUpload, + debug = !Meteor.isProduction, allowClientCode = false, }){ const collection = new FilesCollection({ collectionName, storagePath, onBeforeUpload, - onAfterUpload(fileRef){ + onAfterUpload(fileRef) { + // Call the provided afterUpload hook first + onAfterUpload?.(fileRef); + // Start moving files to AWS:S3 // after fully received by the Meteor server @@ -217,18 +221,20 @@ if (Meteor.isServer && Meteor.settings.useS3) { collectionName, storagePath, onBeforeUpload, - debug = Meteor.isProduction, + onAfterUpload, + debug = !Meteor.isProduction, allowClientCode = false, }){ const collection = new FilesCollection({ collectionName, storagePath, onBeforeUpload, + onAfterUpload, debug, allowClientCode, }); - if (Meteor.isServer){ + if (Meteor.isServer) { // Use the normal file system to read files collection.readJSONFile = async function(file){ const fileString = await fsp.readFile(file.path, 'utf8'); diff --git a/app/imports/api/icons/Icons.js b/app/imports/api/icons/Icons.js index 286a4bee..072d2467 100644 --- a/app/imports/api/icons/Icons.js +++ b/app/imports/api/icons/Icons.js @@ -7,17 +7,17 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let Icons = new Mongo.Collection('icons'); let iconsSchema = new SimpleSchema({ - name: { - type: String, + name: { + type: String, unique: true, max: STORAGE_LIMITS.name, index: 1, - }, - description: { - type: String, - optional: true, + }, + description: { + type: String, + optional: true, max: STORAGE_LIMITS.description, - }, + }, tags: { type: Array, optional: true, @@ -38,7 +38,7 @@ if (Meteor.isServer) { Icons._ensureIndex({ 'name': 'text', 'description': 'text', - 'tags': 'text', + 'tags': 'text', }); } @@ -55,15 +55,15 @@ Icons.attachSchema(iconsSchema); // This method does not validate icons against the schema, use wisely; const writeIcons = new ValidatedMethod({ - name: 'icons.write', - validate: null, - run(icons){ + name: 'icons.write', + validate: null, + run(icons) { assertAdmin(this.userId); - if (Meteor.isServer){ + if (Meteor.isServer) { this.unblock(); - Icons.rawCollection().insert(icons, {ordered: false}); + Icons.rawCollection().insert(icons, { ordered: false }); } - } + } }); const findIcons = new ValidatedMethod({ @@ -80,11 +80,11 @@ const findIcons = new ValidatedMethod({ numRequests: 20, timeInterval: 10000, }, - run({search}){ + run({ search }) { if (!search) return []; if (!Meteor.isServer) return; return Icons.find( - { $text: {$search: search} }, + { $text: { $search: search } }, { // relevant documents have a higher score. fields: { diff --git a/app/imports/api/library/Libraries.js b/app/imports/api/library/Libraries.js index 358489ea..696f0001 100644 --- a/app/imports/api/library/Libraries.js +++ b/app/imports/api/library/Libraries.js @@ -20,10 +20,15 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let Libraries = new Mongo.Collection('libraries'); let LibrarySchema = new SimpleSchema({ - name: { + name: { type: String, max: STORAGE_LIMITS.name, }, + description: { + type: String, + optional: true, + max: STORAGE_LIMITS.summary, + }, }); LibrarySchema.extend(SharingSchema); @@ -34,72 +39,95 @@ export default Libraries; const insertLibrary = new ValidatedMethod({ name: 'libraries.insert', - mixins: [ - simpleSchemaMixin, + mixins: [ + simpleSchemaMixin, ], schema: LibrarySchema.omit('owner'), run(library) { if (!this.userId) { throw new Meteor.Error('Libraries.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('Libraries.methods.insert.denied', - `The ${tier.name} tier does not allow you to insert a library`); + `The ${tier.name} tier does not allow you to insert a library`); } library.owner = this.userId; - return Libraries.insert(library); + return Libraries.insert(library); }, }); const updateLibraryName = new ValidatedMethod({ - name: 'libraries.updateName', - validate: new SimpleSchema({ - _id: { - type: String, - regEx: SimpleSchema.RegEx.id - }, - name: { - type: String, - }, - }).validator(), + name: 'libraries.updateName', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.id + }, + name: { + type: String, + }, + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, name}){ - let library = Libraries.findOne(_id); - assertEditPermission(library, this.userId); - Libraries.update(_id, {$set: {name}}); - }, + run({ _id, name }) { + let library = Libraries.findOne(_id); + assertEditPermission(library, this.userId); + Libraries.update(_id, { $set: { name } }); + }, +}); + +const updateLibraryDescription = new ValidatedMethod({ + name: 'libraries.updateDescription', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.id + }, + description: { + type: String, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id, description }) { + let library = Libraries.findOne(_id); + assertEditPermission(library, this.userId); + Libraries.update(_id, { $set: { description } }); + }, }); const removeLibrary = new ValidatedMethod({ - name: 'libraries.remove', - validate: new SimpleSchema({ - _id: { - type: String, - regEx: SimpleSchema.RegEx.id - }, - }).validator(), + name: 'libraries.remove', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.id + }, + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}){ - let library = Libraries.findOne(_id); - assertOwnership(library, this.userId); + run({ _id }) { + let library = Libraries.findOne(_id); + assertOwnership(library, this.userId); this.unblock(); removeLibaryWork(_id) - } + } }); -export function removeLibaryWork(libraryId){ +export function removeLibaryWork(libraryId) { Libraries.remove(libraryId); - LibraryNodes.remove({'ancestors.id': libraryId}); + LibraryNodes.remove({ 'ancestors.id': libraryId }); } -export { LibrarySchema, insertLibrary, updateLibraryName, removeLibrary }; +export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, removeLibrary }; diff --git a/app/imports/api/library/LibraryCollections.js b/app/imports/api/library/LibraryCollections.js new file mode 100644 index 00000000..aec4d351 --- /dev/null +++ b/app/imports/api/library/LibraryCollections.js @@ -0,0 +1,135 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import SimpleSchema from 'simpl-schema'; +import SharingSchema from '/imports/api/sharing/SharingSchema.js'; +import simpleSchemaMixin from '/imports/api/creature/mixins/simpleSchemaMixin.js'; +import { assertEditPermission, assertOwnership } from '/imports/api/sharing/sharingPermissions.js'; +import { getUserTier } from '/imports/api/users/patreon/tiers.js' +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; + +/** + * LibraryCollections are groups of libraries that are subscribed together at once + */ +const LibraryCollections = new Mongo.Collection('libraryCollections'); + +const LibraryCollectionSchema = new SimpleSchema({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + description: { + type: String, + optional: true, + max: STORAGE_LIMITS.summary, + }, + libraries: { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.libraryCollectionCount, + }, + 'libraries.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, +}); + +LibraryCollectionSchema.extend(SharingSchema); +LibraryCollections.attachSchema(LibraryCollectionSchema); + +export default LibraryCollections; + +const insertLibraryCollection = new ValidatedMethod({ + name: 'libraryCollections.insert', + mixins: [ + simpleSchemaMixin, + ], + schema: LibraryCollectionSchema.omit('owner'), + run(libraryCollection) { + if (!this.userId) { + throw new Meteor.Error('LibraryCollections.methods.insert.denied', + 'You need to be logged in to insert a library'); + } + let tier = getUserTier(this.userId); + if (!tier.paidBenefits){ + throw new Meteor.Error('LibraryCollections.methods.insert.denied', + `The ${tier.name} tier does not allow you to insert a library collection`); + } + libraryCollection.owner = this.userId; + return LibraryCollections.insert(libraryCollection); + }, +}); + +const updateLibraryCollection = new ValidatedMethod({ + name: 'libraryCollections.update', + mixins: [ + simpleSchemaMixin, + ], + schema: { + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + update: { + type: LibraryCollectionSchema + .pick('name', 'description', 'libraries') + .extend({ //make libraries optional + libraries: { + optional: true, + defaultValue: undefined, + }, + }), + } + }, + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({_id, update}){ + const libraryCollection = LibraryCollections.findOne(_id, { + fields: { + owner: 1, + writers: 1, + } + }); + assertEditPermission(libraryCollection, this.userId); + return LibraryCollections.update(_id, {$set: update}); + }, +}); + +const removeLibraryCollection = new ValidatedMethod({ + name: 'libraryCollections.remove', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.id + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({_id}){ + const libraryCollection = LibraryCollections.findOne(_id, { + fields: { + owner: 1, + } + }); + assertOwnership(libraryCollection, this.userId); + return LibraryCollections.remove(_id); + } +}); + +function getLibraryIdsByCollectionId(libraryCollectionId) { + const libraryCollection = LibraryCollections.findOne(libraryCollectionId) + return libraryCollection?.libraries || []; +} + +export { + LibraryCollectionSchema, + insertLibraryCollection, + updateLibraryCollection, + removeLibraryCollection, + getLibraryIdsByCollectionId, +}; diff --git a/app/imports/api/library/LibraryNodes.js b/app/imports/api/library/LibraryNodes.js index 283a8a68..50d97cd3 100644 --- a/app/imports/api/library/LibraryNodes.js +++ b/app/imports/api/library/LibraryNodes.js @@ -23,28 +23,28 @@ let LibraryNodeSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, - type: { + type: { type: String, allowedValues: Object.keys(propertySchemasIndex), }, - tags: { - type: Array, - defaultValue: [], + tags: { + type: Array, + defaultValue: [], maxCount: STORAGE_LIMITS.tagCount, - }, - 'tags.$': { - type: String, + }, + 'tags.$': { + type: String, max: STORAGE_LIMITS.tagLength, - }, + }, libraryTags: { - type: Array, - defaultValue: [], + type: Array, + defaultValue: [], maxCount: STORAGE_LIMITS.tagCount, - }, - 'libraryTags.$': { - type: String, + }, + 'libraryTags.$': { + type: String, max: STORAGE_LIMITS.tagLength, - }, + }, icon: { type: storedIconsSchema, optional: true, @@ -56,37 +56,37 @@ let LibraryNodeSchema = new SimpleSchema({ if (Meteor.isServer) { LibraryNodes._ensureIndex({ 'name': 'text', - 'tags': 'text', + 'tags': 'text', }); } -for (let key in propertySchemasIndex){ - let schema = new SimpleSchema({}); - schema.extend(LibraryNodeSchema); +for (let key in propertySchemasIndex) { + let schema = new SimpleSchema({}); + schema.extend(LibraryNodeSchema); schema.extend(ColorSchema); - schema.extend(propertySchemasIndex[key]); - schema.extend(ChildSchema); - schema.extend(SoftRemovableSchema); - LibraryNodes.attachSchema(schema, { - selector: {type: key} - }); + schema.extend(propertySchemasIndex[key]); + schema.extend(ChildSchema); + schema.extend(SoftRemovableSchema); + LibraryNodes.attachSchema(schema, { + selector: { type: key } + }); } -function getLibrary(node){ +function getLibrary(node) { if (!node) throw new Meteor.Error('No node provided'); let library = Libraries.findOne(node.ancestors[0].id); if (!library) throw new Meteor.Error('Library does not exist'); return library; } -function assertNodeEditPermission(node, userId){ +function assertNodeEditPermission(node, userId) { let lib = getLibrary(node); return assertEditPermission(lib, userId); } const insertNode = new ValidatedMethod({ name: 'libraryNodes.insert', - validate: null, + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, @@ -95,8 +95,8 @@ const insertNode = new ValidatedMethod({ run(libraryNode) { delete libraryNode._id; assertNodeEditPermission(libraryNode, this.userId); - let nodeId = LibraryNodes.insert(libraryNode); - if (libraryNode.type == 'reference'){ + let nodeId = LibraryNodes.insert(libraryNode); + if (libraryNode.type == 'reference') { libraryNode._id = nodeId; updateReferenceNodeWork(libraryNode, this.userId); } @@ -106,37 +106,37 @@ const insertNode = new ValidatedMethod({ const updateLibraryNode = new ValidatedMethod({ name: 'libraryNodes.update', - validate({_id, path}){ - if (!_id) return false; - // We cannot change these fields with a simple update - switch (path[0]){ - case 'type': + validate({ _id, path }) { + if (!_id) return false; + // We cannot change these fields with a simple update + switch (path[0]) { + case 'type': case 'order': case 'parent': case 'ancestors': - return false; - } + return false; + } }, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, value}) { + run({ _id, path, value }) { let node = LibraryNodes.findOne(_id); assertNodeEditPermission(node, this.userId); let pathString = path.join('.'); let modifier; // unset empty values - if (value === null || value === undefined){ - modifier = {$unset: {[pathString]: 1}}; + if (value === null || value === undefined) { + modifier = { $unset: { [pathString]: 1 } }; } else { - modifier = {$set: {[pathString]: value}}; + modifier = { $set: { [pathString]: value } }; } - let numUpdated = LibraryNodes.update(_id, modifier, { - selector: {type: node.type}, - }); - if (node.type == 'reference'){ + let numUpdated = LibraryNodes.update(_id, modifier, { + selector: { type: node.type }, + }); + if (node.type == 'reference') { node = LibraryNodes.findOne(_id); updateReferenceNodeWork(node, this.userId); } @@ -145,87 +145,87 @@ const updateLibraryNode = new ValidatedMethod({ }); const pushToLibraryNode = new ValidatedMethod({ - name: 'libraryNodes.push', - validate: null, + name: 'libraryNodes.push', + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, value}){ - let node = LibraryNodes.findOne(_id); + run({ _id, path, value }) { + let node = LibraryNodes.findOne(_id); assertNodeEditPermission(node, this.userId); - return LibraryNodes.update(_id, { - $push: {[path.join('.')]: value}, - }, { - selector: {type: node.type}, - }); - } + return LibraryNodes.update(_id, { + $push: { [path.join('.')]: value }, + }, { + selector: { type: node.type }, + }); + } }); const pullFromLibraryNode = new ValidatedMethod({ - name: 'libraryNodes.pull', - validate: null, + name: 'libraryNodes.pull', + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id, path, itemId}){ - let node = LibraryNodes.findOne(_id); + run({ _id, path, itemId }) { + let node = LibraryNodes.findOne(_id); assertNodeEditPermission(node, this.userId); - return LibraryNodes.update(_id, { - $pull: {[path.join('.')]: {_id: itemId}}, - }, { - selector: {type: node.type}, - getAutoValues: false, - }); - } + return LibraryNodes.update(_id, { + $pull: { [path.join('.')]: { _id: itemId } }, + }, { + selector: { type: node.type }, + getAutoValues: false, + }); + } }); const softRemoveLibraryNode = new ValidatedMethod({ - name: 'libraryNodes.softRemove', - validate: new SimpleSchema({ - _id: SimpleSchema.RegEx.Id - }).validator(), + name: 'libraryNodes.softRemove', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}){ - let node = LibraryNodes.findOne(_id); + run({ _id }) { + let node = LibraryNodes.findOne(_id); assertNodeEditPermission(node, this.userId); - softRemove({_id, collection: LibraryNodes}); - } + softRemove({ _id, collection: LibraryNodes }); + } }); const restoreLibraryNode = new ValidatedMethod({ - name: 'libraryNodes.restore', - validate: new SimpleSchema({ - _id: SimpleSchema.RegEx.Id - }).validator(), + name: 'libraryNodes.restore', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({_id}){ + run({ _id }) { // Permissions let node = LibraryNodes.findOne(_id); assertNodeEditPermission(node, this.userId); // Do work - restore({_id, collection: LibraryNodes}); - } + restore({ _id, collection: LibraryNodes }); + } }); export default LibraryNodes; export { - LibraryNodeSchema, - insertNode, - updateLibraryNode, - pullFromLibraryNode, - pushToLibraryNode, - softRemoveLibraryNode, + LibraryNodeSchema, + insertNode, + updateLibraryNode, + pullFromLibraryNode, + pushToLibraryNode, + softRemoveLibraryNode, restoreLibraryNode, }; diff --git a/app/imports/api/library/getCreatureLibraryIds.js b/app/imports/api/library/getCreatureLibraryIds.js new file mode 100644 index 00000000..beb9c7d3 --- /dev/null +++ b/app/imports/api/library/getCreatureLibraryIds.js @@ -0,0 +1,39 @@ +import LibraryCollections from '/imports/api/library/LibraryCollections.js'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import getUserLibraryIds from './getUserLibraryIds'; +import { intersection, union } from 'lodash'; + +export default function getCreatureLibraryIds(creature, userId) { + if (!userId) return []; + + // Get the ids of libraries the user is permitted to view + const userLibIds = getUserLibraryIds(userId); + + // If given a creature Id, get the creature document + if (typeof creature === 'string') { + creature = Creatures.findOne(creature, { + fields: { + allowedLibraries: 1, + allowedLibraryCollections: 1, + } + }); + if (!creature) return []; + } + + // 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 || []; + LibraryCollections.find({ + _id: { $in: allowedCollections } + }, { fields: { libraries: 1 } }).forEach(collection => { + creatureLibIds = union(creatureLibIds, collection.libraries); + }); + + // return all the ids that the creature allows and the user can view + return intersection(userLibIds, creatureLibIds); +} \ No newline at end of file diff --git a/app/imports/api/library/getUserLibraryIds.js b/app/imports/api/library/getUserLibraryIds.js new file mode 100644 index 00000000..55f7498d --- /dev/null +++ b/app/imports/api/library/getUserLibraryIds.js @@ -0,0 +1,31 @@ +import LibraryCollections from '/imports/api/library/LibraryCollections.js'; +import Libraries from '/imports/api/library/Libraries.js'; +import { union } from 'lodash'; + +export default function getUserLibraryIds(userId) { + if (!userId) return []; + const user = Meteor.users.findOne(userId); + let subbedIds = user?.subscribedLibraries || []; + const subCollections = user?.subscribedLibraryCollections || []; + LibraryCollections.find({ + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: subCollections }, public: true }, + ] + }, { fields: { libraries: 1 } }).forEach(collection => { + subbedIds = union(subbedIds, collection.libraries); + }); + const libraryIds = Libraries.find({ + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: subbedIds }, public: true }, + ] + }, { + fields: { _id: 1 } + }).map(lib => lib._id); + return libraryIds; +} \ No newline at end of file diff --git a/app/imports/api/library/methods/duplicateLibraryNode.js b/app/imports/api/library/methods/duplicateLibraryNode.js index 4da84997..107f3c6b 100644 --- a/app/imports/api/library/methods/duplicateLibraryNode.js +++ b/app/imports/api/library/methods/duplicateLibraryNode.js @@ -4,13 +4,13 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { - setLineageOfDocs, - renewDocIds + setLineageOfDocs, + renewDocIds } from '/imports/api/parenting/parenting.js'; import { reorderDocs } from '/imports/api/parenting/order.js'; var snackbar; -if (Meteor.isClient){ +if (Meteor.isClient) { snackbar = require( '/imports/ui/components/snackbars/SnackbarQueue.js' ).snackbar @@ -20,7 +20,7 @@ const DUPLICATE_CHILDREN_LIMIT = 50; const duplicateLibraryNode = new ValidatedMethod({ name: 'libraryNodes.duplicate', - validate: new SimpleSchema({ + validate: new SimpleSchema({ _id: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -31,7 +31,7 @@ const duplicateLibraryNode = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({_id}) { + run({ _id }) { let libraryNode = LibraryNodes.findOne(_id); assertDocEditPermission(libraryNode, this.userId); @@ -40,16 +40,16 @@ const duplicateLibraryNode = new ValidatedMethod({ libraryNode._id = libraryNodeId; let nodes = LibraryNodes.find({ - 'ancestors.id': _id, - removed: {$ne: true}, - }, { + 'ancestors.id': _id, + removed: { $ne: true }, + }, { limit: DUPLICATE_CHILDREN_LIMIT + 1, - sort: {order: 1}, + sort: { order: 1 }, }).fetch(); - if (nodes.length > DUPLICATE_CHILDREN_LIMIT){ + if (nodes.length > DUPLICATE_CHILDREN_LIMIT) { nodes.pop(); - if (Meteor.isClient){ + if (Meteor.isClient) { snackbar({ text: `Only the first ${DUPLICATE_CHILDREN_LIMIT} children were duplicated`, }); @@ -58,21 +58,21 @@ const duplicateLibraryNode = new ValidatedMethod({ // re-map all the ancestors setLineageOfDocs({ - docArray: nodes, - newAncestry : [ + docArray: nodes, + newAncestry: [ ...libraryNode.ancestors, - {id: libraryNodeId, collection: 'libraryNodes'} + { id: libraryNodeId, collection: 'libraryNodes' } ], - oldParent : {id: _id, collection: 'libraryNodes'}, - }); + oldParent: { id: _id, collection: 'libraryNodes' }, + }); // Give the docs new IDs without breaking internal references - renewDocIds({docArray: nodes}); + renewDocIds({ docArray: nodes }); // Order the root node libraryNode.order += 0.5; - LibraryNodes.batchInsert([libraryNode, ...nodes]); + LibraryNodes.batchInsert([libraryNode, ...nodes]); // Tree structure changed by inserts, reorder the tree reorderDocs({ diff --git a/app/imports/api/parenting/ChildSchema.js b/app/imports/api/parenting/ChildSchema.js index c2a6bc69..f4787bcc 100644 --- a/app/imports/api/parenting/ChildSchema.js +++ b/app/imports/api/parenting/ChildSchema.js @@ -22,7 +22,7 @@ let ChildSchema = new SimpleSchema({ order: { type: Number, }, - parent: { + parent: { type: RefSchema, optional: true, }, diff --git a/app/imports/api/parenting/SoftRemovableSchema.js b/app/imports/api/parenting/SoftRemovableSchema.js index 8c120553..54c2f92c 100644 --- a/app/imports/api/parenting/SoftRemovableSchema.js +++ b/app/imports/api/parenting/SoftRemovableSchema.js @@ -1,17 +1,17 @@ import SimpleSchema from 'simpl-schema'; let SoftRemovableSchema = new SimpleSchema({ - "removed": { + 'removed': { type: Boolean, optional: true, index: 1, }, - "removedAt": { + 'removedAt': { type: Date, optional: true, index: 1, }, - "removedWith": { + 'removedWith': { optional: true, type: String, regEx: SimpleSchema.RegEx.Id, diff --git a/app/imports/api/parenting/organizeMethods.js b/app/imports/api/parenting/organizeMethods.js index f104154c..88500085 100644 --- a/app/imports/api/parenting/organizeMethods.js +++ b/app/imports/api/parenting/organizeMethods.js @@ -8,7 +8,7 @@ import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; import getCollectionByName from '/imports/api/parenting/getCollectionByName.js'; -import computeCreature from '/imports/api/engine/computeCreature.js'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; const organizeDoc = new ValidatedMethod({ name: 'organize.organizeDoc', @@ -57,10 +57,11 @@ const organizeDoc = new ValidatedMethod({ let parentCreatures = getCreatureAncestors(parent); if (!skipRecompute){ let creaturesToRecompute = union(docCreatures, parentCreatures); - // Recompute the creatures - creaturesToRecompute.forEach(id => { - // Some Dependencies depend on ancestry, so a full recompute is needed - computeCreature(id); + // Mark the creatures for recompute + Creatures.update({ + _id: { $in: creaturesToRecompute } + }, { + $set: { dirty: true }, }); } }, @@ -85,9 +86,14 @@ const reorderDoc = new ValidatedMethod({ assertDocEditPermission(doc, this.userId); safeUpdateDocOrder({docRef, order}); // Recompute the affected creatures - getCreatureAncestors(doc).forEach(id => { - computeCreature(id); - }); + const ancestors = getCreatureAncestors(doc); + if (ancestors.length) { + Creatures.update({ + _id: { $in: ancestors } + }, { + $set: { dirty: true }, + }); + } }, }); diff --git a/app/imports/api/parenting/softRemove.js b/app/imports/api/parenting/softRemove.js index 9ef16f8c..099abfc2 100644 --- a/app/imports/api/parenting/softRemove.js +++ b/app/imports/api/parenting/softRemove.js @@ -40,17 +40,22 @@ const restoreError = function(){ ); }; -export function restore({_id, collection}){ +export function restore({ _id, collection, extraUpdates}){ if (typeof collection === 'string') { collection = getCollectionByName(collection); } + const update = { + $unset: { + removed: 1, + removedAt: 1, + }, + ...extraUpdates + } + let numUpdated = collection.update({ _id, removedWith: {$exists: false} - }, { $unset: { - removed: 1, - removedAt: 1, - }}, { + }, update , { selector: {type: 'any'}, },); if (numUpdated === 0) restoreError(); diff --git a/app/imports/api/properties/Actions.js b/app/imports/api/properties/Actions.js index ccda82d1..1ff4d6fd 100644 --- a/app/imports/api/properties/Actions.js +++ b/app/imports/api/properties/Actions.js @@ -5,9 +5,6 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; /* * Actions are things a character can do - * Any rolls that are children of actions will be rolled when taking the action - * Any actions that are children of this action will be considered alternatives - * to this action */ let ActionSchema = createPropertySchema({ name: { @@ -117,6 +114,11 @@ let ActionSchema = createPropertySchema({ type: 'fieldToCompute', optional: true, }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); const ComputedOnlyActionSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Adjustments.js b/app/imports/api/properties/Adjustments.js index 49c1b248..6b2607f5 100644 --- a/app/imports/api/properties/Adjustments.js +++ b/app/imports/api/properties/Adjustments.js @@ -3,7 +3,7 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; const AdjustmentSchema = createPropertySchema({ - // The roll that determines how much to change the attribute + // The roll that determines how much to change the attribute // This can be simplified, but should only compute when activated amount: { type: 'fieldToCompute', @@ -11,26 +11,31 @@ const AdjustmentSchema = createPropertySchema({ optional: true, defaultValue: 1, }, - // Who this adjustment applies to - target: { - type: String, + // Who this adjustment applies to + target: { + type: String, defaultValue: 'target', - allowedValues: [ + allowedValues: [ 'self', 'target', ], - }, - // The stat this rolls applies to - stat: { - type: String, + }, + // The stat this rolls applies to + stat: { + type: String, optional: true, max: STORAGE_LIMITS.variableName, - }, + }, operation: { type: String, allowedValues: ['set', 'increment'], defaultValue: 'increment', }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); const ComputedOnlyAdjustmentSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.js index 5c83a660..d486d5f9 100644 --- a/app/imports/api/properties/Attributes.js +++ b/app/imports/api/properties/Attributes.js @@ -8,34 +8,34 @@ import createPropertySchema from '/imports/api/properties/subSchemas/createPrope */ let AttributeSchema = createPropertySchema({ name: { - type: String, + type: String, optional: true, max: STORAGE_LIMITS.name, - }, + }, // The technical, lowercase, single-word name used in formulae variableName: { type: String, optional: true, - regEx: VARIABLE_NAME_REGEX, + regEx: VARIABLE_NAME_REGEX, min: 2, max: STORAGE_LIMITS.variableName, }, - // How it is displayed and computed is determined by type + // How it is displayed and computed is determined by type attributeType: { type: String, allowedValues: [ 'ability', //Strength, Dex, Con, etc. 'stat', // Speed, Armor Class - 'modifier', // Proficiency Bonus, displayed as +x + 'modifier', // Proficiency Bonus, displayed as +x 'hitDice', // d12 hit dice 'healthBar', // Hitpoints, Temporary Hitpoints, can take damage - 'bar', // Displayed as a health bar, can't take damage + 'bar', // Displayed as a health bar, can't take damage 'resource', // Rages, sorcery points 'spellSlot', // Level 1, 2, 3... spell slots 'utility', // Aren't displayed, Jump height, Carry capacity ], defaultValue: 'stat', - index: 1, + index: 1, }, // For type hitDice, the size needs to be stored separately hitDiceSize: { @@ -46,31 +46,48 @@ let AttributeSchema = createPropertySchema({ // For type spellSlot, the level needs to be stored separately spellSlotLevel: { type: 'fieldToCompute', - optional: true, + optional: true, }, // For type healthBar midColor, and lowColor can be set separately from the // property's color, which is used as the undamaged color 'healthBarColorMid': { - type: String, - regEx: /^#([a-f0-9]{3}){1,2}\b$/i, - optional: true, + type: String, + regEx: /^#([a-f0-9]{3}){1,2}\b$/i, + optional: true, }, 'healthBarColorLow': { - type: String, - regEx: /^#([a-f0-9]{3}){1,2}\b$/i, - optional: true, + type: String, + regEx: /^#([a-f0-9]{3}){1,2}\b$/i, + optional: true, }, - // The starting value, before effects - baseValue: { + // Control how the health bar takes damage or healing + healthBarNoDamage: { + type: Boolean, + optional: true, + }, + healthBarNoHealing: { + type: Boolean, + optional: true, + }, + healthBarDamageOrder: { + type: SimpleSchema.Integer, + optional: true, + }, + healthBarHealingOrder: { + type: SimpleSchema.Integer, + optional: true, + }, + // The starting value, before effects + baseValue: { type: 'fieldToCompute', - optional: true, - }, + optional: true, + }, // Description of what the attribute is used for description: { type: 'inlineCalculationFieldToCompute', - optional: true, - }, - // The damage done to the attribute, should always compute as positive + optional: true, + }, + // The damage done to the attribute, should always compute as positive damage: { type: SimpleSchema.Integer, optional: true, @@ -80,7 +97,17 @@ let AttributeSchema = createPropertySchema({ type: Boolean, optional: true, }, - // Automatically zero the adjustment on these conditions + // Can the total after damage be negative + ignoreLowerLimit: { + type: Boolean, + optional: true, + }, + // Can the damage value be negative + ignoreUpperLimit: { + type: Boolean, + optional: true, + }, + // Automatically zero the adjustment on these conditions reset: { type: String, optional: true, @@ -99,9 +126,9 @@ let ComputedOnlyAttributeSchema = createPropertySchema({ }, spellSlotLevel: { type: 'computedOnlyField', - optional: true, + optional: true, }, - // The computed value of the attribute + // The computed value of the attribute total: { type: SimpleSchema.oneOf(Number, String, Boolean), optional: true, @@ -110,27 +137,27 @@ let ComputedOnlyAttributeSchema = createPropertySchema({ // The computed value of the attribute minus the damage value: { type: SimpleSchema.oneOf(Number, String, Boolean), - defaultValue: 0, + defaultValue: 0, optional: true, removeBeforeCompute: true, }, - // The computed modifier, provided the attribute type is `ability` - modifier: { - type: SimpleSchema.Integer, - optional: true, + // The computed modifier, provided the attribute type is `ability` + modifier: { + type: SimpleSchema.Integer, + optional: true, removeBeforeCompute: true, - }, + }, // Attributes with proficiency grant it to all skills based on the attribute proficiency: { - type: Number, + type: Number, allowedValues: [0, 0.49, 0.5, 1, 2], - optional: true, + optional: true, removeBeforeCompute: true, - }, + }, // The computed creature constitution modifier for hit dice constitutionMod: { type: Number, - optional: true, + optional: true, removeBeforeCompute: true, }, // Should this attribute hide @@ -145,6 +172,16 @@ let ComputedOnlyAttributeSchema = createPropertySchema({ optional: true, removeBeforeCompute: true, }, + // A list of effect ids targeting this attribute + effects: { + type: Array, + optional: true, + removeBeforeCompute: true, + }, + 'effects.$': { + type: Object, + blackbox: true, + }, }); const ComputedAttributeSchema = new SimpleSchema() diff --git a/app/imports/api/properties/Branches.js b/app/imports/api/properties/Branches.js index 952c7715..73716eb6 100644 --- a/app/imports/api/properties/Branches.js +++ b/app/imports/api/properties/Branches.js @@ -3,8 +3,8 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; let BranchSchema = createPropertySchema({ - branchType: { - type: String, + branchType: { + type: String, allowedValues: [ // Uses the condition field to determine whether to apply children 'if', @@ -26,7 +26,7 @@ let BranchSchema = createPropertySchema({ //'option', ], defaultValue: 'if', - }, + }, text: { type: String, optional: true, @@ -37,6 +37,11 @@ let BranchSchema = createPropertySchema({ optional: true, parseLevel: 'compile', }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); let ComputedOnlyBranchSchema = createPropertySchema({ diff --git a/app/imports/api/properties/BuffRemovers.js b/app/imports/api/properties/BuffRemovers.js new file mode 100644 index 00000000..b96466cb --- /dev/null +++ b/app/imports/api/properties/BuffRemovers.js @@ -0,0 +1,84 @@ +import SimpleSchema from 'simpl-schema'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; + +let BuffRemoverSchema = createPropertySchema({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + // This will remove just the nearest ancestor buff + targetParentBuff: { + type: Boolean, + optional: true, + }, + // The following only applies when not targeting the parent buff + // Which character to remove buffs from + target: { + type: String, + allowedValues: [ + 'self', + 'target', + ], + defaultValue: 'target', + }, + // remove 1 or remove all + removeAll: { + type: Boolean, + optional: true, + defaultValue: true, + }, + // Buffs to remove based on tags: + 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, + }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, +}); + +let ComputedOnlyBuffRemoverSchema = createPropertySchema({}); + +const ComputedBuffRemoverSchema = new SimpleSchema() + .extend(BuffRemoverSchema) + .extend(ComputedOnlyBuffRemoverSchema); + +export { BuffRemoverSchema, ComputedOnlyBuffRemoverSchema, ComputedBuffRemoverSchema }; diff --git a/app/imports/api/properties/Buffs.js b/app/imports/api/properties/Buffs.js index 43efd841..386c402b 100644 --- a/app/imports/api/properties/Buffs.js +++ b/app/imports/api/properties/Buffs.js @@ -4,60 +4,74 @@ import createPropertySchema from '/imports/api/properties/subSchemas/createPrope let BuffSchema = createPropertySchema({ name: { - type: String, - optional: true, + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, + hideRemoveButton: { + type: Boolean, + optional: true, + }, // How many rounds this buff lasts - duration: { - type: 'fieldToCompute', - optional: true, - }, + duration: { + type: 'fieldToCompute', + optional: true, + }, target: { - type: String, - allowedValues: [ + type: String, + allowedValues: [ 'self', 'target', ], - defaultValue: 'target', - }, + defaultValue: 'target', + }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, + // Prevent the children from being crystalized + skipCrystalization: { + type: Boolean, + optional: true, + }, }); let ComputedOnlyBuffSchema = createPropertySchema({ - description: { - type: 'computedOnlyInlineCalculationField', - optional: true, - max: STORAGE_LIMITS.description, - }, - duration: { - type: 'computedOnlyField', - optional: true, - }, - durationSpent: { - type: Number, - optional: true, - min: 0, - }, - appliedBy: { - type: Object, + description: { + type: 'computedOnlyInlineCalculationField', optional: true, - }, - 'appliedBy.name': { - type: String, + max: STORAGE_LIMITS.description, + }, + duration: { + type: 'computedOnlyField', + optional: true, + }, + durationSpent: { + type: Number, + optional: true, + min: 0, + }, + appliedBy: { + type: Object, + optional: true, + }, + 'appliedBy.name': { + type: String, max: STORAGE_LIMITS.name, - }, - 'appliedBy.id': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - 'appliedBy.collection': { - type: String, + }, + 'appliedBy.id': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'appliedBy.collection': { + type: String, max: STORAGE_LIMITS.collectionName, - }, + }, }); const ComputedBuffSchema = new SimpleSchema() diff --git a/app/imports/api/properties/ClassLevels.js b/app/imports/api/properties/ClassLevels.js index f0600917..bbd76d58 100644 --- a/app/imports/api/properties/ClassLevels.js +++ b/app/imports/api/properties/ClassLevels.js @@ -4,26 +4,26 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; const ClassLevelSchema = createPropertySchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, - // The name of this class level's variable - variableName: { + }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, + // The name of this class level's variable + variableName: { type: String, min: 2, - regEx: VARIABLE_NAME_REGEX, + regEx: VARIABLE_NAME_REGEX, max: STORAGE_LIMITS.variableName, optional: true, }, - level: { + level: { type: SimpleSchema.Integer, - defaultValue: 1, + defaultValue: 1, max: STORAGE_LIMITS.levelMax, }, // Filters out of UI if condition isn't met, but isn't otherwise enforced @@ -34,7 +34,7 @@ const ClassLevelSchema = createPropertySchema({ }, }); -const ComputedOnlyClassLevelSchema = createPropertySchema({ +const ComputedOnlyClassLevelSchema = createPropertySchema({ description: { type: 'computedOnlyInlineCalculationField', optional: true, diff --git a/app/imports/api/properties/Classes.js b/app/imports/api/properties/Classes.js index ee631246..e6e6471f 100644 --- a/app/imports/api/properties/Classes.js +++ b/app/imports/api/properties/Classes.js @@ -1,34 +1,95 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; -import { SlotSchema, ComputedOnlySlotSchema } from './Slots.js'; // Classes are like slots, except they only take class levels and enforce that // lower levels are taken before higher levels let ClassSchema = createPropertySchema({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, // Only `classLevel`s with the same variable name can fill the class variableName: { type: String, optional: true, max: STORAGE_LIMITS.variableName, }, -}).extend(SlotSchema); + slotTags: { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.tagCount, + }, + 'slotTags.$': { + type: String, + max: STORAGE_LIMITS.tagLength, + }, + extraTags: { + type: Array, + defaultValue: [], + 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, + }, + slotCondition: { + type: 'fieldToCompute', + optional: true, + }, +}); const ComputedOnlyClassSchema = createPropertySchema({ - level: { - type: SimpleSchema.Integer, - optional: true, - removeBeforeCompute: true, - }, - missingLevels: { - type: Array, - optional: true, - removeBeforeCompute: true, - }, - 'missingLevels.$': { - type: SimpleSchema.Integer, - }, - }).extend(ComputedOnlySlotSchema); + // Computed fields + description: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, + slotCondition: { + type: 'computedOnlyField', + optional: true, + }, + + // Denormalised fields + level: { + type: SimpleSchema.Integer, + optional: true, + removeBeforeCompute: true, + }, + missingLevels: { + type: Array, + optional: true, + removeBeforeCompute: true, + }, + 'missingLevels.$': { + type: SimpleSchema.Integer, + }, +}); const ComputedClassSchema = new SimpleSchema() .extend(ClassSchema) diff --git a/app/imports/api/properties/Constants.js b/app/imports/api/properties/Constants.js index 0995ccd9..ded0f090 100644 --- a/app/imports/api/properties/Constants.js +++ b/app/imports/api/properties/Constants.js @@ -13,29 +13,29 @@ import resolve, { Context, traverse } from '/imports/parser/resolve.js'; */ let ConstantSchema = new SimpleSchema({ name: { - type: String, - optional: true, + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, // The technical, lowercase, single-word name used in formulae variableName: { type: String, - regEx: VARIABLE_NAME_REGEX, + regEx: VARIABLE_NAME_REGEX, min: 2, defaultValue: 'newConstant', max: STORAGE_LIMITS.variableName, }, - // The input value to be parsed, must return a constant node or an array + // The input value to be parsed, must return a constant node or an array // of constant nodes to be valid - calculation: { - type: String, - optional: true, + calculation: { + type: String, + optional: true, max: STORAGE_LIMITS.calculation, - }, + }, errors: { type: Array, maxCount: STORAGE_LIMITS.errorCount, - autoValue(){ + autoValue() { let calc = this.field('calculation'); if (!calc.isSet && this.isModifier) { this.unset() @@ -44,27 +44,27 @@ let ConstantSchema = new SimpleSchema({ let string = calc.value; if (!string) return []; // Evaluate the calculation with no scope - let {result, context} = parseString(string); + let { result, context } = parseString(string); // Any existing errors will result in an early failure if (context && context.errors.length) return context.errors; // Ban variables in constants if necessary result && traverse(result, node => { - if (node.parseType === 'symbol' || node.parseType === 'accessor'){ + if (node.parseType === 'symbol' || node.parseType === 'accessor') { context.error('Variables can\'t be used to define a constant'); } }); return context && context.errors || []; } }, - 'errors.$':{ + 'errors.$': { type: ErrorSchema, }, }); -function parseString(string, fn = 'compile'){ +function parseString(string, fn = 'compile') { let context = new Context(); - if (!string){ - return {result: string, context}; + if (!string) { + return { result: string, context }; } // Parse the string using mathjs @@ -74,11 +74,11 @@ function parseString(string, fn = 'compile'){ } catch (e) { let message = prettifyParseError(e); context.error(message); - return {context}; + return { context }; } - if (!node) return {context}; - let {result} = resolve(fn, node, {/*empty scope*/}, context); - return {result, context} + if (!node) return { context }; + let { result } = resolve(fn, node, {/*empty scope*/ }, context); + return { result, context } } const ComputedOnlyConstantSchema = new SimpleSchema({}); diff --git a/app/imports/api/properties/Containers.js b/app/imports/api/properties/Containers.js index 402a3e83..6936bd15 100644 --- a/app/imports/api/properties/Containers.js +++ b/app/imports/api/properties/Containers.js @@ -3,61 +3,61 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; let ContainerSchema = createPropertySchema({ - name: { - type: String, - optional: true, - trim: false, + name: { + type: String, + optional: true, + trim: false, max: STORAGE_LIMITS.name, - }, - carried: { - type: Boolean, - defaultValue: true, - optional: true, - }, - contentsWeightless: { - type: Boolean, - optional: true, - }, - weight: { - type: Number, - min: 0, + }, + carried: { + type: Boolean, + defaultValue: true, optional: true, - }, - value: { - type: Number, - min: 0, + }, + contentsWeightless: { + type: Boolean, optional: true, - }, - description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + }, + weight: { + type: Number, + min: 0, + optional: true, + }, + value: { + type: Number, + min: 0, + optional: true, + }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, }); const ComputedOnlyContainerSchema = createPropertySchema({ description: { - type: 'computedOnlyInlineCalculationField', - optional: true, - }, + type: 'computedOnlyInlineCalculationField', + optional: true, + }, // Weight of all the contents, zero if `contentsWeightless` is true - contentsWeight:{ + contentsWeight: { type: Number, optional: true, removeBeforeCompute: true, }, // Weight of all the carried contents (some sub-containers might not be carried) // zero if `contentsWeightless` is true - carriedWeight:{ + carriedWeight: { type: Number, optional: true, removeBeforeCompute: true, }, - contentsValue:{ + contentsValue: { type: Number, optional: true, removeBeforeCompute: true, }, - carriedValue:{ + carriedValue: { type: Number, optional: true, removeBeforeCompute: true, @@ -65,7 +65,7 @@ const ComputedOnlyContainerSchema = createPropertySchema({ }); const ComputedContainerSchema = new SimpleSchema() - .extend(ComputedOnlyContainerSchema) - .extend(ContainerSchema); + .extend(ComputedOnlyContainerSchema) + .extend(ContainerSchema); export { ContainerSchema, ComputedOnlyContainerSchema, ComputedContainerSchema }; diff --git a/app/imports/api/properties/DamageMultipliers.js b/app/imports/api/properties/DamageMultipliers.js index c88c4cbe..3c75fbbf 100644 --- a/app/imports/api/properties/DamageMultipliers.js +++ b/app/imports/api/properties/DamageMultipliers.js @@ -8,10 +8,10 @@ import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; */ let DamageMultiplierSchema = new SimpleSchema({ name: { - type: String, - optional: true, + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, damageTypes: { type: Array, defaultValue: [], @@ -23,11 +23,11 @@ let DamageMultiplierSchema = new SimpleSchema({ max: STORAGE_LIMITS.calculation, regEx: VARIABLE_NAME_REGEX, }, - // The value of the damage multiplier - value: { + // The value of the damage multiplier + value: { type: Number, - defaultValue: 0.5, - allowedValues: [0, 0.5, 2], + defaultValue: 0.5, + allowedValues: [0, 0.5, 2], }, // Tags which bypass this multiplier (OR) excludeTags: { diff --git a/app/imports/api/properties/Damages.js b/app/imports/api/properties/Damages.js index 85cafee0..190dd4b5 100644 --- a/app/imports/api/properties/Damages.js +++ b/app/imports/api/properties/Damages.js @@ -4,7 +4,7 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; const DamageSchema = createPropertySchema({ - // The roll that determines how much to damage the attribute + // The roll that determines how much to damage the attribute // This can be simplified, but only computed when applied amount: { type: 'fieldToCompute', @@ -12,21 +12,26 @@ const DamageSchema = createPropertySchema({ defaultValue: '1d8 + strength.modifier', parseLevel: 'compile', }, - // Who this damage applies to - target: { - type: String, + // Who this damage applies to + target: { + type: String, defaultValue: 'target', - allowedValues: [ + allowedValues: [ 'self', 'target', ], - }, - damageType: { - type: String, + }, + damageType: { + type: String, max: STORAGE_LIMITS.calculation, - defaultValue: 'slashing', + defaultValue: 'slashing', regEx: VARIABLE_NAME_REGEX, - }, + }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); const ComputedOnlyDamageSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Features.js b/app/imports/api/properties/Features.js index 0abe8be9..91fc95b6 100644 --- a/app/imports/api/properties/Features.js +++ b/app/imports/api/properties/Features.js @@ -3,19 +3,19 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; let FeatureSchema = createPropertySchema({ - name: { - type: String, + name: { + type: String, max: STORAGE_LIMITS.name, optional: true, - }, - summary: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + }, + summary: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + type: 'inlineCalculationFieldToCompute', + optional: true, + }, }); let ComputedOnlyFeatureSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Folders.js b/app/imports/api/properties/Folders.js index da4e5386..45055ce1 100644 --- a/app/imports/api/properties/Folders.js +++ b/app/imports/api/properties/Folders.js @@ -1,15 +1,15 @@ -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 SimpleSchema({ +let FolderSchema = new createPropertySchema({ name: { type: String, max: STORAGE_LIMITS.name, }, }); -const ComputedOnlyFolderSchema = new SimpleSchema({}); +const ComputedOnlyFolderSchema = new createPropertySchema({}); export { FolderSchema, ComputedOnlyFolderSchema }; diff --git a/app/imports/api/properties/Items.js b/app/imports/api/properties/Items.js index 373a6054..a328cfcf 100644 --- a/app/imports/api/properties/Items.js +++ b/app/imports/api/properties/Items.js @@ -3,58 +3,58 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; const ItemSchema = createPropertySchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - // Plural name of the item, if there is more than one - plural: { - type: String, - optional: true, + }, + // Plural name of the item, if there is more than one + plural: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, - // Number currently held - quantity: { - type: SimpleSchema.Integer, - min: 0, - defaultValue: 1 - }, - // Weight per item in the stack - weight: { - type: Number, - min: 0, + type: 'inlineCalculationFieldToCompute', optional: true, - }, - // Value per item in the stack, in gold pieces - value: { - type: Number, - min: 0, + }, + // Number currently held + quantity: { + type: SimpleSchema.Integer, + min: 0, + defaultValue: 1 + }, + // Weight per item in the stack + weight: { + type: Number, + min: 0, optional: true, - }, - // If this item is equipped, it requires attunement - requiresAttunement: { - type: Boolean, - optional: true, - }, + }, + // Value per item in the stack, in gold pieces + value: { + type: Number, + min: 0, + optional: true, + }, + // If this item is equipped, it requires attunement + requiresAttunement: { + type: Boolean, + optional: true, + }, attuned: { - type: Boolean, - optional: true, - }, - // Show increment/decrement buttons in item lists - showIncrement: { - type: Boolean, - optional: true, - }, - // Unequipped items shouldn't affect creature stats - equipped: { - type: Boolean, - defaultValue: false, - }, + type: Boolean, + optional: true, + }, + // Show increment/decrement buttons in item lists + showIncrement: { + type: Boolean, + optional: true, + }, + // Unequipped items shouldn't affect creature stats + equipped: { + type: Boolean, + defaultValue: false, + }, }); let ComputedOnlyItemSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Notes.js b/app/imports/api/properties/Notes.js index dd76d906..1e738b08 100644 --- a/app/imports/api/properties/Notes.js +++ b/app/imports/api/properties/Notes.js @@ -3,19 +3,19 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; let NoteSchema = createPropertySchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - summary: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + }, + summary: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + type: 'inlineCalculationFieldToCompute', + optional: true, + }, }); let ComputedOnlyNoteSchema = createPropertySchema({ diff --git a/app/imports/api/properties/PointBuys.js b/app/imports/api/properties/PointBuys.js new file mode 100644 index 00000000..db20911a --- /dev/null +++ b/app/imports/api/properties/PointBuys.js @@ -0,0 +1,159 @@ +import SimpleSchema from 'simpl-schema'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; +import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; +import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js'; + +/* + * PointBuys are reason-value attached to skills and abilities + * that modify their final value or presentation in some way + */ +let PointBuySchema = createPropertySchema({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + ignored: { + type: Boolean, + optional: true, + }, + 'values': { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.pointBuyRowsCount, + }, + 'values.$': { + type: Object, + }, + 'values.$._id': { + type: String, + regEx: SimpleSchema.RegEx.Id, + autoValue(){ + if (!this.isSet) return Random.id(); + } + }, + 'values.$.name': { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + 'values.$.variableName': { + type: String, + optional: true, + regEx: VARIABLE_NAME_REGEX, + min: 2, + max: STORAGE_LIMITS.variableName, + }, + 'values.$.value': { + type: Number, + optional: true, + }, + 'values.$.min': { + type: 'fieldToCompute', + optional: true, + }, + 'values.$.max': { + type: 'fieldToCompute', + optional: true, + }, + 'values.$.cost': { + type: 'fieldToCompute', + optional: true, + }, + min: { + type: 'fieldToCompute', + optional: true, + }, + max: { + type: 'fieldToCompute', + optional: true, + }, + total: { + type: 'fieldToCompute', + optional: true, + }, + cost: { + type: 'fieldToCompute', + optional: true, + parseLevel: 'compile', + }, +}); + +const ComputedOnlyPointBuySchema = createPropertySchema({ + min: { + type: 'computedOnlyField', + optional: true, + }, + max: { + type: 'computedOnlyField', + optional: true, + }, + cost: { + type: 'computedOnlyField', + optional: true, + parseLevel: 'compile', + }, + 'values': { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.pointBuyRowsCount, + }, + 'values.$': { + type: Object, + }, + 'values.$.min': { + type: 'computedOnlyField', + optional: true, + }, + 'values.$.max': { + type: 'computedOnlyField', + optional: true, + }, + 'values.$.cost': { + type: 'computedOnlyField', + optional: true, + parseLevel: 'compile', + }, + 'values.$.spent': { + type: Number, + optional: true, + removeBeforeCompute: true, + }, + 'values.$.errors': { + type: Array, + optional: true, + removeBeforeCompute: true, + }, + 'values.$.errors.$': { + type: ErrorSchema, + }, + total: { + type: 'computedOnlyField', + optional: true, + }, + spent: { + type: Number, + optional: true, + removeBeforeCompute: true, + }, + pointsLeft: { + type: Number, + optional: true, + removeBeforeCompute: true, + }, + errors: { + type: Array, + optional: true, + removeBeforeCompute: true, + }, + 'errors.$': { + type: ErrorSchema, + }, +}); + +const ComputedPointBuySchema = new SimpleSchema() + .extend(ComputedOnlyPointBuySchema) + .extend(PointBuySchema); + +export { PointBuySchema, ComputedPointBuySchema, ComputedOnlyPointBuySchema }; diff --git a/app/imports/api/properties/Proficiencies.js b/app/imports/api/properties/Proficiencies.js index 8e8246ac..e79276ff 100644 --- a/app/imports/api/properties/Proficiencies.js +++ b/app/imports/api/properties/Proficiencies.js @@ -2,28 +2,28 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let ProficiencySchema = new SimpleSchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, - // The variableNames of the skills, tags, or attributes to apply proficiency to - stats: { - type: Array, - defaultValue: [], + }, + // The variableNames of the skills, tags, or attributes to apply proficiency to + stats: { + type: Array, + defaultValue: [], maxCount: STORAGE_LIMITS.statsToTarget, - }, - 'stats.$': { - type: String, + }, + 'stats.$': { + type: String, max: STORAGE_LIMITS.variableName, - }, - // A number representing how proficient the character is + }, + // A number representing how proficient the character is // where 0.49 is half rounded down and 0.5 is half rounded up - value: { - type: Number, - allowedValues: [0.49, 0.5, 1, 2], - defaultValue: 1, - }, + value: { + type: Number, + allowedValues: [0.49, 0.5, 1, 2], + defaultValue: 1, + }, }); const ComputedOnlyProficiencySchema = new SimpleSchema({}); diff --git a/app/imports/api/properties/Rolls.js b/app/imports/api/properties/Rolls.js index 03e819a4..7a2c5eeb 100644 --- a/app/imports/api/properties/Rolls.js +++ b/app/imports/api/properties/Rolls.js @@ -23,14 +23,14 @@ import createPropertySchema from '/imports/api/properties/subSchemas/createPrope */ let RollSchema = createPropertySchema({ name: { - type: String, + type: String, defaultValue: 'New Roll', max: STORAGE_LIMITS.name, - }, + }, // The technical, lowercase, single-word name used in formulae variableName: { type: String, - regEx: VARIABLE_NAME_REGEX, + regEx: VARIABLE_NAME_REGEX, min: 2, defaultValue: 'newRoll', max: STORAGE_LIMITS.variableName, diff --git a/app/imports/api/properties/SavingThrows.js b/app/imports/api/properties/SavingThrows.js index f5cfbf70..d0045187 100644 --- a/app/imports/api/properties/SavingThrows.js +++ b/app/imports/api/properties/SavingThrows.js @@ -16,20 +16,25 @@ let SavingThrowSchema = createPropertySchema({ optional: true, }, // Who this saving throw applies to - target: { - type: String, + target: { + type: String, defaultValue: 'target', - allowedValues: [ + allowedValues: [ 'self', 'target', ], - }, + }, // The variable name of save to roll stat: { type: String, optional: true, max: STORAGE_LIMITS.variableName, }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); const ComputedOnlySavingThrowSchema = createPropertySchema({ diff --git a/app/imports/api/properties/Skills.js b/app/imports/api/properties/Skills.js index 281fe985..a2083cdf 100644 --- a/app/imports/api/properties/Skills.js +++ b/app/imports/api/properties/Skills.js @@ -9,10 +9,10 @@ import createPropertySchema from '/imports/api/properties/subSchemas/createPrope */ let SkillSchema = createPropertySchema({ name: { - type: String, - optional: true, + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, // The technical, lowercase, single-word name used in formulae // Ignored for skilltype = save variableName: { @@ -22,33 +22,33 @@ let SkillSchema = createPropertySchema({ max: STORAGE_LIMITS.variableName, optional: true, }, - // The variable name of the ability this skill relies on + // The variable name of the ability this skill relies on ability: { type: String, optional: true, max: STORAGE_LIMITS.variableName, }, - // What type of skill is this + // What type of skill is this skillType: { type: String, allowedValues: [ 'skill', 'save', - 'check', + 'check', 'tool', 'weapon', 'armor', 'language', - 'utility', //not displayed anywhere + 'utility', //not displayed anywhere ], defaultValue: 'skill', }, - // The base proficiency of this skill - baseProficiency: { - type: Number, - optional: true, + // The base proficiency of this skill + baseProficiency: { + type: Number, + optional: true, allowedValues: [0.49, 0.5, 1, 2], - }, + }, // The starting value, before effects baseValue: { type: 'fieldToCompute', @@ -56,16 +56,16 @@ let SkillSchema = createPropertySchema({ }, // Description of what the skill is used for description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, + type: 'inlineCalculationFieldToCompute', + optional: true, + }, }); let ComputedOnlySkillSchema = createPropertySchema({ - // Computed value of skill to be added to skill rolls + // Computed value of skill to be added to skill rolls value: { type: Number, - defaultValue: 0, + defaultValue: 0, optional: true, removeBeforeCompute: true, }, @@ -75,33 +75,33 @@ let ComputedOnlySkillSchema = createPropertySchema({ optional: true, }, description: { - type: 'computedOnlyInlineCalculationField', - optional: true, - }, - // Computed value added by the ability - abilityMod: { - type: SimpleSchema.Integer, - optional: true, + type: 'computedOnlyInlineCalculationField', + optional: true, + }, + // Computed value added by the ability + abilityMod: { + type: SimpleSchema.Integer, + optional: true, removeBeforeCompute: true, - }, - // Computed advantage/disadvantage + }, + // Computed advantage/disadvantage advantage: { type: SimpleSchema.Integer, optional: true, allowedValues: [-1, 0, 1], removeBeforeCompute: true, }, - // Computed bonus to passive checks + // Computed bonus to passive checks passiveBonus: { type: Number, optional: true, removeBeforeCompute: true, }, - // Computed proficiency multiplier + // Computed proficiency multiplier proficiency: { type: Number, allowedValues: [0, 0.49, 0.5, 1, 2], - defaultValue: 0, + defaultValue: 0, removeBeforeCompute: true, }, // Compiled text of all conditional benefits @@ -113,7 +113,7 @@ let ComputedOnlySkillSchema = createPropertySchema({ 'conditionalBenefits.$': { type: String, }, - // Computed number of things forcing this skill to fail + // Computed number of things forcing this skill to fail fail: { type: SimpleSchema.Integer, optional: true, @@ -131,6 +131,16 @@ let ComputedOnlySkillSchema = createPropertySchema({ optional: true, removeBeforeCompute: true, }, + // A list of effect ids targeting this skill + effects: { + type: Array, + optional: true, + removeBeforeCompute: true, + }, + 'effects.$': { + type: Object, + blackbox: true, + }, }) const ComputedSkillSchema = new SimpleSchema() diff --git a/app/imports/api/properties/SpellLists.js b/app/imports/api/properties/SpellLists.js index c4911cf0..01434ea2 100644 --- a/app/imports/api/properties/SpellLists.js +++ b/app/imports/api/properties/SpellLists.js @@ -3,17 +3,17 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; let SpellListSchema = createPropertySchema({ - name: { - type: String, - optional: true, + name: { + type: String, + optional: true, max: STORAGE_LIMITS.name, - }, + }, description: { - type: 'inlineCalculationFieldToCompute', - optional: true, - }, - // Calculation of how many spells in this list can be prepared - maxPrepared: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, + // Calculation of how many spells in this list can be prepared + maxPrepared: { type: 'fieldToCompute', optional: true, }, diff --git a/app/imports/api/properties/Spells.js b/app/imports/api/properties/Spells.js index 304e16a3..575c9b5e 100644 --- a/app/imports/api/properties/Spells.js +++ b/app/imports/api/properties/Spells.js @@ -3,93 +3,93 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; const magicSchools = [ - 'abjuration', - 'conjuration', - 'divination', - 'enchantment', - 'evocation', - 'illusion', - 'necromancy', - 'transmutation', + 'abjuration', + 'conjuration', + 'divination', + 'enchantment', + 'evocation', + 'illusion', + 'necromancy', + 'transmutation', ]; let SpellSchema = new SimpleSchema({}) -.extend(ActionSchema) -.extend({ - name: { - type: String, - optional: true, - max: STORAGE_LIMITS.name, - }, - // If it's always prepared, it doesn't count against the number of spells - // prepared in a spell list, and enabled should be true - alwaysPrepared: { - type: Boolean, - optional: true, - }, - prepared: { - type: Boolean, - optional: true, - }, - // This spell ignores spell slot rules - castWithoutSpellSlots: { - type: Boolean, - optional: true, - }, - hasAttackRoll: { - type: Boolean, - optional: true, - }, - castingTime: { - type: String, - optional: true, - defaultValue: 'action', - max: STORAGE_LIMITS.spellDetail, - }, - range: { - type: String, - optional: true, - max: STORAGE_LIMITS.spellDetail, - }, - duration: { - type: String, - optional: true, - defaultValue: 'Instantaneous', - max: STORAGE_LIMITS.spellDetail, - }, - verbal: { - type: Boolean, - optional: true, - }, - somatic: { - type: Boolean, - optional: true, - }, - concentration: { - type: Boolean, - optional: true, - }, - material: { - type: String, - optional: true, - max: STORAGE_LIMITS.spellDetail, - }, - ritual: { - type: Boolean, - optional: true, - }, - level: { - type: SimpleSchema.Integer, - defaultValue: 1, - max: 9, - min: 0, - }, - school: { - type: String, - defaultValue: 'abjuration', - allowedValues: magicSchools, - }, -}); + .extend(ActionSchema) + .extend({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + // If it's always prepared, it doesn't count against the number of spells + // prepared in a spell list, and enabled should be true + alwaysPrepared: { + type: Boolean, + optional: true, + }, + prepared: { + type: Boolean, + optional: true, + }, + // This spell ignores spell slot rules + castWithoutSpellSlots: { + type: Boolean, + optional: true, + }, + hasAttackRoll: { + type: Boolean, + optional: true, + }, + castingTime: { + type: String, + optional: true, + defaultValue: 'action', + max: STORAGE_LIMITS.spellDetail, + }, + range: { + type: String, + optional: true, + max: STORAGE_LIMITS.spellDetail, + }, + duration: { + type: String, + optional: true, + defaultValue: 'Instantaneous', + max: STORAGE_LIMITS.spellDetail, + }, + verbal: { + type: Boolean, + optional: true, + }, + somatic: { + type: Boolean, + optional: true, + }, + concentration: { + type: Boolean, + optional: true, + }, + material: { + type: String, + optional: true, + max: STORAGE_LIMITS.spellDetail, + }, + ritual: { + type: Boolean, + optional: true, + }, + level: { + type: SimpleSchema.Integer, + defaultValue: 1, + max: 9, + min: 0, + }, + school: { + type: String, + defaultValue: 'abjuration', + allowedValues: magicSchools, + }, + }); const ComputedOnlySpellSchema = new SimpleSchema() .extend(ComputedOnlyActionSchema); diff --git a/app/imports/api/properties/Toggles.js b/app/imports/api/properties/Toggles.js index 7a76d061..755ed89b 100644 --- a/app/imports/api/properties/Toggles.js +++ b/app/imports/api/properties/Toggles.js @@ -41,7 +41,7 @@ const ComputedOnlyToggleSchema = createPropertySchema({ }); const ComputedToggleSchema = new SimpleSchema() - .extend(ComputedOnlyToggleSchema) - .extend(ToggleSchema); + .extend(ComputedOnlyToggleSchema) + .extend(ToggleSchema); export { ToggleSchema, ComputedOnlyToggleSchema, ComputedToggleSchema }; diff --git a/app/imports/api/properties/Triggers.js b/app/imports/api/properties/Triggers.js new file mode 100644 index 00000000..0bfd7d9d --- /dev/null +++ b/app/imports/api/properties/Triggers.js @@ -0,0 +1,142 @@ +import SimpleSchema from 'simpl-schema'; +import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; + +const eventOptions = { + doActionProperty: 'Do action', + // receiveActionProperty: 'Receiving action property', + check: 'Roll check', + // flipToggle: 'Toggle changed', + // itemEquipped: 'Item equipped' + // itemUnequipped: 'Item unequipped' + damageProperty: 'Attribute damaged or healed', + anyRest: 'Short or long rest', + longRest: 'Long rest', + shortRest: 'Short rest', +} + +const timingOptions = { + before: 'Before', + after: 'After', +} + +const actionPropertyTypeOptions = { + action: 'Action', + adjustment: 'Attribute damage', + branch: 'Branch', + buff: 'Buff', + buffRemover: 'Buff Removed', + damage: 'Damage', + note: 'Note', + roll: 'Roll', + savingThrow: 'Saving throw', + spell: 'Spell', + toggle: 'Toggle', +} + +/* + * Triggers are like actions that fire themselves when certain things happen on + * the sheet. Either during another action or as its own action after a sheet + * event. The same trigger can't fire twice in the same action step. + */ +let TriggerSchema = createPropertySchema({ + name: { + type: String, + optional: true, + max: STORAGE_LIMITS.name, + }, + description: { + type: 'inlineCalculationFieldToCompute', + optional: true, + }, + event: { + type: String, + allowedValues: Object.keys(eventOptions), + defaultValue: 'doActionProperty', + }, + // Action type + actionPropertyType: { + type: String, + allowedValues: Object.keys(actionPropertyTypeOptions), + optional: true, + }, + timing: { + type: String, + allowedValues: Object.keys(timingOptions), + defaultValue: 'after', + }, + condition: { + type: 'fieldToCompute', + optional: true, + parseLevel: 'compile', + }, + // Which tags the trigger 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, + }, + // Prevent the property from showing up in the log + silent: { + type: Boolean, + optional: true, + }, +}); + +const ComputedOnlyTriggerSchema = createPropertySchema({ + summary: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, + description: { + type: 'computedOnlyInlineCalculationField', + optional: true, + }, + condition: { + type: 'computedOnlyField', + optional: true, + parseLevel: 'compile', + }, +}); + +const ComputedTriggerSchema = new SimpleSchema() + .extend(TriggerSchema) + .extend(ComputedOnlyTriggerSchema); + +export { + TriggerSchema, ComputedOnlyTriggerSchema, ComputedTriggerSchema, + eventOptions, timingOptions, actionPropertyTypeOptions +}; diff --git a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js index d47d560d..93f3793a 100644 --- a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js +++ b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js @@ -3,6 +3,7 @@ import { ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js'; import { ComputedOnlyAdjustmentSchema } from '/imports/api/properties/Adjustments.js'; import { ComputedOnlyAttributeSchema } from '/imports/api/properties/Attributes.js'; import { ComputedOnlyBuffSchema } from '/imports/api/properties/Buffs.js'; +import { ComputedOnlyBuffRemoverSchema } from '/imports/api/properties/BuffRemovers.js'; import { ComputedOnlyBranchSchema } from '/imports/api/properties/Branches.js'; import { ComputedOnlyClassSchema } from '/imports/api/properties/Classes.js'; import { ComputedOnlyClassLevelSchema } from '/imports/api/properties/ClassLevels.js'; @@ -15,6 +16,7 @@ import { ComputedOnlyFeatureSchema } from '/imports/api/properties/Features.js'; import { ComputedOnlyFolderSchema } from '/imports/api/properties/Folders.js'; import { ComputedOnlyItemSchema } from '/imports/api/properties/Items.js'; import { ComputedOnlyNoteSchema } from '/imports/api/properties/Notes.js'; +import { ComputedOnlyPointBuySchema } from '/imports/api/properties/PointBuys.js'; import { ComputedOnlyProficiencySchema } from '/imports/api/properties/Proficiencies.js'; import { ComputedOnlyReferenceSchema } from '/imports/api/properties/References.js'; import { ComputedOnlyRollSchema } from '/imports/api/properties/Rolls.js'; @@ -25,12 +27,14 @@ import { ComputedOnlySlotFillerSchema } from '/imports/api/properties/SlotFiller import { ComputedOnlySpellSchema } from '/imports/api/properties/Spells.js'; import { ComputedOnlySpellListSchema } from '/imports/api/properties/SpellLists.js'; import { ComputedOnlyToggleSchema } from '/imports/api/properties/Toggles.js'; +import { ComputedOnlyTriggerSchema } from '/imports/api/properties/Triggers.js'; const propertySchemasIndex = { action: ComputedOnlyActionSchema, adjustment: ComputedOnlyAdjustmentSchema, attribute: ComputedOnlyAttributeSchema, buff: ComputedOnlyBuffSchema, + buffRemover: ComputedOnlyBuffRemoverSchema, branch: ComputedOnlyBranchSchema, class: ComputedOnlyClassSchema, classLevel: ComputedOnlyClassLevelSchema, @@ -43,6 +47,7 @@ const propertySchemasIndex = { folder: ComputedOnlyFolderSchema, item: ComputedOnlyItemSchema, note: ComputedOnlyNoteSchema, + pointBuy: ComputedOnlyPointBuySchema, proficiency: ComputedOnlyProficiencySchema, propertySlot: ComputedOnlySlotSchema, reference: ComputedOnlyReferenceSchema, @@ -53,6 +58,7 @@ const propertySchemasIndex = { spellList: ComputedOnlySpellListSchema, spell: ComputedOnlySpellSchema, toggle: ComputedOnlyToggleSchema, + trigger: ComputedOnlyTriggerSchema, any: new SimpleSchema({}), }; diff --git a/app/imports/api/properties/computedPropertySchemasIndex.js b/app/imports/api/properties/computedPropertySchemasIndex.js index 1fa8c9d8..7bdfb302 100644 --- a/app/imports/api/properties/computedPropertySchemasIndex.js +++ b/app/imports/api/properties/computedPropertySchemasIndex.js @@ -3,6 +3,7 @@ import { ComputedActionSchema } from '/imports/api/properties/Actions.js'; import { ComputedAdjustmentSchema } from '/imports/api/properties/Adjustments.js'; import { ComputedAttributeSchema } from '/imports/api/properties/Attributes.js'; import { ComputedBuffSchema } from '/imports/api/properties/Buffs.js'; +import { ComputedBuffRemoverSchema } from '/imports/api/properties/BuffRemovers.js'; import { ComputedBranchSchema } from '/imports/api/properties/Branches.js'; import { ComputedClassSchema } from '/imports/api/properties/Classes.js'; import { ComputedClassLevelSchema } from '/imports/api/properties/ClassLevels.js'; @@ -15,6 +16,7 @@ import { ComputedFeatureSchema } from '/imports/api/properties/Features.js'; import { FolderSchema } 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'; import { ProficiencySchema } from '/imports/api/properties/Proficiencies.js'; import { ReferenceSchema } from '/imports/api/properties/References.js'; import { ComputedRollSchema } from '/imports/api/properties/Rolls.js'; @@ -25,12 +27,14 @@ 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'; +import { ComputedTriggerSchema } from '/imports/api/properties/Triggers.js'; const propertySchemasIndex = { action: ComputedActionSchema, adjustment: ComputedAdjustmentSchema, attribute: ComputedAttributeSchema, buff: ComputedBuffSchema, + buffRemover: ComputedBuffRemoverSchema, branch: ComputedBranchSchema, class: ComputedClassSchema, classLevel: ComputedClassLevelSchema, @@ -41,6 +45,7 @@ const propertySchemasIndex = { feature: ComputedFeatureSchema, folder: FolderSchema, note: ComputedNoteSchema, + pointBuy: ComputedPointBuySchema, proficiency: ProficiencySchema, propertySlot: ComputedSlotSchema, reference: ReferenceSchema, @@ -51,6 +56,7 @@ const propertySchemasIndex = { spellList: ComputedSpellListSchema, spell: ComputedSpellSchema, toggle: ComputedToggleSchema, + trigger: ComputedTriggerSchema, container: ComputedContainerSchema, item: ComputedItemSchema, any: new SimpleSchema({}), diff --git a/app/imports/api/properties/propertySchemasIndex.js b/app/imports/api/properties/propertySchemasIndex.js index 0cba80a0..fdccedc9 100644 --- a/app/imports/api/properties/propertySchemasIndex.js +++ b/app/imports/api/properties/propertySchemasIndex.js @@ -3,6 +3,7 @@ import { ActionSchema } from '/imports/api/properties/Actions.js'; import { AdjustmentSchema } from '/imports/api/properties/Adjustments.js'; import { AttributeSchema } from '/imports/api/properties/Attributes.js'; import { BuffSchema } from '/imports/api/properties/Buffs.js'; +import { BuffRemoverSchema } from '/imports/api/properties/BuffRemovers.js'; import { BranchSchema } from '/imports/api/properties/Branches.js'; import { ClassSchema } from '/imports/api/properties/Classes.js'; import { ClassLevelSchema } from '/imports/api/properties/ClassLevels.js'; @@ -13,6 +14,7 @@ import { EffectSchema } from '/imports/api/properties/Effects.js'; import { FeatureSchema } from '/imports/api/properties/Features.js'; import { FolderSchema } from '/imports/api/properties/Folders.js'; import { NoteSchema } from '/imports/api/properties/Notes.js'; +import { PointBuySchema } from '/imports/api/properties/PointBuys.js'; import { ProficiencySchema } from '/imports/api/properties/Proficiencies.js'; import { ReferenceSchema } from '/imports/api/properties/References.js'; import { RollSchema } from '/imports/api/properties/Rolls.js'; @@ -23,6 +25,7 @@ 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'; +import { TriggerSchema } from '/imports/api/properties/Triggers.js'; import { ContainerSchema } from '/imports/api/properties/Containers.js'; import { ItemSchema } from '/imports/api/properties/Items.js'; @@ -31,6 +34,7 @@ const propertySchemasIndex = { adjustment: AdjustmentSchema, attribute: AttributeSchema, buff: BuffSchema, + buffRemover: BuffRemoverSchema, branch: BranchSchema, class: ClassSchema, classLevel: ClassLevelSchema, @@ -41,6 +45,7 @@ const propertySchemasIndex = { feature: FeatureSchema, folder: FolderSchema, note: NoteSchema, + pointBuy: PointBuySchema, proficiency: ProficiencySchema, propertySlot: SlotSchema, reference: ReferenceSchema, @@ -51,6 +56,7 @@ const propertySchemasIndex = { spellList: SpellListSchema, spell: SpellSchema, toggle: ToggleSchema, + trigger: TriggerSchema, container: ContainerSchema, item: ItemSchema, any: new SimpleSchema({}), diff --git a/app/imports/api/properties/subSchemas/AdjustmentSchema.js b/app/imports/api/properties/subSchemas/AdjustmentSchema.js index 04031e2a..5ca96137 100644 --- a/app/imports/api/properties/subSchemas/AdjustmentSchema.js +++ b/app/imports/api/properties/subSchemas/AdjustmentSchema.js @@ -5,31 +5,31 @@ const AdjustmentSchema = new SimpleSchema({ _id: { type: String, regEx: SimpleSchema.RegEx.Id, - autoValue(){ + autoValue() { if (!this.isSet) return Random.id(); } }, - // The roll that determines how much to change the attribute + // The roll that determines how much to change the attribute adjustment: { type: String, optional: true, defaultValue: '1', }, - // Who this adjustment applies to - target: { - type: String, + // Who this adjustment applies to + target: { + type: String, defaultValue: 'every', - allowedValues: [ + allowedValues: [ 'self', // the character who took the action 'each', // rolled once for `each` target 'every', // rolled once and applied to `every` target ], - }, - // The stat this rolls applies to, if damage type is set, this is ignored - stat: { - type: String, + }, + // The stat this rolls applies to, if damage type is set, this is ignored + stat: { + type: String, optional: true, - }, + }, }); export default AdjustmentSchema; diff --git a/app/imports/api/properties/subSchemas/ColorSchema.js b/app/imports/api/properties/subSchemas/ColorSchema.js index f258c269..0597b336 100644 --- a/app/imports/api/properties/subSchemas/ColorSchema.js +++ b/app/imports/api/properties/subSchemas/ColorSchema.js @@ -1,12 +1,12 @@ import SimpleSchema from 'simpl-schema'; const ColorSchema = new SimpleSchema({ - color: { - type: String, - // match hex colors of the form #A23 or #A23f56 - regEx: /^#([a-f0-9]{3}){1,2}\b$/i, - optional: true, - }, + color: { + type: String, + // match hex colors of the form #A23 or #A23f56 + regEx: /^#([a-f0-9]{3}){1,2}\b$/i, + optional: true, + }, }); export default ColorSchema; diff --git a/app/imports/api/properties/subSchemas/DeathSavesSchema.js b/app/imports/api/properties/subSchemas/DeathSavesSchema.js index ea7f2933..b6238544 100644 --- a/app/imports/api/properties/subSchemas/DeathSavesSchema.js +++ b/app/imports/api/properties/subSchemas/DeathSavesSchema.js @@ -1,26 +1,26 @@ import SimpleSchema from 'simpl-schema'; const DeathSavesSchema = new SimpleSchema({ - pass: { - type: SimpleSchema.Integer, - min: 0, - max: 3, - defaultValue: 0, - }, - fail: { - type: SimpleSchema.Integer, - min: 0, - max: 3, - defaultValue: 0, - }, - canDeathSave: { - type: Boolean, - defaultValue: true, - }, - stable: { - type: Boolean, - defaultValue: false, - }, + pass: { + type: SimpleSchema.Integer, + min: 0, + max: 3, + defaultValue: 0, + }, + fail: { + type: SimpleSchema.Integer, + min: 0, + max: 3, + defaultValue: 0, + }, + canDeathSave: { + type: Boolean, + defaultValue: true, + }, + stable: { + type: Boolean, + defaultValue: false, + }, }); export default DeathSavesSchema; diff --git a/app/imports/api/properties/subSchemas/ErrorSchema.js b/app/imports/api/properties/subSchemas/ErrorSchema.js index 87835150..41635874 100644 --- a/app/imports/api/properties/subSchemas/ErrorSchema.js +++ b/app/imports/api/properties/subSchemas/ErrorSchema.js @@ -6,10 +6,10 @@ const ErrorSchema = new SimpleSchema({ type: String, max: STORAGE_LIMITS.errorMessage, }, - type: { + type: { type: String, max: STORAGE_LIMITS.name, - }, + }, }); export default ErrorSchema; diff --git a/app/imports/api/properties/subSchemas/InlineComputationSchema.js b/app/imports/api/properties/subSchemas/InlineComputationSchema.js index e220a3aa..14a33e75 100644 --- a/app/imports/api/properties/subSchemas/InlineComputationSchema.js +++ b/app/imports/api/properties/subSchemas/InlineComputationSchema.js @@ -3,7 +3,7 @@ import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; const InlineComputationSchema = new SimpleSchema({ - // The part between bracers {} + // The part between bracers {} calculation: { type: String, max: STORAGE_LIMITS.calculation, diff --git a/app/imports/api/properties/subSchemas/RollResultsSchema.js b/app/imports/api/properties/subSchemas/RollResultsSchema.js index 1d555ddc..8aea32e9 100644 --- a/app/imports/api/properties/subSchemas/RollResultsSchema.js +++ b/app/imports/api/properties/subSchemas/RollResultsSchema.js @@ -1,11 +1,11 @@ import SimpleSchema from 'simpl-schema'; import ResultsSchema from '/imports/api/properties/subSchemas/ResultsSchema.js'; -let RollResultsSchema = new SimpleSchema ({ +let RollResultsSchema = new SimpleSchema({ _id: { type: String, regEx: SimpleSchema.RegEx.Id, - autoValue(){ + autoValue() { if (!this.isSet) return Random.id(); } }, @@ -17,9 +17,9 @@ let RollResultsSchema = new SimpleSchema ({ optional: true, }, results: { - type: ResultsSchema, - defaultValue: {}, - }, + type: ResultsSchema, + defaultValue: {}, + }, }); -export default RollResultsSchema ; +export default RollResultsSchema; diff --git a/app/imports/api/properties/subSchemas/computedField.js b/app/imports/api/properties/subSchemas/computedField.js index 0f0ef37b..0d7ad114 100644 --- a/app/imports/api/properties/subSchemas/computedField.js +++ b/app/imports/api/properties/subSchemas/computedField.js @@ -28,6 +28,7 @@ function computedOnlyField(field){ [`${field}.effects`]: { type: Array, optional: true, + removeBeforeCompute: true, }, [`${field}.effects.$`]: { type: Object, diff --git a/app/imports/api/sharing/SharingSchema.js b/app/imports/api/sharing/SharingSchema.js index 77e5f939..2a4f87c3 100644 --- a/app/imports/api/sharing/SharingSchema.js +++ b/app/imports/api/sharing/SharingSchema.js @@ -4,35 +4,35 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let SharingSchema = new SimpleSchema({ owner: { - type: String, - regEx: SimpleSchema.RegEx.Id, - index: 1 - }, - readers: { - type: Array, - defaultValue: [], - index: 1, + type: String, + regEx: SimpleSchema.RegEx.Id, + index: 1 + }, + readers: { + type: Array, + defaultValue: [], + index: 1, maxCount: STORAGE_LIMITS.readersCount, - }, - 'readers.$': { - type: String, - regEx: SimpleSchema.RegEx.Id - }, - writers: { - type: Array, - defaultValue: [], - index: 1, + }, + 'readers.$': { + type: String, + regEx: SimpleSchema.RegEx.Id + }, + writers: { + type: Array, + defaultValue: [], + index: 1, maxCount: STORAGE_LIMITS.writersCount, - }, - 'writers.$': { - type: String, - regEx: SimpleSchema.RegEx.Id - }, - public: { - type: Boolean, - defaultValue: false, - index: 1, - }, + }, + 'writers.$': { + type: String, + regEx: SimpleSchema.RegEx.Id + }, + public: { + type: Boolean, + defaultValue: false, + index: 1, + }, }); export default SharingSchema; diff --git a/app/imports/api/sharing/sharing.js b/app/imports/api/sharing/sharing.js index d026d879..f6c8d8b7 100644 --- a/app/imports/api/sharing/sharing.js +++ b/app/imports/api/sharing/sharing.js @@ -9,8 +9,8 @@ import { getUserTier } from '/imports/api/users/patreon/tiers.js'; const setPublic = new ValidatedMethod({ name: 'sharing.setPublic', - validate: new SimpleSchema({ - docRef: RefSchema, + validate: new SimpleSchema({ + docRef: RefSchema, isPublic: { type: Boolean }, }).validator(), mixins: [RateLimiterMixin], @@ -18,19 +18,19 @@ const setPublic = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({docRef, isPublic}){ - let doc = fetchDocByRef(docRef); - assertOwnership(doc, this.userId); - return getCollectionByName(docRef.collection).update(docRef.id, { - $set: {public: isPublic}, + run({ docRef, isPublic }) { + let doc = fetchDocByRef(docRef); + assertOwnership(doc, this.userId); + return getCollectionByName(docRef.collection).update(docRef.id, { + $set: { public: isPublic }, }); - }, + }, }); const updateUserSharePermissions = new ValidatedMethod({ name: 'sharing.updateUserSharePermissions', - validate: new SimpleSchema({ - docRef: RefSchema, + validate: new SimpleSchema({ + docRef: RefSchema, userId: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -45,40 +45,40 @@ const updateUserSharePermissions = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({docRef, userId, role}){ - let doc = fetchDocByRef(docRef); - if (role === 'none'){ + run({ docRef, userId, role }) { + let doc = fetchDocByRef(docRef); + if (role === 'none') { // only assert ownership if you aren't removing yourself - if (this.userId !== userId){ + if (this.userId !== userId) { assertOwnership(doc, this.userId); } return getCollectionByName(docRef.collection).update(docRef.id, { $pullAll: { readers: userId, writers: userId }, }); } - if (doc.owner === userId){ + if (doc.owner === userId) { throw new Meteor.Error('Sharing update failed', - 'User is already the owner of this document'); + 'User is already the owner of this document'); } assertOwnership(doc, this.userId); - if (role === 'reader'){ + if (role === 'reader') { return getCollectionByName(docRef.collection).update(docRef.id, { $addToSet: { readers: userId }, $pullAll: { writers: userId }, }); - } else if (role === 'writer'){ + } else if (role === 'writer') { return getCollectionByName(docRef.collection).update(docRef.id, { $addToSet: { writers: userId }, $pullAll: { readers: userId }, }); } - }, + }, }); const transferOwnership = new ValidatedMethod({ name: 'sharing.transferOwnership', - validate: new SimpleSchema({ - docRef: RefSchema, + validate: new SimpleSchema({ + docRef: RefSchema, userId: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -89,31 +89,31 @@ const transferOwnership = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({docRef, userId}){ + run({ docRef, userId }) { let doc = fetchDocByRef(docRef); assertOwnership(doc, this.userId); let collection = getCollectionByName(docRef.collection); let tier = getUserTier(userId); - if (docRef.collection === 'creatures'){ + if (docRef.collection === 'creatures') { let currentCharacterCount = collection.find({ owner: userId, }, { - fields: {_id: 1}, + fields: { _id: 1 }, }).count(); if ( tier.characterSlots !== -1 && currentCharacterCount >= tier.characterSlots - ){ + ) { throw new Meteor.Error('Sharing.methods.transferOwnership.denied', - 'The new owner is already at their character limit') + 'The new owner is already at their character limit') } - } else if (docRef.collection === 'libraries'){ - if (!tier.paidBenefits){ + } else if (docRef.collection === 'libraries') { + if (!tier.paidBenefits) { throw new Meteor.Error('Sharing.methods.transferOwnership.denied', - 'The new owner\'s Patreon tier does not have access to library ownership'); + 'The new owner\'s Patreon tier does not have access to library ownership'); } } @@ -123,10 +123,10 @@ const transferOwnership = new ValidatedMethod({ }); // Then make the user the owner and the current owner a writer return collection.update(docRef.id, { - $set: {owner: userId}, + $set: { owner: userId }, $addToSet: { writers: this.userId }, }); - }, + }, }); export { setPublic, updateUserSharePermissions, transferOwnership }; diff --git a/app/imports/api/sharing/sharingPermissions.js b/app/imports/api/sharing/sharingPermissions.js index 6f48ed4b..58629c62 100644 --- a/app/imports/api/sharing/sharingPermissions.js +++ b/app/imports/api/sharing/sharingPermissions.js @@ -35,7 +35,7 @@ export function assertOwnership(doc, userId){ export function assertEditPermission(doc, userId) { assertIdValid(userId); assertdocExists(doc); - let user = Meteor.users.findOne(userId, { + const user = Meteor.users.findOne(userId, { fields: { 'services.patreon': 1, 'roles': 1, @@ -83,6 +83,7 @@ export function assertViewPermission(doc, userId) { assertdocExists(doc); if (doc.public) return true; assertIdValid(userId); + if ( doc.owner === userId || _.contains(doc.readers, userId) || @@ -90,6 +91,17 @@ export function assertViewPermission(doc, userId) { ){ return true; } else { + + // Admin override + const user = Meteor.users.findOne(userId, { + fields: { + 'roles': 1, + } + }); + if (user.roles && user.roles.includes('admin')){ + return true; + } + throw new Meteor.Error('View permission denied', 'You do not have permission to view this document'); } diff --git a/app/imports/api/tabletop/Messages.js b/app/imports/api/tabletop/Messages.js index b2dc0280..634f32d9 100644 --- a/app/imports/api/tabletop/Messages.js +++ b/app/imports/api/tabletop/Messages.js @@ -51,21 +51,21 @@ const sendMessage = new ValidatedMethod({ timeInterval: 5000, }, - run({content, tabletopId}) { + run({ content, tabletopId }) { let user = Meteor.user(); if (!user) { throw new Meteor.Error('messages.send.denied', - 'You need to be logged in to send a message'); + 'You need to be logged in to send a message'); } assertUserInTabletop(tabletopId, this.userId); return Messages.insert({ - content, + content, tabletopId, timestamp: new Date(), userId: user._id, username: user.username, - }); + }); }, }); @@ -87,24 +87,24 @@ const removeMessages = new ValidatedMethod({ timeInterval: 5000, }, - run({messageId, tabletopId}) { + run({ messageId, tabletopId }) { if (!this.userId) { throw new Meteor.Error('messages.remove.denied', - 'You need to be logged in to remove a tabletop'); + 'You need to be logged in to remove a tabletop'); } let message = Messages.findOne(messageId); let tabletop = Tabletops.findOne(message.tabletopId); - if (this.userId !== message.userId && this.userId !== tabletop.gameMaster){ + if (this.userId !== message.userId && this.userId !== tabletop.gameMaster) { throw new Meteor.Error('messages.remove.denied', - 'You don\'t have permission to remove this message'); + 'You don\'t have permission to remove this message'); } let removed = Messages.remove({ - _id: messageId, - }); + _id: messageId, + }); Creatures.update({ tabletop: tabletopId, }, { - $unset: {tabletop: 1}, + $unset: { tabletop: 1 }, }); return removed; }, diff --git a/app/imports/api/tabletop/methods/insertTabletop.js b/app/imports/api/tabletop/methods/insertTabletop.js index 0857031a..62ad2156 100644 --- a/app/imports/api/tabletop/methods/insertTabletop.js +++ b/app/imports/api/tabletop/methods/insertTabletop.js @@ -19,14 +19,14 @@ const insertTabletop = new ValidatedMethod({ run() { if (!this.userId) { throw new Meteor.Error('tabletops.insert.denied', - 'You need to be logged in to insert a tabletop'); + 'You need to be logged in to insert a tabletop'); } assertUserHasPaidBenefits(this.userId); assertAdmin(this.userId); return Tabletops.insert({ - gameMaster: this.userId, - }); + gameMaster: this.userId, + }); }, }); diff --git a/app/imports/api/tabletop/methods/removeTabletop.js b/app/imports/api/tabletop/methods/removeTabletop.js index 633cc6d0..4636eb4b 100644 --- a/app/imports/api/tabletop/methods/removeTabletop.js +++ b/app/imports/api/tabletop/methods/removeTabletop.js @@ -24,22 +24,22 @@ const removeTabletop = new ValidatedMethod({ timeInterval: 5000, }, - run({tabletopId}) { + run({ tabletopId }) { if (!this.userId) { throw new Meteor.Error('tabletops.remove.denied', - 'You need to be logged in to remove a tabletop'); + 'You need to be logged in to remove a tabletop'); } assertUserHasPaidBenefits(this.userId); assertUserIsTabletopOwner(tabletopId, this.userId); assertAdmin(this.userId); let removed = Tabletops.remove({ - _id: tabletopId, - }); + _id: tabletopId, + }); Creatures.update({ tabletop: tabletopId, }, { - $unset: {tabletop: 1}, + $unset: { tabletop: 1 }, }); return removed; }, diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js index 31a4d818..54328914 100644 --- a/app/imports/api/users/Users.js +++ b/app/imports/api/users/Users.js @@ -8,87 +8,97 @@ 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(',') || []; const userSchema = new SimpleSchema({ - username: { - type: String, - optional: true, - max: 30, - min: 4, - }, - emails: { - type: Array, - optional: true, - }, - 'emails.$': { - type: Object, - }, - 'emails.$.address': { - type: String, - regEx: SimpleSchema.RegEx.Email, - }, - 'emails.$.verified': { - type: Boolean, - }, - registered_emails: { - type: Array, - optional: true, - }, - 'registered_emails.$': { - type: Object, - blackbox: true, - }, - createdAt: { - type: Date - }, - services: { - type: Object, - optional: true, - blackbox: true, - }, - roles: { - type: Array, - optional: true, - }, - 'roles.$': { - type: String - }, - // In order to avoid an 'Exception in setInterval callback' from Meteor - heartbeat: { - type: Date, - optional: true, - }, - apiKey: { - type: String, - index: 1, - optional: true, - }, - darkMode: { - type: Boolean, - optional: true, - }, - subscribedLibraries: { - type: Array, - defaultValue: defaultLibraries, - max: 100, - }, - 'subscribedLibraries.$': { - type: String, + username: { + type: String, + optional: true, + max: 30, + min: 4, + }, + emails: { + type: Array, + optional: true, + }, + 'emails.$': { + type: Object, + }, + 'emails.$.address': { + type: String, + regEx: SimpleSchema.RegEx.Email, + }, + 'emails.$.verified': { + type: Boolean, + }, + registered_emails: { + type: Array, + optional: true, + }, + 'registered_emails.$': { + type: Object, + blackbox: true, + }, + createdAt: { + type: Date + }, + services: { + type: Object, + optional: true, + blackbox: true, + }, + roles: { + type: Array, + optional: true, + }, + 'roles.$': { + type: String + }, + // In order to avoid an 'Exception in setInterval callback' from Meteor + heartbeat: { + type: Date, + optional: true, + }, + apiKey: { + type: String, + index: 1, + optional: true, + }, + darkMode: { + type: Boolean, + optional: true, + }, + subscribedLibraries: { + type: Array, + defaultValue: defaultLibraries, + maxCount: 100, + }, + 'subscribedLibraries.$': { + type: String, regEx: SimpleSchema.RegEx.Id, - }, + }, + subscribedLibraryCollections: { + type: Array, + defaultValue: defaultLibraryCollections, + maxCount: 100, + }, + 'subscribedLibraryCollections.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, subscribedCharacters: { - type: Array, - defaultValue: [], - max: 100, - }, - 'subscribedCharacters.$': { - type: String, + type: Array, + defaultValue: [], + max: 100, + }, + 'subscribedCharacters.$': { + type: String, regEx: SimpleSchema.RegEx.Id, - }, - fileStorageUsed: { - type: Number, - optional: true, - }, + }, + fileStorageUsed: { + type: Number, + optional: true, + }, profile: { type: Object, blackbox: true, @@ -113,25 +123,25 @@ Meteor.users.attachSchema(userSchema); Meteor.users.generateApiKey = new ValidatedMethod({ name: 'users.generateApiKey', - validate: null, + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run(){ - if(Meteor.isClient) return; - var user = Meteor.users.findOne(this.userId); - if (!user) return; - if (user && user.apiKey) return; - var apiKey = Random.id(30); - Meteor.users.update(this.userId, {$set: {apiKey}}); - }, + run() { + if (Meteor.isClient) return; + var user = Meteor.users.findOne(this.userId); + if (!user) return; + if (user && user.apiKey) return; + var apiKey = Random.id(30); + Meteor.users.update(this.userId, { $set: { apiKey } }); + }, }); Meteor.users.setDarkMode = new ValidatedMethod({ name: 'users.setDarkMode', - validate: new SimpleSchema({ + validate: new SimpleSchema({ darkMode: { type: Boolean }, }).validator(), mixins: [RateLimiterMixin], @@ -139,81 +149,81 @@ Meteor.users.setDarkMode = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({darkMode}){ - if (!this.userId) return; - Meteor.users.update(this.userId, {$set: {darkMode}}); - }, + run({ darkMode }) { + if (!this.userId) return; + Meteor.users.update(this.userId, { $set: { darkMode } }); + }, }); Meteor.users.sendVerificationEmail = new ValidatedMethod({ - name: 'users.sendVerificationEmail', - validate: new SimpleSchema({ - userId:{ - type: String, - optional: true, - }, - address: { - type: String, - }, - }).validator(), + name: 'users.sendVerificationEmail', + validate: new SimpleSchema({ + userId: { + type: String, + optional: true, + }, + address: { + type: String, + }, + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({userId, address}){ - userId = this.userId || userId; - let user = Meteor.users.findOne(userId); - if (!user) { - throw new Meteor.Error('User not found', - 'Can\'t send a validation email to a user that does not exist'); - } - if (!some(user.emails, email => email.address === address)) { - throw new Meteor.Error('Email address not found', - 'The specified email address wasn\'t found on this user account'); - } - Accounts.sendVerificationEmail(userId, address); - } + run({ userId, address }) { + userId = this.userId || userId; + let user = Meteor.users.findOne(userId); + if (!user) { + throw new Meteor.Error('User not found', + 'Can\'t send a validation email to a user that does not exist'); + } + if (!some(user.emails, email => email.address === address)) { + throw new Meteor.Error('Email address not found', + 'The specified email address wasn\'t found on this user account'); + } + Accounts.sendVerificationEmail(userId, address); + } }); Meteor.users.canPickUsername = new ValidatedMethod({ - name: 'users.canPickUsername', - validate: userSchema.pick('username').validator(), + name: 'users.canPickUsername', + validate: userSchema.pick('username').validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({username}){ - if (Meteor.isClient) return; - let user = Accounts.findUserByUsername(username); + run({ username }) { + if (Meteor.isClient) return; + let user = Accounts.findUserByUsername(username); // You can pick your own username - if (user && user._id === this.userId){ + if (user && user._id === this.userId) { return false; } - return !!user; - } + return !!user; + } }); Meteor.users.setUsername = new ValidatedMethod({ name: 'users.setUsername', - validate: userSchema.pick('username').validator(), + validate: userSchema.pick('username').validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({username}){ - if (!this.userId) throw 'Can only set your username if logged in'; + run({ username }) { + if (!this.userId) throw 'Can only set your username if logged in'; if (Meteor.isClient) return; return Accounts.setUsername(this.userId, username) - } + } }); Meteor.users.setPreference = new ValidatedMethod({ name: 'users.setPreference', - validate: new SimpleSchema({ - preference:{ + validate: new SimpleSchema({ + preference: { type: String, }, value: { @@ -225,67 +235,97 @@ Meteor.users.setPreference = new ValidatedMethod({ numRequests: 5, timeInterval: 5000, }, - run({preference, value}){ - if (!this.userId) throw 'You can only set preferences once logged in'; + run({ preference, value }) { + if (!this.userId) throw 'You can only set preferences once logged in'; let prefPath = `preferences.${preference}` - if (value == true){ + if (value == true) { return Meteor.users.update(this.userId, { - $set: {[prefPath]: true}, + $set: { [prefPath]: true }, }); } else { return Meteor.users.update(this.userId, { - $unset: {[prefPath]: 1}, + $unset: { [prefPath]: 1 }, }); } - }, + }, }); Meteor.users.subscribeToLibrary = new ValidatedMethod({ name: 'users.subscribeToLibrary', - validate: new SimpleSchema({ - libraryId:{ - type: String, + validate: new SimpleSchema({ + libraryId: { + type: String, regEx: SimpleSchema.RegEx.Id, - }, + }, subscribe: { type: Boolean, }, - }).validator(), + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({libraryId, subscribe}){ - if (!this.userId) throw 'Can only subscribe if logged in'; - if (subscribe){ + run({ libraryId, subscribe }) { + if (!this.userId) throw 'Can only subscribe if logged in'; + if (subscribe) { return Meteor.users.update(this.userId, { - $addToSet: {subscribedLibraries: libraryId}, + $addToSet: { subscribedLibraries: libraryId }, }); } else { return Meteor.users.update(this.userId, { - $pullAll: {subscribedLibraries: libraryId}, + $pullAll: { subscribedLibraries: libraryId }, }); } - } + } }); -Meteor.users.findUserByUsernameOrEmail = new ValidatedMethod({ - name: 'users.findUserByUsernameOrEmail', - validate: new SimpleSchema({ - usernameOrEmail:{ - type: String, - }, - }).validator(), +Meteor.users.subscribeToLibraryCollection = new ValidatedMethod({ + name: 'users.subscribeToLibraryCollection', + validate: new SimpleSchema({ + libraryCollectionId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + subscribe: { + type: Boolean, + }, + }).validator(), mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, timeInterval: 5000, }, - run({usernameOrEmail}){ - if (Meteor.isClient) return; - let user = Accounts.findUserByUsername(usernameOrEmail) || - Accounts.findUserByEmail(usernameOrEmail); - return user && user._id; - } + run({ libraryCollectionId, subscribe }) { + if (!this.userId) throw 'Can only subscribe if logged in'; + if (subscribe) { + return Meteor.users.update(this.userId, { + $addToSet: { subscribedLibraryCollections: libraryCollectionId }, + }); + } else { + return Meteor.users.update(this.userId, { + $pullAll: { subscribedLibraryCollections: libraryCollectionId }, + }); + } + } +}); + +Meteor.users.findUserByUsernameOrEmail = new ValidatedMethod({ + name: 'users.findUserByUsernameOrEmail', + validate: new SimpleSchema({ + usernameOrEmail: { + type: String, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ usernameOrEmail }) { + if (Meteor.isClient) return; + let user = Accounts.findUserByUsername(usernameOrEmail) || + Accounts.findUserByEmail(usernameOrEmail); + return user && user._id; + } }); diff --git a/app/imports/api/users/methods/addEmail.js b/app/imports/api/users/methods/addEmail.js index af8e81f6..6abb2435 100644 --- a/app/imports/api/users/methods/addEmail.js +++ b/app/imports/api/users/methods/addEmail.js @@ -3,8 +3,8 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; const addEmail = new ValidatedMethod({ - name: 'users.addEmail', - validate: new SimpleSchema({ + name: 'users.addEmail', + validate: new SimpleSchema({ email: { type: String, regEx: SimpleSchema.RegEx.Email, @@ -15,20 +15,20 @@ const addEmail = new ValidatedMethod({ numRequests: 1, timeInterval: 5000, }, - run({email}){ + run({ email }) { const userId = Meteor.userId(); const user = Meteor.users.findOne(userId); if (!user) throw new Meteor.Error('No user', - 'You must be logged in to add an email address'); - if (user.emails && user.emails.length >= 2){ + 'You must be logged in to add an email address'); + if (user.emails && user.emails.length >= 2) { throw new Meteor.Error('Emails full', - 'You may only have up to 2 email addresses per account'); + 'You may only have up to 2 email addresses per account'); } - if (Meteor.isServer){ + if (Meteor.isServer) { Accounts.addEmail(userId, email); Accounts.sendVerificationEmail(userId, email); } - } + } }); export default addEmail; diff --git a/app/imports/api/users/methods/deleteMyAccount.js b/app/imports/api/users/methods/deleteMyAccount.js index f6947bfc..5c555855 100644 --- a/app/imports/api/users/methods/deleteMyAccount.js +++ b/app/imports/api/users/methods/deleteMyAccount.js @@ -1,31 +1,31 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import Libraries, {removeLibaryWork} from '/imports/api/library/Libraries.js'; +import Libraries, { removeLibaryWork } from '/imports/api/library/Libraries.js'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; -import {removeCreatureWork} from '/imports/api/creature/creatures/methods/removeCreature.js'; +import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; Meteor.users.deleteMyAccount = new ValidatedMethod({ - name: 'users.deleteMyAccount', - validate: null, + name: 'users.deleteMyAccount', + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 1, timeInterval: 5000, }, - run(){ + run() { let userId = Meteor.userId(); if (!userId) throw new Meteor.Error('No user', 'You must be logged in to delete your account'); // Delete all creatures - let creatures = Creatures.find({owner: userId}, {fields: {_id: 1}}).fetch(); + let creatures = Creatures.find({ owner: userId }, { fields: { _id: 1 } }).fetch(); creatures.forEach(creature => removeCreatureWork(creature._id)); // Remove permissions from all creatures Creatures.update({ $or: [ - {writers: userId}, - {readers: userId}, + { writers: userId }, + { readers: userId }, ], }, { $pull: { @@ -37,14 +37,14 @@ Meteor.users.deleteMyAccount = new ValidatedMethod({ }); // Delete all libraries - let libraries = Libraries.find({owner: userId}, {fields: {_id: 1}}).fetch(); + let libraries = Libraries.find({ owner: userId }, { fields: { _id: 1 } }).fetch(); libraries.forEach(library => removeLibaryWork(library._id)); // Remove permissions from all creatures Libraries.update({ $or: [ - {writers: userId}, - {readers: userId}, + { writers: userId }, + { readers: userId }, ], }, { $pull: { @@ -57,5 +57,5 @@ Meteor.users.deleteMyAccount = new ValidatedMethod({ // delete the account Meteor.users.remove(userId); - } + } }); diff --git a/app/imports/api/users/methods/removeEmail.js b/app/imports/api/users/methods/removeEmail.js index 86ffb822..06a14281 100644 --- a/app/imports/api/users/methods/removeEmail.js +++ b/app/imports/api/users/methods/removeEmail.js @@ -3,8 +3,8 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; const removeEmail = new ValidatedMethod({ - name: 'users.removeEmail', - validate: new SimpleSchema({ + name: 'users.removeEmail', + validate: new SimpleSchema({ email: { type: String, regEx: SimpleSchema.RegEx.Email, @@ -15,23 +15,23 @@ const removeEmail = new ValidatedMethod({ numRequests: 1, timeInterval: 5000, }, - run({email}){ + run({ email }) { const userId = Meteor.userId(); const user = Meteor.users.findOne(userId); if (!user) throw new Meteor.Error('No user', - 'You must be logged in to remove an email address'); - if (!user.emails){ + 'You must be logged in to remove an email address'); + if (!user.emails) { throw new Meteor.Error('No email to remove', - 'No email addresses are associated with this account'); + 'No email addresses are associated with this account'); } - if (user.emails.length == 1){ + if (user.emails.length == 1) { throw new Meteor.Error('Can\'t remove last email', - 'You may not remove the last email address from your account'); + 'You may not remove the last email address from your account'); } - if (Meteor.isServer){ + if (Meteor.isServer) { Accounts.removeEmail(userId, email); } - } + } }); export default removeEmail; diff --git a/app/imports/api/users/patreon/tiers.js b/app/imports/api/users/patreon/tiers.js index 2d789993..edd1d44a 100644 --- a/app/imports/api/users/patreon/tiers.js +++ b/app/imports/api/users/patreon/tiers.js @@ -110,8 +110,8 @@ export function getUserTier(user){ export function assertUserHasPaidBenefits(user){ let tier = getUserTier(user); if (!tier.paidBenefits){ - throw new Meteor.Error('Creatures.methods.insert.denied', - `The ${tier.name} tier does not allow you to insert a creature`); + throw new Meteor.Error('no paid benefits', + `The ${tier.name} tier does not have the required benefits`); } } diff --git a/app/imports/constants/DAMAGE_TYPES.js b/app/imports/constants/DAMAGE_TYPES.js index 9e544266..beb02518 100644 --- a/app/imports/constants/DAMAGE_TYPES.js +++ b/app/imports/constants/DAMAGE_TYPES.js @@ -13,6 +13,7 @@ const DAMAGE_TYPES = Object.freeze([ 'psychic', 'radiant', 'thunder', + 'extra', ]); export default DAMAGE_TYPES; diff --git a/app/imports/constants/MAINTENANCE_MODE.js b/app/imports/constants/MAINTENANCE_MODE.js index f81e01e4..42045410 100644 --- a/app/imports/constants/MAINTENANCE_MODE.js +++ b/app/imports/constants/MAINTENANCE_MODE.js @@ -6,6 +6,7 @@ if (Meteor.isServer){ const dbVersion = Migrations.getVersion(); if ( !Meteor.settings.public.maintenanceMode && + dbVersion !== undefined && SCHEMA_VERSION !== dbVersion ){ Meteor.settings.public.maintenanceMode = { diff --git a/app/imports/constants/PROPERTIES.js b/app/imports/constants/PROPERTIES.js index 4c35c038..f3db0cbe 100644 --- a/app/imports/constants/PROPERTIES.js +++ b/app/imports/constants/PROPERTIES.js @@ -2,12 +2,14 @@ const PROPERTIES = Object.freeze({ action: { icon: '$vuetify.icons.action', name: 'Action', + docsPath: 'property/action', helpText: 'Actions are things your character can do. When an action is taken, all the properties under it are activated.', suggestedParents: ['classLevel', 'feature', 'item'], }, attribute: { icon: '$vuetify.icons.attribute', name: 'Attribute', + docsPath: 'property/attribute', helpText: 'Attributes are the numbered statistics of your character, excluding rolls you might add proficiency bonus to, those are skills.', examples: 'Ability scores, speed, hit points, ki', suggestedParents: ['classLevel', 'buff'], @@ -15,42 +17,56 @@ const PROPERTIES = Object.freeze({ adjustment: { icon: '$vuetify.icons.attribute_damage', name: 'Attribute damage', + docsPath: 'property/attribute-damage', helpText: 'Attribute damage reduces the current value of an attribute when it is applied by an action. A negative value causes the attribute to increase instead, up to its normal maximum.', suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'], }, buff: { icon: '$vuetify.icons.buff', name: 'Buff', + docsPath: 'property/buff', helpText: 'When a buff is activated as a child of an action, it will copy the properties under itself onto a target character.', suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'], }, + buffRemover: { + icon: '$vuetify.icons.buffRemover', + name: 'Remove Buff', + docsPath: 'property/remove-buff', + helpText: 'Removes a buff from the target character', + suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'], + }, branch: { icon: 'mdi-file-tree', name: 'Branch', + docsPath: 'property/branch', helpText: 'When a branch is activated as a child of an action, it can control which of its children get activated.', suggestedParents: ['action', 'attack', 'savingThrow', 'spell'], }, class: { icon: 'mdi-card-account-details', name: 'Class', + docsPath: 'property/class', helpText: 'Your character should ideally have one starting class. Classes hold class levels', suggestedParents: [], }, classLevel: { icon: '$vuetify.icons.class_level', name: 'Class level', + docsPath: 'property/class-level', helpText: 'Class levels represent a single level gained in a class', suggestedParents: ['class'], }, constant: { icon: 'mdi-anchor', name: 'Constant', + docsPath: 'property/constant', helpText: 'A constant can define a static value that can be used in calculations elsewhere in the sheet', suggestedParents: [], }, container: { icon: 'mdi-bag-personal-outline', name: 'Container', + docsPath: 'property/container', helpText: 'A container holds items in the inventory', examples: 'Coin pouch, backpack', suggestedParents: ['folder'], @@ -58,18 +74,21 @@ const PROPERTIES = Object.freeze({ damage: { icon: '$vuetify.icons.damage', name: 'Damage', + docsPath: 'property/damage', helpText: 'When damage is activated by an action it reduces the hit points of the target creature by the calculated amount.', suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'], }, damageMultiplier: { icon: '$vuetify.icons.damage_multiplier', name: 'Damage multiplier', + docsPath: 'property/damage-multiplier', helpText: 'Resistance, vulnerability, and immunity.', suggestedParents: ['classLevel', 'feature', 'item'], }, effect: { icon: '$vuetify.icons.effect', name: 'Effect', + docsPath: 'property/effect', helpText: 'Effects change the value or state of attributes and skills.', examples: '+2 Strength, Advantage on dexterity saving throws', suggestedParents: ['buff', 'classLevel', 'feature', 'folder', 'item'], @@ -77,36 +96,49 @@ const PROPERTIES = Object.freeze({ feature: { icon: 'mdi-text-subject', name: 'Feature', + docsPath: 'property/feature', helpText: 'Descriptive or narrative features your character has access to', suggestedParents: ['classLevel', 'folder'], }, folder: { icon: 'mdi-folder-outline', name: 'Folder', + docsPath: 'property/feature', helpText: 'A way to organise other properties on the character', - suggestedParents: ['folder'], + suggestedParents: ['action', 'folder'], }, item: { icon: 'mdi-cube-outline', name: 'Item', + docsPath: 'property/item', helpText: 'Objects and equipment your charcter finds on their adventures', suggestedParents: ['container'], }, note: { icon: 'mdi-note-outline', name: 'Note', + docsPath: 'property/note', helpText: 'Notes about your character and their adventures', - suggestedParents: ['folder'], + suggestedParents: ['note', 'folder'], + }, + pointBuy: { + icon: 'mdi-table', + name: 'Point Buy', + docsPath: 'property/point-buy', + helpText: 'A point buy table that allows the user to select an array of values that match a given cost', + suggestedParents: [], }, proficiency: { icon: 'mdi-brightness-1', name: 'Proficiency', + docsPath: 'property/proficiency', helpText: 'Proficiencies apply your proficiency bonus to skills already on your character sheet.', suggestedParents: ['buff', 'classLevel', 'feature', 'folder'], }, roll: { icon: '$vuetify.icons.roll', name: 'Roll', + docsPath: 'property/roll', helpText: 'When activated by an action, rolls perform a calculation and temporarily store the result for other properties under the same action to use', suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'], }, @@ -120,45 +152,59 @@ const PROPERTIES = Object.freeze({ savingThrow: { icon: '$vuetify.icons.saving_throw', name: 'Saving throw', + docsPath: 'property/saving-throw', helpText: 'When a saving throw is activated by an action, it causes the target to make a saving throw, if the saving throw fails, the children properties of the saving throw are activated.', suggestedParents: ['action', 'attack', 'spell'], }, skill: { icon: '$vuetify.icons.skill', name: 'Skill', + docsPath: 'property/skill', helpText: 'Skills, saves, languages, and weapon and tool proficiencies are all skills. Skills can have a default proficiency set. Proficiencies and effects can change the value and state of skills.', suggestedParents: ['classLevel', 'folder'], }, propertySlot: { icon: 'mdi-power-socket-eu', name: 'Slot', + docsPath: 'property/slot', 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', - helpText: 'A slot filler allows for more advanced logic when it attemptst to fill a slot. It can masquarade as any property type, and calculate whether it should fill a slot or not.', + 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', + docsPath: 'property/spell-list', helpText: 'A list of spells on your character sheet. It can provide a DC and spell attack bonus to the spells within', suggestedParents: [], }, spell: { icon: '$vuetify.icons.spell', name: 'Spell', + docsPath: 'property/spell', helpText: 'A spell your character can potentially cast', suggestedParents: ['spellList'], }, toggle: { icon: '$vuetify.icons.toggle', name: 'Toggle', + docsPath: 'property/toggle', helpText: 'Togggles allow parts of the character sheet to be turned on and off, either manually or as the result of a calculation.', suggestedParents: [], }, + trigger: { + icon: 'mdi-electric-switch', + name: 'Trigger', + docsPath: 'property/trigger', + helpText: 'Triggers apply their children in response to events on the character sheet, such as taking an action or receiving damage', + suggestedParents: [], + }, }); export default PROPERTIES; @@ -170,3 +216,17 @@ export function getPropertyName(type){ export function getPropertyIcon(type){ return type && PROPERTIES[type] && PROPERTIES[type].icon; } + +const propsByDocsPath = new Map(); + +for (const key in PROPERTIES) { + const prop = PROPERTIES[key]; + if (prop.docsPath) { + propsByDocsPath.set(prop.docsPath, { + ...prop, + type: key, + }); + } +} + +export { propsByDocsPath }; diff --git a/app/imports/constants/STORAGE_LIMITS.js b/app/imports/constants/STORAGE_LIMITS.js index f2bd1e1f..cbc647e6 100644 --- a/app/imports/constants/STORAGE_LIMITS.js +++ b/app/imports/constants/STORAGE_LIMITS.js @@ -31,6 +31,8 @@ const STORAGE_LIMITS = Object.freeze({ statsToTarget: 64, tagCount: 64, writersCount: 20, + libraryCollectionCount: 32, + pointBuyRowsCount: 32, }); export default STORAGE_LIMITS; diff --git a/app/imports/constants/SVG_ICONS.js b/app/imports/constants/SVG_ICONS.js index 00ab7353..0ec43912 100644 --- a/app/imports/constants/SVG_ICONS.js +++ b/app/imports/constants/SVG_ICONS.js @@ -59,6 +59,10 @@ const SVG_ICONS = Object.freeze({ name: 'buff', shape: 'M331.924 20.385c-36.708.887-82.53 60.972-116.063 147.972h.003c30.564-65.57 71.17-106.39 97.348-99.378 28.058 7.516 37.11 69.42 24.847 148.405-.895-.32-1.773-.642-2.672-.96.893.367 1.765.738 2.65 1.106-2.988 19.215-7.22 39.424-12.767 60.12-2.77 10.332-5.763 20.39-8.936 30.14-24.996-3.82-52.374-9.537-80.82-17.16-105.856-28.36-186.115-72.12-179.307-97.53 4.257-15.884 42.167-23.775 95.908-20.29-74.427-8.7-128.912-2.044-135.035 20.803-9.038 33.73 89.168 89.372 219.147 124.2 24.436 6.55 48.267 11.897 70.918 16.042-28.965 75.878-68.293 126.078-96.653 118.48-21.817-5.85-35.995-45.443-36.316-100.206-4.79 75.476 9.278 131.945 40.66 140.356 38.836 10.407 91.394-54.998 127.896-152.98 80.12 10.74 138.958 4.278 145.38-19.682 6.384-23.82-41.025-58.44-115.102-89.03 20.713-109.022 8.483-198.5-31.96-209.34-2.968-.796-6.013-1.144-9.124-1.07zm40.568 213.086c44.65 22.992 71.146 47.135 67.07 62.348-4.055 15.13-38.104 20.457-87.333 16.303 3.415-10.604 6.64-21.502 9.63-32.663 4.176-15.588 7.713-30.965 10.632-45.986z', }, + 'perpendicular-rings-crossed-out': { + name: 'buffRemover', + shape: 'm 342.85706,29.520771 -10e-4,0.0017 C 311.09371,29.014264 281.87055,64.906082 247.60595,132.05856 L 172.54472,18.060082 142.22614,39.094548 433.13139,479.99983 459.83281,455.56605 385.30374,345.07055 c 60.47709,6.76324 95.40448,9.77027 101.5894,-10.08043 7.33528,-23.5444 -38.64515,-60.03954 -111.4339,-93.57921 25.07382,-108.10207 16.44748,-197.999126 -23.52751,-210.454283 -2.93364,-0.914533 -5.96321,-1.384872 -9.07467,-1.435856 z M 318.01727,76.44599 c 1.43939,0.165657 2.83703,0.458385 4.19071,0.879905 27.7335,8.636553 34.29121,70.855545 18.86666,149.284215 -0.88143,-0.35568 -1.74564,-0.7136 -2.63114,-1.06745 0.87755,0.40257 1.73435,0.80776 2.60387,1.21101 -3.29527,16.73399 -2.43292,17.48734 -7.73765,35.32955 L 249.50725,134.94386 C 277.16641,91.939481 296.57992,73.978789 318.01727,76.44599 Z M 21.309482,189.96942 c -10.385032,33.3398 85.507398,92.87964 213.982728,132.89854 18.67653,5.81939 37.00521,10.88752 54.75098,15.23025 -7.08963,-10.99585 -14.12857,-21.92647 -21.25144,-32.93262 -2.34002,-0.62085 -4.72317,-1.30046 -7.07998,-1.94408 -8.56911,-2.33994 -17.21776,-4.79347 -26.04044,-7.54193 C 131.03978,263.09208 52.602729,216.14328 60.425516,191.02722 65.316857,175.32699 103.51348,168.9655 157.07104,174.60556 121.54273,171.56302 37.239273,147.38028 21.309482,189.96942 Z M 374.83456,244.0657 c 43.69065,24.76623 69.19628,49.95346 64.51274,64.99048 -4.48269,14.38828 -26.92387,13.08335 -72.99123,7.92393 L 362.747,311.5007 c -5.21026,-7.91039 -3.28718,-12.58648 -0.38327,-21.91074 4.79853,-15.40771 8.94901,-30.63076 12.46879,-45.52239 z m -74.68362,109.69514 c -31.13564,67.76298 -69.48034,110.7402 -95.97392,102.48874 -21.56445,-6.72129 -34.14164,-46.85287 -32.26347,-101.58445 -7.81672,75.22256 3.97274,132.21064 34.99162,141.87493 32.83746,10.2293 77.9122,-34.67 115.55909,-108.20499 -7.40453,-11.47654 -14.88401,-23.0444 -22.31332,-34.57423 z' + }, 'roll': { name: 'roll', shape: 'M 339.33314,69.985523 152.23146,95.161367 297.9199,159.07076 Z m 13.13639,6.106743 -41.41324,89.085234 142.77019,70.18694 z M 286.72755,169.97878 132.07338,102.13811 116.91912,287.72473 Z m 23.20215,10.78603 19.43763,205.72115 132.11738,-131.21375 z m -14.47505,0.7893 -172.49845,119.61061 192.24446,89.36907 z M 115.16567,131.2299 49.03503,273.48548 l 52.96523,18.92787 z m 334.97786,155.72184 -114.74252,113.9631 48.61189,28.29247 z m -329.82146,28.96669 45.63657,144.20053 139.66241,-58.06123 z m -17.69094,-7.89455 -46.061724,-16.46047 81.264174,127.69506 z m 220.42858,102.47108 -107.73294,44.78793 150.00722,-20.18447 z', diff --git a/app/imports/migrations/methods/getVersion.js b/app/imports/migrations/methods/getVersion.js index 8b67d5fe..fdff2ab6 100644 --- a/app/imports/migrations/methods/getVersion.js +++ b/app/imports/migrations/methods/getVersion.js @@ -10,7 +10,7 @@ const dbVersionToGitVersion = { const getVersion = new ValidatedMethod({ name: 'admin.getVersion', - validate: null, + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 5, diff --git a/app/imports/migrations/methods/migrateTo.js b/app/imports/migrations/methods/migrateTo.js index f61fb9b3..98e1cf66 100644 --- a/app/imports/migrations/methods/migrateTo.js +++ b/app/imports/migrations/methods/migrateTo.js @@ -6,7 +6,7 @@ import { Migrations } from 'meteor/percolate:migrations'; const migrateTo = new ValidatedMethod({ name: 'admin.migrateTo', - validate: new SimpleSchema({ + validate: new SimpleSchema({ version: { type: SimpleSchema.oneOf( SimpleSchema.Integer, @@ -19,7 +19,7 @@ const migrateTo = new ValidatedMethod({ numRequests: 1, timeInterval: 10000, }, - run({version}) { + run({ version }) { if (Meteor.isClient) return; assertAdmin(this.userId); Migrations.migrateTo(version); diff --git a/app/imports/migrations/methods/validateDatabase.js b/app/imports/migrations/methods/validateDatabase.js index c555f3c5..09138f4f 100644 --- a/app/imports/migrations/methods/validateDatabase.js +++ b/app/imports/migrations/methods/validateDatabase.js @@ -4,7 +4,7 @@ import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js'; const validateDatabase = new ValidatedMethod({ name: 'validateDatabase', - validate: null, + validate: null, mixins: [RateLimiterMixin], rateLimit: { numRequests: 1, @@ -23,8 +23,8 @@ const validateDatabase = new ValidatedMethod({ const schema = collection.instance.simpleSchema(doc); let cleanDoc = schema.clean(doc); try { - schema.validate(cleanDoc, {modifier: false}); - } catch (e){ + schema.validate(cleanDoc, { modifier: false }); + } catch (e) { console.log(collection.name, doc._id, e.message || e.reason || e.toString()); } }); diff --git a/app/imports/migrations/server/dbv1/dbv1.js b/app/imports/migrations/server/dbv1/dbv1.js index b265cd9d..8ecfef81 100644 --- a/app/imports/migrations/server/dbv1/dbv1.js +++ b/app/imports/migrations/server/dbv1/dbv1.js @@ -1,9 +1,6 @@ import { Migrations } from 'meteor/percolate:migrations'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; -import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js'; -import { restoreCreature } from '/imports/api/creature/archive/methods/restoreCreatures.js'; -import { archiveCreature } from '/imports/api/creature/archive/methods/archiveCreatureToFile.js'; import transformFields from '/imports/migrations/server/transformFields.js'; import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; @@ -22,34 +19,11 @@ Migrations.add({ }); function migrate({reversed} = {}){ - console.log('restoring all characters from database archive'); - const restoredIds = restoreAllCreatures(); - console.log('migrating creature properties'); migrateCollection({collection: CreatureProperties, reversed}); console.log('migrating library nodes') migrateCollection({collection: LibraryNodes, reversed}); - - console.log('archiving characters to file system archive'); - rearchiveAllCreatures(restoredIds); -} - -function restoreAllCreatures(){ - const ids = []; - ArchivedCreatures.find({}, { - fields: {_id: 1} - }).forEach(archive => { - const id = restoreCreature(archive._id); - ids.push(id); - }); - return ids; -} - -function rearchiveAllCreatures(ids){ - ids.forEach(id => { - archiveCreature(id); - }); } function migrateCollection({collection, reversed}){ diff --git a/app/imports/migrations/server/dbv1/dbv1.test.js b/app/imports/migrations/server/dbv1/dbv1.test.js index 9a0887ac..efbd4fdd 100644 --- a/app/imports/migrations/server/dbv1/dbv1.test.js +++ b/app/imports/migrations/server/dbv1/dbv1.test.js @@ -124,6 +124,7 @@ const expectedMigratedAttribute = { damage: 3, value: 17, constitutionMod: 2, + dirty: true, } const exampleAttack = { @@ -221,6 +222,7 @@ describe('migrateProperty', function() { prop: newAction, reversed: true, }); + delete reversedAction.dirty; assert.deepEqual(action, exampleAction, 'action should not be bashed'); assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible'); }); diff --git a/app/imports/migrations/v1Migration/migrateCharacter.js b/app/imports/migrations/v1Migration/migrateCharacter.js deleted file mode 100644 index f4246d19..00000000 --- a/app/imports/migrations/v1Migration/migrateCharacter.js +++ /dev/null @@ -1,12 +0,0 @@ -import { fetch } from 'meteor/fetch' - -export default function importCharacter(url){ - // Using v1's JSON API to fetch the character data in a usable format - // url -> https://dicecloud.com/character//json?key= - fetch(url) - .then(response => response.json()) - .then(data => { - let character = data.characters[0]; - console.log(character.name + ' fetched successfuly') - }); -} diff --git a/app/imports/parser/functions.js b/app/imports/parser/functions.js index b0d16561..90a7e8df 100644 --- a/app/imports/parser/functions.js +++ b/app/imports/parser/functions.js @@ -112,10 +112,10 @@ export default { } }, 'resolve': { - comment: 'Forces the given calcultion to resolve into a number', + 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(3d6)', result: '2'}, + {input: 'resolve(1d6)', result: '4'}, ], arguments: ['parseNode'], fn: function resolveFn(node){ diff --git a/app/imports/parser/grammar.js b/app/imports/parser/grammar.js index d8a449f8..ec217527 100644 --- a/app/imports/parser/grammar.js +++ b/app/imports/parser/grammar.js @@ -4,7 +4,7 @@ function id(x) { return x[0]; } import node from './parseTree/_index.js'; - import moo from 'moo'; + import moo from 'moo'; const lexer = moo.compile({ number: /[0-9]+(?:\.[0-9]+)?/, diff --git a/app/imports/parser/grammar.ne b/app/imports/parser/grammar.ne index e2f7d1bf..51d1ece2 100644 --- a/app/imports/parser/grammar.ne +++ b/app/imports/parser/grammar.ne @@ -2,7 +2,7 @@ @{% import node from './parseTree/_index.js'; - import moo from 'moo'; + import moo from 'moo'; const lexer = moo.compile({ number: /[0-9]+(?:\.[0-9]+)?/, diff --git a/app/imports/parser/parseTree/accessor.js b/app/imports/parser/parseTree/accessor.js index 64ef1ba9..920e1e6b 100644 --- a/app/imports/parser/parseTree/accessor.js +++ b/app/imports/parser/parseTree/accessor.js @@ -1,4 +1,5 @@ import constant from './constant.js'; +// import array from './array.js'; import { toString } from '../resolve.js'; const accessor = { @@ -9,18 +10,18 @@ const accessor = { name, }; }, - compile(node, scope, context){ + compile(node, scope, context) { let value = scope && scope[node.name]; // For objects, get their value node.path.forEach(name => { if (value === undefined) return; value = value[name]; }); - let valueType = typeof value; + let valueType = Array.isArray(value) ? 'array' : typeof value; // If the accessor returns an objet, get the object's value instead while (valueType === 'object'){ value = value.value; - valueType = typeof value; + valueType = Array.isArray(value) ? 'array' : typeof value; } // Return a parse node based on the type returned if (valueType === 'string' || valueType === 'number' || valueType === 'boolean'){ @@ -31,7 +32,21 @@ const accessor = { }), context, }; - } else if (valueType === 'undefined'){ + } + /* Can't access #object.tags until this is fixed + * If we activate this, the array node expects values to be an array of + * parse nodes, so it will break unless the values are coerced here or at + * in the array node's code to be parse nodes, not raw js + else if (valueType === 'array') { + return { + result: array.create({ + values: value, + }), + context, + }; + } + */ + else if (valueType === 'undefined') { return { result: accessor.create({ name: node.name, @@ -40,8 +55,7 @@ const accessor = { context, }; } else { - context.error(`${node.name} returned an unexpected type`); - context.error(JSON.stringify(value, null, 2)); + context.error(`Accessing ${accessor.toString(node)} is not supported yet`); return { result: accessor.create({ name: node.name, diff --git a/app/imports/parser/parseTree/index.js b/app/imports/parser/parseTree/index.js index 64f9945c..d866cc38 100644 --- a/app/imports/parser/parseTree/index.js +++ b/app/imports/parser/parseTree/index.js @@ -2,23 +2,23 @@ import resolve, { traverse, toString, map } from '../resolve'; import error from './error'; const indexNode = { - create({array, index}) { - return { + create({ array, index }) { + return { parseType: 'index', array, index, } }, - resolve(fn, node, scope, context){ - let {result: index} = resolve(fn, node.index, scope, context); - let {result: array} = resolve(fn, node.array, scope, context); + resolve(fn, node, scope, context) { + let { result: index } = resolve(fn, node.index, scope, context); + let { result: array } = resolve(fn, node.array, scope, context); if ( index.valueType === 'number' && Number.isInteger(index.value) && array.parseType === 'array' - ){ - if (index.value < 1 || index.value > array.values.length){ + ) { + if (index.value < 1 || index.value > array.values.length) { context.error({ type: 'warning', message: `Index of ${index.value} is out of range for an array` + @@ -26,11 +26,11 @@ const indexNode = { }); } let selection = array.values[index.value - 1]; - if (selection){ + if (selection) { return resolve(fn, selection, scope, context); } - } else if (fn === 'reduce'){ - if (array.parseType !== 'array'){ + } else if (fn === 'reduce') { + if (array.parseType !== 'array') { const message = `Can not get the index of a non-array node: ${toString(node.array)} = ${toString(array)}` context.error(message); return { @@ -40,7 +40,7 @@ const indexNode = { }), context, }; - } else if (!index.isInteger){ + } else if (!index.isInteger) { const message = `${toString(array)} is not an integer index of the array` context.error(message); return { @@ -60,17 +60,17 @@ const indexNode = { context, }; }, - toString(node){ + toString(node) { return `${toString(node.array)}[${toString(node.index)}]`; }, - traverse(node, fn){ + traverse(node, fn) { fn(node); traverse(node.array, fn); traverse(node.index, fn); }, - map(node, fn){ + map(node, fn) { const resultingNode = fn(node); - if (resultingNode === node){ + if (resultingNode === node) { node.array = map(node.array, fn); node.index = map(node.index, fn); } diff --git a/app/imports/parser/parseTree/rollArray.js b/app/imports/parser/parseTree/rollArray.js index d8b0c016..969b948d 100644 --- a/app/imports/parser/parseTree/rollArray.js +++ b/app/imports/parser/parseTree/rollArray.js @@ -1,24 +1,24 @@ import constant from './constant.js'; const rollArray = { - create({values, diceSize, diceNum}) { - return { + create({ values, diceSize, diceNum }) { + return { parseType: 'rollArray', values, diceSize, diceNum, }; }, - compile(node, scope, context){ + compile(node, scope, context) { return { result: node, context }; }, - toString(node){ + toString(node) { return `${node.diceNum || ''}d${node.diceSize} [ ${node.values.join(', ')} ]`; }, - reduce(node, scope, context){ + reduce(node, scope, context) { const total = node.values.reduce((a, b) => a + b, 0); return { result: constant.create({ diff --git a/app/imports/api/engine/actions/tests/testTypes/applyAction.testFn.js b/app/imports/server/action.js similarity index 100% rename from app/imports/api/engine/actions/tests/testTypes/applyAction.testFn.js rename to app/imports/server/action.js diff --git a/app/imports/server/config/SyncedCronConfig.js b/app/imports/server/config/SyncedCronConfig.js index 8ef48fec..21ab1abd 100644 --- a/app/imports/server/config/SyncedCronConfig.js +++ b/app/imports/server/config/SyncedCronConfig.js @@ -1,4 +1,4 @@ -import { SyncedCron } from 'meteor/percolate:synced-cron'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; SyncedCron.config({ // Log job run details to console diff --git a/app/imports/server/cron/deleteSoftRemovedDocuments.js b/app/imports/server/cron/deleteSoftRemovedDocuments.js index 3758aa27..b22d0293 100644 --- a/app/imports/server/cron/deleteSoftRemovedDocuments.js +++ b/app/imports/server/cron/deleteSoftRemovedDocuments.js @@ -1,50 +1,50 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js'; -import { SyncedCron } from 'meteor/percolate:synced-cron'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; Meteor.startup(() => { - const collections = [ - CreatureProperties, + const collections = [ + CreatureProperties, LibraryNodes, - ]; + ]; - /** - * Deletes all soft removed documents that were removed more than 1 day ago - * and were not restored - * @return {Number} Number of documents removed - */ - const deleteOldSoftRemovedDocs = function(){ - const now = new Date(); + /** + * Deletes all soft removed documents that were removed more than 1 day ago + * and were not restored + * @return {Number} Number of documents removed + */ + const deleteOldSoftRemovedDocs = function () { + const now = new Date(); const yesterday = new Date(now.getTime() - (24 * 60 * 60 * 1000)); - collections.forEach(collection => { - collection.remove({ - removed: true, - removedAt: {$lt: yesterday} // dates *before* yesterday - }, function(error){ - if (error){ - console.error(JSON.stringify(error, null, 2)); - } - }); - }); - }; + collections.forEach(collection => { + collection.remove({ + removed: true, + removedAt: { $lt: yesterday } // dates *before* yesterday + }, function (error) { + if (error) { + console.error(JSON.stringify(error, null, 2)); + } + }); + }); + }; - SyncedCron.add({ - name: 'deleteSoftRemovedDocs', - schedule: function(parser) { - return parser.text('every 10 minutes'); - }, - job: deleteOldSoftRemovedDocs, - }); + SyncedCron.add({ + name: 'deleteSoftRemovedDocs', + schedule: function (parser) { + return parser.text('every 10 minutes'); + }, + job: deleteOldSoftRemovedDocs, + }); - SyncedCron.start(); + SyncedCron.start(); - // Add a method to manually trigger removal - Meteor.methods({ - deleteOldSoftRemovedDocs() { + // Add a method to manually trigger removal + Meteor.methods({ + deleteOldSoftRemovedDocs() { assertAdmin(this.userId); - this.unblock(); - deleteOldSoftRemovedDocs(); - }, - }); + this.unblock(); + deleteOldSoftRemovedDocs(); + }, + }); }); diff --git a/app/imports/server/imageProcessing/createThumbnail.js b/app/imports/server/imageProcessing/createThumbnail.js new file mode 100644 index 00000000..4837133d --- /dev/null +++ b/app/imports/server/imageProcessing/createThumbnail.js @@ -0,0 +1,8 @@ +import * as sharp from 'sharp'; + +export default async function createThumbnail(image) { + await sharp(image) + .resize(320, 240) + .png() + .toBuffer(); +} diff --git a/app/imports/server/publications/archivedCreatures.js b/app/imports/server/publications/archivedCreatures.js deleted file mode 100644 index 93043407..00000000 --- a/app/imports/server/publications/archivedCreatures.js +++ /dev/null @@ -1,19 +0,0 @@ -import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js'; - -Meteor.publish('archivedCreatures', function(){ - this.autorun(function (){ - var userId = this.userId; - if (!userId) { - return []; - } - return ArchivedCreatures.find({ - owner: userId, - }, { - fields: { - creature: 1, - owner: 1, - } - } - ); - }); -}); diff --git a/app/imports/server/publications/docs.js b/app/imports/server/publications/docs.js new file mode 100644 index 00000000..2bc3a7b4 --- /dev/null +++ b/app/imports/server/publications/docs.js @@ -0,0 +1,35 @@ +import { propsByDocsPath } from '/imports/constants/PROPERTIES.js'; + + +// Manual doc paths +const docPaths = [ + 'computed-fields', + 'inline-calculations', + 'dependency-loops', + 'docs', + 'tags', + 'walkthroughs/create-a-class', +]; +const docs = new Map(); +docPaths.forEach(path => { + docs.set(path, Assets.getText(`docs/${path}.md`)) +}); + +// Doc paths for properties +propsByDocsPath.forEach(prop => { + docs.set(prop.docsPath, Assets.getText(`docs/${prop.docsPath}.md`)); +}); + +Meteor.publish('docs', function (path) { + if (!path) { + docs.forEach((text, path) => { + this.added('docs', path, { text }); + }); + } else { + const text = docs.get(path); + if (text) { + this.added('docs', path, { text }); + } + } + this.ready(); +}); diff --git a/app/imports/server/publications/icons.js b/app/imports/server/publications/icons.js index a289fdcf..fc5313a2 100644 --- a/app/imports/server/publications/icons.js +++ b/app/imports/server/publications/icons.js @@ -1,16 +1,16 @@ import Icons from '/imports/api/icons/Icons.js'; -Meteor.publish('sampleIcons', function(){ - return Icons.find({}, {limit: 50}); +Meteor.publish('sampleIcons', function () { + return Icons.find({}, { limit: 50 }); }); -Meteor.publish('searchIcons', function(searchValue) { +Meteor.publish('searchIcons', function (searchValue) { // Don't publish anything if there's no search value if (!searchValue) { return []; } return Icons.find( - { $text: {$search: searchValue} }, + { $text: { $search: searchValue } }, { // relevant documents have a higher score. fields: { diff --git a/app/imports/server/publications/index.js b/app/imports/server/publications/index.js index 3bdc770a..bb74b5b5 100644 --- a/app/imports/server/publications/index.js +++ b/app/imports/server/publications/index.js @@ -8,6 +8,7 @@ import '/imports/server/publications/icons.js'; import '/imports/server/publications/tabletops.js'; import '/imports/server/publications/slotFillers.js'; import '/imports/server/publications/ownedDocuments.js'; -import '/imports/server/publications/archivedCreatures.js'; import '/imports/server/publications/searchLibraryNodes.js'; import '/imports/server/publications/archiveFiles.js'; +import '/imports/server/publications/userImages.js'; +import '/imports/server/publications/docs.js'; diff --git a/app/imports/server/publications/library.js b/app/imports/server/publications/library.js index 41e2b080..67dcf74a 100644 --- a/app/imports/server/publications/library.js +++ b/app/imports/server/publications/library.js @@ -1,27 +1,136 @@ import SimpleSchema from 'simpl-schema'; import Libraries from '/imports/api/library/Libraries.js'; +import LibraryCollections from '/imports/api/library/LibraryCollections.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; -import { assertViewPermission } from '/imports/api/sharing/sharingPermissions.js'; +import { assertViewPermission, assertDocViewPermission } from '/imports/api/sharing/sharingPermissions.js'; +import { union } from 'lodash'; -Meteor.publish('libraries', function(){ - this.autorun(function (){ +const LIBRARY_NODE_TREE_FIELDS = { + _id: 1, + name: 1, + type: 1, + icon: 1, + color: 1, + order: 1, + parent: 1, + ancestors: 1, + tags: 1, + slotFillerCondition: 1, + removed: 1, + removedAt: 1, + // SlotFillers + slotQuantityFilled: 1, + slotFillerType: 1, + // Effect + operation: 1, + targetTags: 1, + stats: 1, + // Item + quantity: 1, + plural: 1, + equipped: 1, + // Branch + branchType: 1, + // Damage: + damageType: 1, + stat: 1, + amount: 1, + // Class level + level: 1, + variableName: 1, + // Proficiency + value: 1, + // Reference + cache: 1, + // Saving throw + dc: 1, +} + +export { LIBRARY_NODE_TREE_FIELDS }; + +Meteor.publish('libraryCollection', function (libraryCollectionId) { + this.autorun(function () { + let userId = this.userId; + if (!userId) return []; + this.autorun(function () { + const libraryCollectionCursor = LibraryCollections.find({ + _id: libraryCollectionId, + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { public: true }, + ] + }); + const libraryCollection = libraryCollectionCursor.fetch()[0]; + if (!libraryCollection) return [ libraryCollectionCursor ]; + this.autorun(function () { + const libraryCursor = Libraries.find({ + _id: {$in: libraryCollection.libraries}, + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { public: true }, + ] + }, { + sort: { name: 1 } + }); + return [ libraryCollectionCursor, libraryCursor ]; + }); + }); + }) +}); + +Meteor.publish('libraries', function () { + this.autorun(function () { let userId = this.userId; if (!userId) { return []; } const user = Meteor.users.findOne(userId, { - fields: {subscribedLibraries: 1} + fields: { subscribedLibraries: 1, subscribedLibraryCollections: 1 } }); - const subs = user && user.subscribedLibraries || []; - return Libraries.find({ - $or: [ - {owner: this.userId}, - {writers: this.userId}, - {readers: this.userId}, - { _id: {$in: subs}, public: true }, - ] - }, { - sort: {name: 1} + + this.autorun(function () { + // Get the collections the user is subscribed to + const subCollections = user && user.subscribedLibraryCollections || []; + const libraryCollectionsCursor = LibraryCollections.find({ + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: subCollections }, public: true }, + ] + }, { + sort: { name: 1 } + }); + + // Collate all the libraryIds in those collections + let collectionLibIds = []; + libraryCollectionsCursor.forEach(libCollection => { + collectionLibIds = union(collectionLibIds, libCollection.libraries); + }); + + // Get the libraries the user is subscribed to directly + const subs = user && user.subscribedLibraries || []; + + // Combine all the library Ids + const libIds = union(collectionLibIds, subs); + + this.autorun(function () { + const librariesCursor = Libraries.find({ + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: libIds }, public: true }, + ] + }, { + sort: { name: 1 } + }); + return [librariesCursor, libraryCollectionsCursor]; + }); }); }); }); @@ -63,12 +172,35 @@ Meteor.publish('libraryNodes', function(libraryId){ LibraryNodes.find({ 'ancestors.id': libraryId, }, { - sort: {order: 1}, + sort: { order: 1 }, + fields: LIBRARY_NODE_TREE_FIELDS, }), ]; }); }); +const nodeIdSchema = new SimpleSchema({ + libraryNodeId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, +}); + +Meteor.publish('libraryNode', function (libraryNodeId) { + if (!libraryNodeId) return []; + nodeIdSchema.validate({ libraryNodeId }); + this.autorun(function () { + const userId = this.userId; + const nodeCursor = LibraryNodes.find({_id: libraryNodeId}); + let node = nodeCursor.fetch()[0]; + try { assertDocViewPermission(node, userId) } + catch (e) { + return this.error(e); + } + return [ nodeCursor ]; + }); +}); + Meteor.publish('softRemovedLibraryNodes', function(libraryId){ if (!libraryId) return []; libraryIdSchema.validate({libraryId}); diff --git a/app/imports/server/publications/searchLibraryNodes.js b/app/imports/server/publications/searchLibraryNodes.js index ddbbf4ba..cedbc7a8 100644 --- a/app/imports/server/publications/searchLibraryNodes.js +++ b/app/imports/server/publications/searchLibraryNodes.js @@ -1,6 +1,8 @@ import { check } from 'meteor/check'; import Libraries from '/imports/api/library/Libraries.js'; 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'; Meteor.publish('selectedLibraryNodes', function(selectedNodeIds){ @@ -37,7 +39,7 @@ Meteor.publish('selectedLibraryNodes', function(selectedNodeIds){ })]; }); -Meteor.publish('searchLibraryNodes', function(){ +Meteor.publish('searchLibraryNodes', function(creatureId){ let self = this; this.autorun(function (){ let type = self.data('type'); @@ -49,23 +51,12 @@ Meteor.publish('searchLibraryNodes', function(){ } // Get all the ids of libraries the user can access - const user = Meteor.users.findOne(userId, { - fields: {subscribedLibraries: 1} - }); - if (!user) return []; - - const subs = user.subscribedLibraries || []; - let libraries = Libraries.find({ - $or: [ - {owner: this.userId}, - {writers: this.userId}, - {readers: this.userId}, - {_id: {$in: subs}}, - ] - }, { - fields: {_id: 1, name: 1}, - }); - let libraryIds = libraries.map(lib => lib._id); + let libraryIds; + if (creatureId) { + libraryIds = getCreatureLibraryIds(creatureId, userId) + } else { + libraryIds = getUserLibraryIds(userId) + } // Build a filter for nodes in those libraries that match the type let filter = { @@ -122,6 +113,7 @@ Meteor.publish('searchLibraryNodes', function(){ }); let cursor = LibraryNodes.find(filter, options); + const libraries = Libraries.find({ _id: { $in: libraryIds } }); Mongo.Collection._publishCursor(libraries, self, 'libraries'); diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index 611f1d23..28d2da6f 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -1,10 +1,12 @@ import SimpleSchema from 'simpl-schema'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import computeCreature from '/imports/api/engine/computeCreature.js'; import VERSION from '/imports/constants/VERSION.js'; +import { loadCreature } from '/imports/api/engine/loadCreatures.js'; let schema = new SimpleSchema({ creatureId: { @@ -13,7 +15,8 @@ let schema = new SimpleSchema({ }, }); -Meteor.publish('singleCharacter', function(creatureId){ +Meteor.publish('singleCharacter', function (creatureId) { + const self = this; try { schema.validate({ creatureId }); } catch (e){ @@ -21,21 +24,27 @@ Meteor.publish('singleCharacter', function(creatureId){ } this.autorun(function (computation){ let userId = this.userId; - let creatureCursor - creatureCursor = Creatures.find({ + let permissionCreature = Creatures.findOne({ _id: creatureId, + }, { + fields: { owner: 1, readers: 1, writers: 1, public: 1, computeVersion: 1 } }); - let creature = creatureCursor.fetch()[0]; - try { assertViewPermission(creature, userId) } - catch(e){ return [] } - if (creature.computeVersion !== VERSION && computation.firstRun){ + try { assertViewPermission(permissionCreature, userId) } + catch (e) { return [] } + loadCreature(creatureId, self); + if (permissionCreature.computeVersion !== VERSION && computation.firstRun){ try { computeCreature(creatureId) } catch(e){ console.error(e) } } return [ - creatureCursor, + Creatures.find({ + _id: creatureId, + }), + CreatureVariables.find({ + _creatureId: creatureId, + }), CreatureProperties.find({ 'ancestors.id': creatureId, }), diff --git a/app/imports/server/publications/slotFillers.js b/app/imports/server/publications/slotFillers.js index c5b3397f..6427af79 100644 --- a/app/imports/server/publications/slotFillers.js +++ b/app/imports/server/publications/slotFillers.js @@ -3,6 +3,8 @@ import Libraries from '/imports/api/library/Libraries.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; 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'; Meteor.publish('slotFillers', function(slotId, searchTerm){ if (searchTerm) check(searchTerm, String); @@ -20,21 +22,18 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ } // Get all the ids of libraries the user can access - const user = Meteor.users.findOne(userId, { - fields: {subscribedLibraries: 1} - }); - const subs = user && user.subscribedLibraries || []; - let libraries = Libraries.find({ + const creatureId = slot.ancestors[0].id; + const libraryIds = getCreatureLibraryIds(creatureId, userId); + const libraries = Libraries.find({ $or: [ - {owner: this.userId}, - {writers: this.userId}, - {readers: this.userId}, - {_id: {$in: subs}}, + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: libraryIds }, public: true }, ] }, { - fields: {_id: 1, name: 1}, + sort: { name: 1 } }); - let libraryIds = libraries.map(lib => lib._id); // Build a filter for nodes in those libraries that match the slot let filter = getSlotFillFilter({slot, libraryIds}); @@ -50,7 +49,8 @@ Meteor.publish('slotFillers', function(slotId, 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. @@ -61,10 +61,13 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ } } else { delete filter.$text - options = {sort: { - name: 1, - order: 1, - }}; + options = { + sort: { + name: 1, + order: 1, + }, + fields: LIBRARY_NODE_TREE_FIELDS, + }; } options.limit = limit; @@ -72,7 +75,65 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){ self.setData('countAll', LibraryNodes.find(filter).count()); }); self.autorun(function () { - Meteor._sleepForMs(1000); + return [ + LibraryNodes.find(filter, options), + libraries + ]; + }); + }); + }); +}); + +Meteor.publish('classFillers', function(classId){ + let self = this; + if (!classId) return []; + + this.autorun(function (){ + let userId = this.userId; + if (!userId) { + return []; + } + // Get the class + let classProp = CreatureProperties.findOne(classId); + if (!classProp){ + return []; + } + + // Get all the ids of libraries the user can access + const creatureId = classProp.ancestors[0].id; + const libraryIds = getCreatureLibraryIds(creatureId, userId); + const libraries = Libraries.find({ + $or: [ + { owner: userId }, + { writers: userId }, + { readers: userId }, + { _id: { $in: libraryIds }, public: true }, + ] + }, { + sort: { name: 1 } + }); + + // Build a filter for nodes in those libraries that match the slot + let filter = getSlotFillFilter({slot: classProp, 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 = { + sort: { + name: 1, + order: 1, + }, + fields: LIBRARY_NODE_TREE_FIELDS, + limit, + }; + + self.autorun(function () { + self.setData('countAll', LibraryNodes.find(filter).count()); + }); + self.autorun(function () { return [LibraryNodes.find(filter, options), libraries]; }); }); diff --git a/app/imports/server/publications/userImages.js b/app/imports/server/publications/userImages.js new file mode 100644 index 00000000..765b607a --- /dev/null +++ b/app/imports/server/publications/userImages.js @@ -0,0 +1,7 @@ +import UserImages from '/imports/api/files/UserImages.js'; + +Meteor.publish('userImages', function () { + return UserImages.find({ + userId: this.userId, + }).cursor; +}); diff --git a/app/imports/server/publications/users.js b/app/imports/server/publications/users.js index 289f60dc..b6e59e82 100644 --- a/app/imports/server/publications/users.js +++ b/app/imports/server/publications/users.js @@ -2,30 +2,33 @@ import SimpleSchema from 'simpl-schema'; import '/imports/api/users/Users.js'; import Invites from '/imports/api/users/Invites.js'; -Meteor.publish('user', function(){ - return [ - Meteor.users.find(this.userId, {fields: { - roles: 1, - username: 1, - apiKey: 1, - darkMode: 1, - subscribedLibraries: 1, - fileStorageUsed: 1, - profile: 1, - preferences: 1, - 'services.patreon.id': 1, - 'services.patreon.entitledCents': 1, - 'services.patreon.entitledCentsOverride': 1, - 'services.google.id': 1, - 'services.google.picture': 1, - 'services.google.name': 1, - 'services.google.email': 1, - 'services.google.locale': 1, - }}), +Meteor.publish('user', function () { + return [ + Meteor.users.find(this.userId, { + fields: { + roles: 1, + username: 1, + apiKey: 1, + darkMode: 1, + subscribedLibraries: 1, + subscribedLibraryCollections: 1, + fileStorageUsed: 1, + profile: 1, + preferences: 1, + 'services.patreon.id': 1, + 'services.patreon.entitledCents': 1, + 'services.patreon.entitledCentsOverride': 1, + 'services.google.id': 1, + 'services.google.picture': 1, + 'services.google.name': 1, + 'services.google.email': 1, + 'services.google.locale': 1, + } + }), Invites.find({ $or: [ - {inviter: this.userId}, - {invitee: this.userId} + { inviter: this.userId }, + { invitee: this.userId } ], }, { fields: { @@ -40,19 +43,19 @@ let userIdsSchema = new SimpleSchema({ type: Array, optional: true, }, - 'ids.$':{ + 'ids.$': { type: String, regEx: SimpleSchema.RegEx.Id, } }) -Meteor.publish('userPublicProfiles', function(ids){ - userIdsSchema.validate({ids}); - if (!this.userId || !ids) return this.ready(); - return Meteor.users.find({ - _id: {$in: ids} - },{ - fields: {username: 1}, - sort: {username: 1}, - }); +Meteor.publish('userPublicProfiles', function (ids) { + userIdsSchema.validate({ ids }); + if (!this.userId || !ids) return this.ready(); + return Meteor.users.find({ + _id: { $in: ids } + }, { + fields: { username: 1 }, + sort: { username: 1 }, + }); }); diff --git a/app/imports/server/rest/apiPublications/creature.js b/app/imports/server/rest/apiPublications/creature.js index f805c92b..a35ae634 100644 --- a/app/imports/server/rest/apiPublications/creature.js +++ b/app/imports/server/rest/apiPublications/creature.js @@ -1,6 +1,7 @@ import SimpleSchema from 'simpl-schema'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import computeCreature from '/imports/api/engine/computeCreature.js'; import VERSION from '/imports/constants/VERSION.js'; @@ -40,6 +41,9 @@ Meteor.publish('api-creature', function(creatureId){ CreatureProperties.find({ 'ancestors.id': creatureId, }), + CreatureVariables.find({ + _creatureId: creatureId, + }), ]; }, { url: 'api/creature/:0' diff --git a/app/imports/ui/components/CardHighlight.vue b/app/imports/ui/components/CardHighlight.vue index 42a25fcc..714cdb28 100644 --- a/app/imports/ui/components/CardHighlight.vue +++ b/app/imports/ui/components/CardHighlight.vue @@ -18,7 +18,7 @@ props: { active: Boolean, dark: Boolean, - } + }, } diff --git a/app/imports/ui/components/ColumnLayout.vue b/app/imports/ui/components/ColumnLayout.vue index 42e1a9a3..8d3fc918 100644 --- a/app/imports/ui/components/ColumnLayout.vue +++ b/app/imports/ui/components/ColumnLayout.vue @@ -18,35 +18,40 @@ export default { }; - diff --git a/app/imports/ui/components/ImageUploadInput.vue b/app/imports/ui/components/ImageUploadInput.vue new file mode 100644 index 00000000..c93235b4 --- /dev/null +++ b/app/imports/ui/components/ImageUploadInput.vue @@ -0,0 +1,77 @@ + + + + + \ No newline at end of file diff --git a/app/imports/ui/components/IncrementMenu.vue b/app/imports/ui/components/IncrementMenu.vue index 6a2b954c..99c2de58 100644 --- a/app/imports/ui/components/IncrementMenu.vue +++ b/app/imports/ui/components/IncrementMenu.vue @@ -65,102 +65,103 @@ diff --git a/app/imports/ui/components/MarkdownText.vue b/app/imports/ui/components/MarkdownText.vue index 681878d2..bb133d27 100644 --- a/app/imports/ui/components/MarkdownText.vue +++ b/app/imports/ui/components/MarkdownText.vue @@ -1,33 +1,27 @@ - - diff --git a/app/imports/ui/components/SharedIcon.vue b/app/imports/ui/components/SharedIcon.vue index 568bd91c..936f01d0 100644 --- a/app/imports/ui/components/SharedIcon.vue +++ b/app/imports/ui/components/SharedIcon.vue @@ -1,6 +1,6 @@ diff --git a/app/imports/ui/components/global/IconPicker.vue b/app/imports/ui/components/global/IconPicker.vue index d7f3ed1a..13ef7954 100644 --- a/app/imports/ui/components/global/IconPicker.vue +++ b/app/imports/ui/components/global/IconPicker.vue @@ -7,28 +7,28 @@ style="overflow-y: auto;" > @@ -87,44 +87,51 @@ export default { SvgIcon, }, mixins: [SmartInput], - props: { + props: { label: { type: String, default: 'Icon', }, - }, - data(){return { - menu: false, - searchString: '', - icons: [], - };}, + buttonStyle: { + type: String, + default: undefined, + }, + }, + data() { + return { + menu: false, + searchString: '', + icons: [], + }; + }, watch: { - menu(value){ - if (value){ + menu(value) { + if (value) { setTimeout(() => { - if (this.$refs.iconSearchField){ + if (this.$refs.iconSearchField) { this.$refs.iconSearchField.$children[0].focus(); } }, 100); } }, }, - methods: { - search(value, ack){ + methods: { + search(value, ack) { this.searchString = value; this.icons = []; - findIcons.call({search: value}, (error, result) => { + findIcons.call({ search: value }, (error, result) => { ack(error); this.icons = result; }); }, - select(icon){ + select(icon) { this.menu = false; this.change(icon); }, - }, + }, } diff --git a/app/imports/ui/components/global/SmartBtn.vue b/app/imports/ui/components/global/SmartBtn.vue new file mode 100644 index 00000000..324fcf3e --- /dev/null +++ b/app/imports/ui/components/global/SmartBtn.vue @@ -0,0 +1,72 @@ + + + diff --git a/app/imports/ui/components/global/SmartCombobox.vue b/app/imports/ui/components/global/SmartCombobox.vue index 07931531..d5c985c0 100644 --- a/app/imports/ui/components/global/SmartCombobox.vue +++ b/app/imports/ui/components/global/SmartCombobox.vue @@ -21,34 +21,36 @@ diff --git a/app/imports/ui/components/global/SmartInputMixin.js b/app/imports/ui/components/global/SmartInputMixin.js index 5e44cc7b..245656b6 100644 --- a/app/imports/ui/components/global/SmartInputMixin.js +++ b/app/imports/ui/components/global/SmartInputMixin.js @@ -13,16 +13,18 @@ export default { context: { default: {} } }, inheritAttrs: false, - data(){ return { - error: false, - ackErrors: null, - rulesErrors: null, - focused: false, - loading: false, - dirty: false, - safeValue: this.value, - inputValue: this.value, - };}, + data() { + return { + error: false, + ackErrors: null, + rulesErrors: null, + focused: false, + loading: false, + dirty: false, + safeValue: this.value, + inputValue: this.value, + }; + }, props: { value: [String, Number, Date, Array, Object, Boolean], errorMessages: [String, Array], @@ -34,11 +36,11 @@ export default { rules: Array, }, watch: { - focused(newFocus){ + focused(newFocus) { // If the value updated while we were focused, show it now on defocus // but not if we are waiting for our own writes to get persisted // and not if there is an error in our input - if (!newFocus && !this.dirty && !this.error){ + if (!newFocus && !this.dirty && !this.error) { this.forceSafeValueUpdate(); } // Start the loading bar on defocus if the input is dirty @@ -48,118 +50,118 @@ export default { !newFocus && this.dirty && !(this.rulesErrors && this.rulesErrors.length) - ){ + ) { if (this.hasChangeListener) this.loading = true; } }, - dirty(newDirty){ + dirty(newDirty) { // Our changes were acknowledged, weren't in error, and we aren't focused, // make sure the internal value matches the database value - if (!newDirty && !this.focused && !this.error){ + if (!newDirty && !this.focused && !this.error) { this.forceSafeValueUpdate(); } }, - value(newValue){ + value(newValue) { if ( !this.focused && !(this.rulesErrors && this.rulesErrors.length) - ){ + ) { this.safeValue = newValue; } }, - safeValue(){ + safeValue() { // The safe value only gets updated from the parent, so it must be valid this.error = false; this.ackErrors = null; }, }, methods: { - input(val){ + input(val) { this.$emit('input', val); this.inputValue = val; this.dirty = true; // Apply the rules if there are any this.rulesErrors = null; - if (this.rules && this.rules.length){ + if (this.rules && this.rules.length) { this.rules.forEach(rule => { const result = rule(val); - if (typeof result === 'string'){ + if (typeof result === 'string') { if (!this.rulesErrors) this.rulesErrors = []; this.rulesErrors.push(result); } }); } - if (this.rulesErrors){ + if (this.rulesErrors) { return; } this.debouncedChange(val); }, - acknowledgeChange(error){ + acknowledgeChange(error) { this.loading = false; this.dirty = false; this.error = !!error; - if (!error){ - this.ackErrors = null; - } else if (typeof error === 'string'){ - this.ackErrors = error; - } else if (error.reason){ + if (!error) { + this.ackErrors = null; + } else if (typeof error === 'string') { + this.ackErrors = error; + } else if (error.reason) { this.ackErrors = error.reason; - } else if (error.message){ + } else if (error.message) { this.ackErrors = error.message; } else { - this.ackErrors = 'Something went wrong' - console.error(error); - } + this.ackErrors = 'Something went wrong' + console.error(error); + } }, - change(val){ + change(val) { this.dirty = true; - if (this.hasChangeListener) this.loading = true; + if (this.hasChangeListener()) this.loading = true; this.$emit('change', val, this.acknowledgeChange); }, - hasChangeListener(){ + hasChangeListener() { return this.$listeners && this.$listeners.change; }, - forceSafeValueUpdate(){ + forceSafeValueUpdate() { // hack to force the value to update on the child component this.safeValue = null; this.$nextTick(() => this.safeValue = this.value); }, - focus(){ + focus() { this.$refs.input.focus(); } }, computed: { - errors(){ + errors() { let errors = this.ackErrors ? [this.ackErrors] : []; - if (Array.isArray(this.rulesErrors)){ + if (Array.isArray(this.rulesErrors)) { errors.push(...this.rulesErrors) } - if (Array.isArray(this.errorMessages)){ + if (Array.isArray(this.errorMessages)) { errors.push(...this.errorMessages); - } else if (typeof this.errorMessages === 'string' && this.errorMessages){ + } else if (typeof this.errorMessages === 'string' && this.errorMessages) { errors.push(this.errorMessages); } return errors; }, - isDisabled(){ + isDisabled() { return this.context.editPermission === false || this.disabled; }, debounceTime() { - if (Number.isFinite(this.debounce)){ + if (Number.isFinite(this.debounce)) { return this.debounce; - } else if (Number.isFinite(this.context.debounceTime)){ + } else if (Number.isFinite(this.context.debounceTime)) { return this.context.debounceTime; } else { return 750; } }, }, - created(){ + created() { this.debouncedChange = debounce(this.change, this.debounceTime); }, - beforeDestroy(){ + beforeDestroy() { this.debouncedChange.flush(); }, }; diff --git a/app/imports/ui/components/global/SmartSelect.vue b/app/imports/ui/components/global/SmartSelect.vue index 439f735b..005ea2ae 100644 --- a/app/imports/ui/components/global/SmartSelect.vue +++ b/app/imports/ui/components/global/SmartSelect.vue @@ -23,9 +23,9 @@ diff --git a/app/imports/parser/TextField.vue b/app/imports/ui/components/global/SmartSlider.vue similarity index 69% rename from app/imports/parser/TextField.vue rename to app/imports/ui/components/global/SmartSlider.vue index 9c9f0919..b9797d9b 100644 --- a/app/imports/parser/TextField.vue +++ b/app/imports/ui/components/global/SmartSlider.vue @@ -1,22 +1,25 @@ diff --git a/app/imports/ui/components/global/globalIndex.js b/app/imports/ui/components/global/globalIndex.js index 05992177..84c28bbe 100644 --- a/app/imports/ui/components/global/globalIndex.js +++ b/app/imports/ui/components/global/globalIndex.js @@ -5,17 +5,21 @@ import IconPicker from '/imports/ui/components/global/IconPicker.vue'; import TextField from '/imports/ui/components/global/TextField.vue'; import TextArea from '/imports/ui/components/global/TextArea.vue'; import SmartSelect from '/imports/ui/components/global/SmartSelect.vue'; +import SmartBtn from '/imports/ui/components/global/SmartBtn.vue'; import SmartCombobox from '/imports/ui/components/global/SmartCombobox.vue'; import SmartCheckbox from '/imports/ui/components/global/SmartCheckbox.vue'; import SmartSwitch from '/imports/ui/components/global/SmartSwitch.vue'; import SvgIcon from '/imports/ui/components/global/SvgIcon.vue'; +import SmartSlider from '/imports/ui/components/global/SmartSlider.vue'; Vue.component('DatePicker', DatePicker); Vue.component('IconPicker', IconPicker); Vue.component('TextField', TextField); Vue.component('TextArea', TextArea); Vue.component('SmartSelect', SmartSelect); +Vue.component('SmartBtn', SmartBtn); Vue.component('SmartCombobox', SmartCombobox); Vue.component('SmartCheckbox', SmartCheckbox); +Vue.component('SmartSlider', SmartSlider); Vue.component('SmartSwitch', SmartSwitch); Vue.component('SvgIcon', SvgIcon); diff --git a/app/imports/ui/components/propertyToolbar.vue b/app/imports/ui/components/propertyToolbar.vue index 36b24414..28b454d7 100644 --- a/app/imports/ui/components/propertyToolbar.vue +++ b/app/imports/ui/components/propertyToolbar.vue @@ -54,6 +54,19 @@ + + + + Help + + + + mdi-help + + diff --git a/app/imports/ui/components/tree/TreeNode.vue b/app/imports/ui/components/tree/TreeNode.vue index a044730f..c17b435a 100644 --- a/app/imports/ui/components/tree/TreeNode.vue +++ b/app/imports/ui/components/tree/TreeNode.vue @@ -14,6 +14,7 @@ @click.stop="$emit('selected', node._id)" >
diff --git a/app/imports/ui/components/tree/TreeNodeList.vue b/app/imports/ui/components/tree/TreeNodeList.vue index 80e11e45..1ea59678 100644 --- a/app/imports/ui/components/tree/TreeNodeList.vue +++ b/app/imports/ui/components/tree/TreeNodeList.vue @@ -23,6 +23,7 @@ :ancestors-of-selected-node="ancestorsOfSelectedNode" :organize="organize" :lazy="lazy" + :start-expanded="startExpanded" @selected="e => $emit('selected', e)" @reordered="e => $emit('reordered', e)" @reorganized="e => $emit('reorganized', e)" @@ -32,101 +33,112 @@ diff --git a/app/imports/ui/components/tree/TreeSearchInput.vue b/app/imports/ui/components/tree/TreeSearchInput.vue index 9c843b9a..5abac7e5 100644 --- a/app/imports/ui/components/tree/TreeSearchInput.vue +++ b/app/imports/ui/components/tree/TreeSearchInput.vue @@ -13,6 +13,16 @@ diff --git a/app/imports/ui/creature/CreatureFormDialog.vue b/app/imports/ui/creature/CreatureFormDialog.vue index 9a80adce..04fa4d19 100644 --- a/app/imports/ui/creature/CreatureFormDialog.vue +++ b/app/imports/ui/creature/CreatureFormDialog.vue @@ -1,5 +1,8 @@ diff --git a/app/imports/ui/creature/character/CharacterSheetFab.vue b/app/imports/ui/creature/character/CharacterSheetFab.vue index b9e789b2..5af7abd0 100644 --- a/app/imports/ui/creature/character/CharacterSheetFab.vue +++ b/app/imports/ui/creature/character/CharacterSheetFab.vue @@ -1,17 +1,17 @@ diff --git a/app/imports/ui/creature/character/characterSheetTabs/InventoryTab.vue b/app/imports/ui/creature/character/characterSheetTabs/InventoryTab.vue index 6e3faac6..ea77fddd 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/InventoryTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/InventoryTab.vue @@ -30,13 +30,11 @@ - + - + $vuetify.icons.spell @@ -47,7 +45,7 @@ - {{ creature.variables.itemsAttuned.value }} + {{ variables.itemsAttuned.value }} @@ -85,9 +83,7 @@ v-for="container in containersWithoutAncestorContainers" :key="container._id" > - +
@@ -104,82 +100,90 @@ import getParentRefByTag from '/imports/api/creature/creatureProperties/methods/ import BUILT_IN_TAGS from '/imports/constants/BUILT_IN_TAGS.js'; import CoinValue from '/imports/ui/components/CoinValue.vue'; import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js'; +import CreatureVariables from '../../../../api/creature/creatures/CreatureVariables'; export default { - components: { - ColumnLayout, - ContainerCard, + components: { + ColumnLayout, + ContainerCard, ToolbarCard, ItemList, CoinValue, - }, - props: { - creatureId: { + }, + props: { + creatureId: { type: String, required: true, }, - }, - data(){ return { - organize: false, - }}, - meteor: { - containers(){ - return CreatureProperties.find({ - 'ancestors.id': this.creatureId, - type: 'container', - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - sort: {order: 1}, - }); - }, - creature(){ - return Creatures.findOne(this.creatureId, {fields: { - color: 1, - variables: 1, - }}); + }, + data() { + return { + organize: false, + } + }, + meteor: { + containers() { + return CreatureProperties.find({ + 'ancestors.id': this.creatureId, + type: 'container', + removed: { $ne: true }, + inactive: { $ne: true }, + }, { + sort: { order: 1 }, + }); }, - containersWithoutAncestorContainers(){ - return CreatureProperties.find({ - 'ancestors.id': { - $eq: this.creatureId, - $nin: this.containerIds - }, - type: 'container', - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - sort: {order: 1}, - }); - }, - carriedItems(){ + creature() { + return Creatures.findOne(this.creatureId, { + fields: { + color: 1, + variables: 1, + } + }); + }, + variables() { + return CreatureVariables.findOne({ _creatureId: this.creatureId }) || {}; + }, + containersWithoutAncestorContainers() { return CreatureProperties.find({ 'ancestors.id': { - $eq: this.creatureId, - $nin: this.containerIds - }, - type: 'item', - equipped: {$ne: true}, - removed: {$ne: true}, - deactivatedByAncestor: {$ne: true}, - }, { - sort: {order: 1}, - }); + $eq: this.creatureId, + $nin: this.containerIds + }, + type: 'container', + removed: { $ne: true }, + inactive: { $ne: true }, + }, { + sort: { order: 1 }, + }); }, - equippedItems(){ + carriedItems() { return CreatureProperties.find({ 'ancestors.id': { - $eq: this.creatureId, - }, - type: 'item', + $eq: this.creatureId, + $nin: this.containerIds + }, + type: 'item', + equipped: { $ne: true }, + removed: { $ne: true }, + deactivatedByAncestor: { $ne: true }, + }, { + sort: { order: 1 }, + }); + }, + equippedItems() { + return CreatureProperties.find({ + 'ancestors.id': { + $eq: this.creatureId, + }, + type: 'item', equipped: true, - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - sort: {order: 1}, - }); + removed: { $ne: true }, + inactive: { $ne: true }, + }, { + sort: { order: 1 }, + }); }, - equipmentParentRef(){ + equipmentParentRef() { return getParentRefByTag( this.creatureId, BUILT_IN_TAGS.equipment ) || getParentRefByTag( @@ -189,7 +193,7 @@ export default { collection: 'creatures' }; }, - carriedParentRef(){ + carriedParentRef() { return getParentRefByTag( this.creatureId, BUILT_IN_TAGS.carried ) || getParentRefByTag( @@ -199,30 +203,31 @@ export default { collection: 'creatures' }; }, - }, - computed: { - containerIds(){ - return this.containers.map(container => container._id); - }, - weightCarried(){ + }, + computed: { + containerIds() { + return this.containers.map(container => container._id); + }, + weightCarried() { return stripFloatingPointOddities( - this.creature.variables && - this.creature.variables.weightCarried && - this.creature.variables.weightCarried.value || 0 + this.variables && + this.variables.weightCarried && + this.variables.weightCarried.value || 0 ); }, - }, - methods: { - clickProperty(_id){ - this.$store.commit('pushDialogStack', { - component: 'creature-property-dialog', - elementId: `tree-node-${_id}`, - data: {_id}, - }); - }, - }, + }, + methods: { + clickProperty(_id) { + this.$store.commit('pushDialogStack', { + component: 'creature-property-dialog', + elementId: `tree-node-${_id}`, + data: { _id }, + }); + }, + }, } diff --git a/app/imports/ui/creature/character/characterSheetTabs/JournalTab.vue b/app/imports/ui/creature/character/characterSheetTabs/JournalTab.vue index edbe0967..f37e7b5b 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/JournalTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/JournalTab.vue @@ -19,8 +19,6 @@ diff --git a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue index 9862591f..e8122e32 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -1,7 +1,5 @@ diff --git a/app/imports/ui/creature/character/errors/CharacterErrors.vue b/app/imports/ui/creature/character/errors/CharacterErrors.vue new file mode 100644 index 00000000..d15963a9 --- /dev/null +++ b/app/imports/ui/creature/character/errors/CharacterErrors.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/app/imports/ui/creature/character/errors/DependencyLoopError.vue b/app/imports/ui/creature/character/errors/DependencyLoopError.vue new file mode 100644 index 00000000..d8e7d632 --- /dev/null +++ b/app/imports/ui/creature/character/errors/DependencyLoopError.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/app/imports/ui/creature/creatureList/CreatureFolderHeader.vue b/app/imports/ui/creature/creatureList/CreatureFolderHeader.vue index 1b2d6bd3..3f826f6f 100644 --- a/app/imports/ui/creature/creatureList/CreatureFolderHeader.vue +++ b/app/imports/ui/creature/creatureList/CreatureFolderHeader.vue @@ -1,28 +1,29 @@ - diff --git a/app/imports/ui/creature/creatureList/CreatureList.vue b/app/imports/ui/creature/creatureList/CreatureList.vue index 1cdcdf96..dac86161 100644 --- a/app/imports/ui/creature/creatureList/CreatureList.vue +++ b/app/imports/ui/creature/creatureList/CreatureList.vue @@ -19,6 +19,7 @@ :is-selected="selectedCreature === creature._id" v-bind="selection ? {} : {to: creature.url}" :dense="dense" + :data-id="dense ? undefined : creature._id" @click="$emit('creature-selected', creature._id)" /> diff --git a/app/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue b/app/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue index 133d713b..944c7642 100644 --- a/app/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue +++ b/app/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue @@ -171,160 +171,166 @@ diff --git a/app/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue b/app/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue index e24407fe..db6ceb72 100644 --- a/app/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue +++ b/app/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue @@ -20,7 +20,7 @@ import { getPropertyName } from '/imports/constants/PROPERTIES.js'; export default { components: { SelectablePropertyDialog, - CreaturePropertyInsertForm, + CreaturePropertyInsertForm, }, props: { forcedType: { @@ -28,21 +28,24 @@ export default { default: undefined, }, }, - data() { return { - type: undefined, - };}, - methods: { - getPropertyName, - back(){ - if (this.forcedType){ + data() { + return { + type: undefined, + }; + }, + methods: { + getPropertyName, + back() { + if (this.forcedType) { this.$store.dispatch('popDialogStack'); } else { this.type = undefined; } }, - }, + }, }; diff --git a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue index 64f684ff..da516221 100644 --- a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue +++ b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue @@ -14,33 +14,57 @@ /> diff --git a/app/imports/ui/creature/experiences/ExperienceInsertDialog.vue b/app/imports/ui/creature/experiences/ExperienceInsertDialog.vue index 28756d1f..9b9ecc72 100644 --- a/app/imports/ui/creature/experiences/ExperienceInsertDialog.vue +++ b/app/imports/ui/creature/experiences/ExperienceInsertDialog.vue @@ -34,9 +34,9 @@ import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin export default { components: { - DialogBase, + DialogBase, ExperienceForm, - }, + }, mixins: [schemaFormMixin], provide: { context: { @@ -52,10 +52,10 @@ export default { type: Boolean, }, }, - data(){ + data() { let schema = ExperienceSchema.omit('creatureId'); let startingModel = {}; - if (this.startAsMilestone){ + if (this.startAsMilestone) { startingModel.levels = 1; } return { @@ -65,14 +65,14 @@ export default { debounceTime: 0, }; }, - methods:{ - insertExperience(){ + methods: { + insertExperience() { let experience = this.schema.clean(this.model); let id = insertExperience.call({ experience, creatureIds: this.creatureIds, - }, (error) => { - if (error){ + }, (error) => { + if (error) { console.error(error); } }); @@ -83,4 +83,5 @@ export default { diff --git a/app/imports/ui/creature/slots/LevelUpDialog.vue b/app/imports/ui/creature/slots/LevelUpDialog.vue new file mode 100644 index 00000000..4503a96e --- /dev/null +++ b/app/imports/ui/creature/slots/LevelUpDialog.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/app/imports/ui/creature/slots/SlotCard.vue b/app/imports/ui/creature/slots/SlotCard.vue new file mode 100644 index 00000000..44f6ab76 --- /dev/null +++ b/app/imports/ui/creature/slots/SlotCard.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/imports/ui/creature/slots/SlotCardsToFill.vue b/app/imports/ui/creature/slots/SlotCardsToFill.vue new file mode 100644 index 00000000..043489e7 --- /dev/null +++ b/app/imports/ui/creature/slots/SlotCardsToFill.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/app/imports/ui/creature/slots/SlotDetailsDialog.vue b/app/imports/ui/creature/slots/SlotDetailsDialog.vue deleted file mode 100644 index c8a14aff..00000000 --- a/app/imports/ui/creature/slots/SlotDetailsDialog.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/app/imports/ui/creature/slots/SlotFillDialog.vue b/app/imports/ui/creature/slots/SlotFillDialog.vue index 0b63870f..cbad8fb9 100644 --- a/app/imports/ui/creature/slots/SlotFillDialog.vue +++ b/app/imports/ui/creature/slots/SlotFillDialog.vue @@ -27,20 +27,18 @@ />

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

diff --git a/app/imports/ui/creature/slots/Slots.vue b/app/imports/ui/creature/slots/Slots.vue deleted file mode 100644 index cf5570d9..00000000 --- a/app/imports/ui/creature/slots/Slots.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - - - diff --git a/app/imports/ui/dialogStack/DeleteConfirmationDialog.vue b/app/imports/ui/dialogStack/DeleteConfirmationDialog.vue index ffe5d186..c8b76735 100644 --- a/app/imports/ui/dialogStack/DeleteConfirmationDialog.vue +++ b/app/imports/ui/dialogStack/DeleteConfirmationDialog.vue @@ -4,7 +4,10 @@ Delete {{ typeName }}
- + This can't be undone

@@ -13,6 +16,8 @@

diff --git a/app/imports/ui/dialogStack/DialogBase.vue b/app/imports/ui/dialogStack/DialogBase.vue index 14a1f1cf..4fcdd912 100644 --- a/app/imports/ui/dialogStack/DialogBase.vue +++ b/app/imports/ui/dialogStack/DialogBase.vue @@ -50,63 +50,69 @@ diff --git a/app/imports/ui/dialogStack/DialogComponentIndex.js b/app/imports/ui/dialogStack/DialogComponentIndex.js index 2635d800..58724cb3 100644 --- a/app/imports/ui/dialogStack/DialogComponentIndex.js +++ b/app/imports/ui/dialogStack/DialogComponentIndex.js @@ -1,4 +1,23 @@ -const AddCreaturePropertyDialog = () => import('/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue'); +// Load commonly used dialogs immediately +import AddCreaturePropertyDialog from '/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue'; +import CharacterCreationDialog from '/imports/ui/creature/character/CharacterCreationDialog.vue'; +import CastSpellWithSlotDialog from '/imports/ui/properties/components/spells/CastSpellWithSlotDialog.vue'; +import CreatureFormDialog from '/imports/ui/creature/CreatureFormDialog.vue'; +import CreaturePropertyCreationDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue'; +import CreaturePropertyDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue'; +import CreaturePropertyFromLibraryDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyFromLibraryDialog.vue'; +import CreatureRootDialog from '/imports/ui/creature/character/CreatureRootDialog.vue'; +import DeleteConfirmationDialog from '/imports/ui/dialogStack/DeleteConfirmationDialog.vue'; +import ExperienceInsertDialog from '/imports/ui/creature/experiences/ExperienceInsertDialog.vue'; +import ExperienceListDialog from '/imports/ui/creature/experiences/ExperienceListDialog.vue'; +import HelpDialog from '/imports/ui/dialogStack/HelpDialog.vue'; +import LevelUpDialog from '/imports/ui/creature/slots/LevelUpDialog.vue'; +import SelectLibraryNodeDialog from '/imports/ui/library/SelectLibraryNodeDialog.vue'; +import SlotFillDialog from '/imports/ui/creature/slots/SlotFillDialog.vue'; +import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue'; +import TransferOwnershipDialog from '/imports/ui/sharing/TransferOwnershipDialog.vue'; + +// Lazily load less common dialogs const ArchiveDialog = () => import('/imports/ui/creature/archive/ArchiveDialog.vue'); const CastSpellWithSlotDialog = () => import('/imports/ui/properties/components/spells/CastSpellWithSlotDialog.vue'); const CharacterSheetDialog = () => import('/imports/ui/tabletop/CharacterSheetDialog.vue'); @@ -8,21 +27,16 @@ const CreaturePropertyDialog = () => import('/imports/ui/creature/creatureProper const CreaturePropertyFromLibraryDialog = () => import('/imports/ui/creature/creatureProperties/CreaturePropertyFromLibraryDialog.vue'); const DeleteConfirmationDialog = () => import('/imports/ui/dialogStack/DeleteConfirmationDialog.vue'); const DeleteUserAccountDialog = () => import('/imports/ui/user/DeleteUserAccountDialog.vue'); -const ExperienceInsertDialog = () => import( '/imports/ui/creature/experiences/ExperienceInsertDialog.vue'); -const ExperienceListDialog = () => import( '/imports/ui/creature/experiences/ExperienceListDialog.vue'); const InviteDialog = () => import('/imports/ui/user/InviteDialog.vue'); +const LibraryCollectionCreationDialog = () => import('/imports/ui/library/LibraryCollectionCreationDialog.vue'); +const LibraryCollectionEditDialog = () => import('/imports/ui/library/LibraryCollectionEditDialog.vue'); const LibraryCreationDialog = () => import('/imports/ui/library/LibraryCreationDialog.vue'); const LibraryEditDialog = () => import('/imports/ui/library/LibraryEditDialog.vue'); const LibraryNodeCreationDialog = () => import('/imports/ui/library/LibraryNodeCreationDialog.vue'); const LibraryNodeDialog = () => import('/imports/ui/library/LibraryNodeDialog.vue'); const MoveLibraryNodeDialog = () => import('/imports/ui/library/MoveLibraryNodeDialog.vue'); const SelectCreaturesDialog = () => import('/imports/ui/tabletop/SelectCreaturesDialog.vue'); -const SelectLibraryNodeDialog = () => import('/imports/ui/library/SelectLibraryNodeDialog.vue'); const ShareDialog = () => import('/imports/ui/sharing/ShareDialog.vue'); -const SlotDetailsDialog = () => import('/imports/ui/creature/slots/SlotDetailsDialog.vue'); -const SlotFillDialog = () => import('/imports/ui/creature/slots/SlotFillDialog.vue'); -const TierTooLowDialog = () => import('/imports/ui/user/TierTooLowDialog.vue'); -const TransferOwnershipDialog = () => import('/imports/ui/sharing/TransferOwnershipDialog.vue'); const UsernameDialog = () => import('/imports/ui/user/UsernameDialog.vue'); export default { @@ -30,15 +44,21 @@ export default { ArchiveDialog, CastSpellWithSlotDialog, CharacterSheetDialog, + CharacterCreationDialog, CreatureFormDialog, CreaturePropertyCreationDialog, CreaturePropertyDialog, CreaturePropertyFromLibraryDialog, + CreatureRootDialog, DeleteConfirmationDialog, DeleteUserAccountDialog, ExperienceInsertDialog, ExperienceListDialog, + HelpDialog, InviteDialog, + LevelUpDialog, + LibraryCollectionCreationDialog, + LibraryCollectionEditDialog, LibraryCreationDialog, LibraryEditDialog, LibraryNodeCreationDialog, @@ -47,7 +67,6 @@ export default { SelectCreaturesDialog, SelectLibraryNodeDialog, ShareDialog, - SlotDetailsDialog, SlotFillDialog, TierTooLowDialog, TransferOwnershipDialog, diff --git a/app/imports/ui/dialogStack/HelpDialog.vue b/app/imports/ui/dialogStack/HelpDialog.vue new file mode 100644 index 00000000..9fe700b2 --- /dev/null +++ b/app/imports/ui/dialogStack/HelpDialog.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/app/imports/ui/dialogStack/mockElement.js b/app/imports/ui/dialogStack/mockElement.js index de843514..6f179af5 100644 --- a/app/imports/ui/dialogStack/mockElement.js +++ b/app/imports/ui/dialogStack/mockElement.js @@ -2,42 +2,42 @@ import { parse, stringify } from 'css-box-shadow'; // Only supports border radius defined like "20px" or "100%" const transformedRadius = (radiusString, deltaWidth, deltaHeight) => { - if (/^\d+\.?\d*px$/.test(radiusString)){ - //The radius is defined in pixel units, so get the radius as a number - const rad = +radiusString.match(/\d+\.?\d*/)[0]; - // Set the x and y radius of the "to" element, compensating for scale - return `${rad / deltaWidth}px / ${rad / deltaHeight}px`; - } else if (/^\d+\.?\d*%$/.test(radiusString)) { - //The radius is defined as a percentage, so just use it as is - return radiusString; - } + if (/^\d+\.?\d*px$/.test(radiusString)) { + //The radius is defined in pixel units, so get the radius as a number + const rad = +radiusString.match(/\d+\.?\d*/)[0]; + // Set the x and y radius of the "to" element, compensating for scale + return `${rad / deltaWidth}px / ${rad / deltaHeight}px`; + } else if (/^\d+\.?\d*%$/.test(radiusString)) { + //The radius is defined as a percentage, so just use it as is + return radiusString; + } }; const transformedBoxShadow = (shadowString, deltaWidth, deltaHeight) => { - if (shadowString === 'none') return shadowString; - if (shadowString[0] === 'r'){ - let strings = shadowString.match(/rgba\([^)]+\)[^,]+/g); - strings = strings.map(string => { - // Move color to end - let m = string.match(/(rgba\([^)]+\))([^,]+)/); - return `${m[2].trim()} ${m[1]}`; - }); - shadowString = strings.join(', '); - } - let scaleAverage = (deltaWidth + deltaHeight) / 2; - let shadows = parse(shadowString); - shadows.forEach(shadow => { - shadow.offsetX /= deltaWidth; - shadow.offsetY /= deltaHeight; - shadow.blurRadius /= scaleAverage; - shadow.spreadRadius /= scaleAverage; - }) - return stringify(shadows); + if (shadowString === 'none') return shadowString; + if (shadowString[0] === 'r') { + let strings = shadowString.match(/rgba\([^)]+\)[^,]+/g); + strings = strings.map(string => { + // Move color to end + let m = string.match(/(rgba\([^)]+\))([^,]+)/); + return `${m[2].trim()} ${m[1]}`; + }); + shadowString = strings.join(', '); + } + let scaleAverage = (deltaWidth + deltaHeight) / 2; + let shadows = parse(shadowString); + shadows.forEach(shadow => { + shadow.offsetX /= deltaWidth; + shadow.offsetY /= deltaHeight; + shadow.blurRadius /= scaleAverage; + shadow.spreadRadius /= scaleAverage; + }) + return stringify(shadows); } -export default function mockElement({source, target, offset = {x: 0, y: 0}}){ - if (!source || !target) throw `Can't mock without ${source ? 'target' : 'source'}` ; - let sourceRect = source.getBoundingClientRect(); +export default function mockElement({ source, target, offset = { x: 0, y: 0 } }) { + if (!source || !target) throw `Can't mock without ${source ? 'target' : 'source'}`; + let sourceRect = source.getBoundingClientRect(); let targetRect = target.getBoundingClientRect(); // Get how must the target change to become the source @@ -47,20 +47,20 @@ export default function mockElement({source, target, offset = {x: 0, y: 0}}){ const deltaTop = sourceRect.top - targetRect.top + offset.y; // Mock the source target.style.transform = `translate(${deltaLeft}px, ${deltaTop}px) ` + - `scale(${deltaWidth}, ${deltaHeight})`; + `scale(${deltaWidth}, ${deltaHeight})`; // Mock the background color unless it's completely transparent let backgroundColor = getComputedStyle(source).backgroundColor - if (backgroundColor !== 'rgba(0, 0, 0, 0)'){ + if (backgroundColor !== 'rgba(0, 0, 0, 0)') { target.style.backgroundColor = backgroundColor; } - // Edge might not combine all border radii into a single value, - // So we just sample the top left one if we need to - let oldRadius = getComputedStyle(source).borderRadius || - getComputedStyle(source).borderTopLeftRadius; - let borderRadius = transformedRadius(oldRadius, deltaWidth, deltaHeight); - target.style.borderRadius = borderRadius; - let boxShadow = transformedBoxShadow( - getComputedStyle(source).boxShadow, deltaWidth, deltaHeight - ); - target.style.setProperty('box-shadow', boxShadow, 'important'); + // Edge might not combine all border radii into a single value, + // So we just sample the top left one if we need to + let oldRadius = getComputedStyle(source).borderRadius || + getComputedStyle(source).borderTopLeftRadius; + let borderRadius = transformedRadius(oldRadius, deltaWidth, deltaHeight); + target.style.borderRadius = borderRadius; + let boxShadow = transformedBoxShadow( + getComputedStyle(source).boxShadow, deltaWidth, deltaHeight + ); + target.style.setProperty('box-shadow', boxShadow, 'important'); } diff --git a/app/imports/ui/documentation/FunctionReference.vue b/app/imports/ui/documentation/FunctionReference.vue deleted file mode 100644 index b8172fbd..00000000 --- a/app/imports/ui/documentation/FunctionReference.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/app/imports/ui/files/ArchiveFileCard.vue b/app/imports/ui/files/ArchiveFileCard.vue index cab9edde..d8ec1f25 100644 --- a/app/imports/ui/files/ArchiveFileCard.vue +++ b/app/imports/ui/files/ArchiveFileCard.vue @@ -44,17 +44,19 @@ export default { required: true, }, }, - data(){return { - restoreLoading: false, - removeLoading: false, - }}, - meteor: { - characterSlots(){ + data() { + return { + restoreLoading: false, + removeLoading: false, + } + }, + meteor: { + characterSlots() { return characterSlotsRemaining(Meteor.userId()); }, - }, + }, methods: { - restore(){ + restore() { this.restoreLoading = true; restoreCreatureFromFile.call({ fileId: this.model._id, @@ -62,26 +64,26 @@ export default { this.restoreLoading = false; if (!error) return; console.error(error); - snackbar({text: error.reason}); + snackbar({ text: error.reason }); + }); + }, + removeArchiveCharacter() { + let that = this; + this.$store.commit('pushDialogStack', { + component: 'delete-confirmation-dialog', + elementId: `${that.model._id}-archive-card`, + data: { + name: this.model.meta.creatureName, + typeName: 'Character Archive' + }, + callback(confirmation) { + if (!confirmation) return; + removeArchiveCreature.call({ fileId: that.model._id }, (error) => { + if (error) console.error(error); + }); + } }); }, - removeArchiveCharacter(){ - let that = this; - this.$store.commit('pushDialogStack', { - component: 'delete-confirmation-dialog', - elementId: `${that.model._id}-archive-card`, - data: { - name: this.model.meta.creatureName, - typeName: 'Character Archive' - }, - callback(confirmation){ - if(!confirmation) return; - removeArchiveCreature.call({fileId: that.model._id}, (error) => { - if (error) console.error(error); - }); - } - }); - }, }, } diff --git a/app/imports/ui/files/UserImageCard.vue b/app/imports/ui/files/UserImageCard.vue new file mode 100644 index 00000000..85c5d155 --- /dev/null +++ b/app/imports/ui/files/UserImageCard.vue @@ -0,0 +1,57 @@ + + + diff --git a/app/imports/ui/layouts/AppLayout.vue b/app/imports/ui/layouts/AppLayout.vue index 8d958e86..99469969 100644 --- a/app/imports/ui/layouts/AppLayout.vue +++ b/app/imports/ui/layouts/AppLayout.vue @@ -6,9 +6,7 @@ > - + - +
{{ $store.state.pageTitle }}
- +
- +
- +
- + - + diff --git a/app/imports/ui/layouts/Sidebar.vue b/app/imports/ui/layouts/Sidebar.vue index 666087ca..b5c87d51 100644 --- a/app/imports/ui/layouts/Sidebar.vue +++ b/app/imports/ui/layouts/Sidebar.vue @@ -100,6 +100,7 @@ {title: 'Tabletops', icon: 'mdi-table', to: '/tabletops'}, {title: 'Feedback', icon: 'mdi-bug', to: '/feedback'}, {title: 'About', icon: 'mdi-sign-text', to: '/about'}, + {title: 'Documentation', icon: 'mdi-book-open-variant', to: '/docs'}, {title: 'Patreon', icon: 'mdi-patreon', href: 'https://www.patreon.com/dicecloud'}, {title: 'Github', icon: 'mdi-github', href: 'https://github.com/ThaumRystra/DiceCloud/tree/version-2'}, ]; diff --git a/app/imports/ui/library/LibraryCollectionCreationDialog.vue b/app/imports/ui/library/LibraryCollectionCreationDialog.vue new file mode 100644 index 00000000..ed764930 --- /dev/null +++ b/app/imports/ui/library/LibraryCollectionCreationDialog.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/app/imports/ui/library/LibraryCollectionEditDialog.vue b/app/imports/ui/library/LibraryCollectionEditDialog.vue new file mode 100644 index 00000000..cccce61a --- /dev/null +++ b/app/imports/ui/library/LibraryCollectionEditDialog.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/app/imports/ui/library/LibraryCollectionHeader.vue b/app/imports/ui/library/LibraryCollectionHeader.vue new file mode 100644 index 00000000..39713450 --- /dev/null +++ b/app/imports/ui/library/LibraryCollectionHeader.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/imports/ui/library/LibraryCollectionToolbar.vue b/app/imports/ui/library/LibraryCollectionToolbar.vue new file mode 100644 index 00000000..0a128b90 --- /dev/null +++ b/app/imports/ui/library/LibraryCollectionToolbar.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/app/imports/ui/library/LibraryContentsContainer.vue b/app/imports/ui/library/LibraryContentsContainer.vue index f30e30fb..1e70fa07 100644 --- a/app/imports/ui/library/LibraryContentsContainer.vue +++ b/app/imports/ui/library/LibraryContentsContainer.vue @@ -1,7 +1,5 @@ - + + - +
+ + +

+ This property can't be viewed yet. +

+
diff --git a/app/imports/ui/library/MoveLibraryNodeDialog.vue b/app/imports/ui/library/MoveLibraryNodeDialog.vue index a5824984..ce412813 100644 --- a/app/imports/ui/library/MoveLibraryNodeDialog.vue +++ b/app/imports/ui/library/MoveLibraryNodeDialog.vue @@ -23,15 +23,17 @@ diff --git a/app/imports/ui/library/SingleLibraryToolbar.vue b/app/imports/ui/library/SingleLibraryToolbar.vue index fc953903..c3404d8f 100644 --- a/app/imports/ui/library/SingleLibraryToolbar.vue +++ b/app/imports/ui/library/SingleLibraryToolbar.vue @@ -43,24 +43,26 @@ import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions import { mapMutations } from 'vuex'; export default { - data(){ return { - loading: false, - }}, + data() { + return { + loading: false, + } + }, meteor: { - library(){ + library() { return Libraries.findOne(this.$route.params.id); }, - subscribed(){ + subscribed() { let libraryId = this.$route.params.id; let user = Meteor.user(); return user?.subscribedLibraries?.includes(libraryId); }, - showSubscribeButton(){ + showSubscribeButton() { let user = Meteor.user(); let library = this.library; if (!user || !library) return; let userId = user._id; - if (user.subscribedLibraries.includes(library._id)){ + if (user.subscribedLibraries.includes(library._id)) { return true } else if ( library.readers.includes(userId) || @@ -72,7 +74,7 @@ export default { return true; } }, - canEdit(){ + canEdit() { try { assertDocEditPermission(this.library, Meteor.userId()); return true @@ -85,7 +87,7 @@ export default { ...mapMutations([ 'toggleDrawer', ]), - subscribe(value){ + subscribe(value) { this.loading = true; Meteor.users.subscribeToLibrary.call({ libraryId: this.$route.params.id, @@ -94,16 +96,17 @@ export default { this.loading = false; }); }, - editLibrary(){ - this.$store.commit('pushDialogStack', { - component: 'library-edit-dialog', - elementId: 'library-edit-button', - data: {_id: this.$route.params.id}, - }); - }, + editLibrary() { + this.$store.commit('pushDialogStack', { + component: 'library-edit-dialog', + elementId: 'library-edit-button', + data: { _id: this.$route.params.id }, + }); + }, }, } diff --git a/app/imports/ui/log/CharacterLog.vue b/app/imports/ui/log/CharacterLog.vue index 6835b262..df636483 100644 --- a/app/imports/ui/log/CharacterLog.vue +++ b/app/imports/ui/log/CharacterLog.vue @@ -9,6 +9,7 @@ diff --git a/app/imports/ui/pages/CharacterSheetPage.vue b/app/imports/ui/pages/CharacterSheetPage.vue index c21908db..a2346e36 100644 --- a/app/imports/ui/pages/CharacterSheetPage.vue +++ b/app/imports/ui/pages/CharacterSheetPage.vue @@ -8,8 +8,8 @@ diff --git a/app/imports/ui/pages/Documentation.vue b/app/imports/ui/pages/Documentation.vue new file mode 100644 index 00000000..d8c83c53 --- /dev/null +++ b/app/imports/ui/pages/Documentation.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/imports/ui/pages/Files.vue b/app/imports/ui/pages/Files.vue index 8d77f097..b30856e7 100644 --- a/app/imports/ui/pages/Files.vue +++ b/app/imports/ui/pages/Files.vue @@ -7,78 +7,130 @@ + + Archived Characters + + + + + + mdi-file-upload-outline + + + + + + + + + + diff --git a/app/imports/ui/pages/FunctionReference.vue b/app/imports/ui/pages/FunctionReference.vue new file mode 100644 index 00000000..9b645dd5 --- /dev/null +++ b/app/imports/ui/pages/FunctionReference.vue @@ -0,0 +1,59 @@ + + + diff --git a/app/imports/ui/pages/Home.vue b/app/imports/ui/pages/Home.vue index 171ade01..72642172 100644 --- a/app/imports/ui/pages/Home.vue +++ b/app/imports/ui/pages/Home.vue @@ -132,17 +132,17 @@ diff --git a/app/imports/ui/pages/Library.vue b/app/imports/ui/pages/Library.vue index d564dee1..65a2d18f 100644 --- a/app/imports/ui/pages/Library.vue +++ b/app/imports/ui/pages/Library.vue @@ -1,18 +1,183 @@ - diff --git a/app/imports/ui/pages/SingleLibrary.vue b/app/imports/ui/pages/SingleLibrary.vue new file mode 100644 index 00000000..4faa0452 --- /dev/null +++ b/app/imports/ui/pages/SingleLibrary.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/imports/ui/pages/Tabletops.vue b/app/imports/ui/pages/Tabletops.vue index fca7d1c6..5417feb4 100644 --- a/app/imports/ui/pages/Tabletops.vue +++ b/app/imports/ui/pages/Tabletops.vue @@ -37,7 +37,7 @@ import SingleCardLayout from '/imports/ui/layouts/SingleCardLayout.vue' import Tabletops from '/imports/api/tabletop/Tabletops.js'; import insertTabletop from '/imports/api/tabletop/methods/insertTabletop.js'; -import snackbar from '/imports/ui/components/snackbars/SnackbarQueue.js'; +import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; export default { components: { diff --git a/app/imports/ui/properties/components/actions/ActionCard.vue b/app/imports/ui/properties/components/actions/ActionCard.vue index 01503d28..68765679 100644 --- a/app/imports/ui/properties/components/actions/ActionCard.vue +++ b/app/imports/ui/properties/components/actions/ActionCard.vue @@ -38,9 +38,7 @@ :disabled="model.insufficientResources || !context.editPermission || !!targetingError" @click.stop="doAction" > - +
-
+
{{ model.name || propertyName }}
@@ -97,10 +93,15 @@ /> + +
@@ -115,8 +116,12 @@ import ItemConsumedView from '/imports/ui/properties/components/actions/ItemCons import PropertyIcon from '/imports/ui/properties/shared/PropertyIcon.vue'; import RollPopup from '/imports/ui/components/RollPopup.vue'; import MarkdownText from '/imports/ui/components/MarkdownText.vue'; -import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js'; +import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; import CardHighlight from '/imports/ui/components/CardHighlight.vue'; +import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue'; +import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { some } from 'lodash'; export default { components: { @@ -125,7 +130,8 @@ export default { MarkdownText, PropertyIcon, RollPopup, - CardHighlight + CardHighlight, + TreeNodeList, }, inject: { context: { @@ -147,20 +153,22 @@ export default { default: undefined, }, }, - data(){return { - activated: undefined, - doActionLoading: false, - hovering: false, - }}, + data() { + return { + activated: undefined, + doActionLoading: false, + hovering: false, + } + }, computed: { - rollBonus(){ + rollBonus() { if (!this.model.attackRoll) return; return numberToSignedString(this.model.attackRoll.value); }, - rollBonusTooLong(){ + rollBonusTooLong() { return this.rollBonus && this.rollBonus.length > 3; }, - propertyName(){ + propertyName() { return getPropertyName(this.model.type); }, cardClasses() { @@ -185,12 +193,41 @@ export default { } return undefined; } - }, + }, + meteor: { + children() { + const indicesOfTerminatingProps = []; + const decendants = CreatureProperties.find({ + 'ancestors.id': this.model._id, + 'removed': { $ne: true }, + }, { + sort: {order: 1} + }).map(prop => { + // Get all the props we don't want to show the decendants of and + // where they might appear in the ancestor list + if (prop.type === 'buff' || prop.type === 'folder') { + indicesOfTerminatingProps.push({ + id: prop._id, + ancestorIndex: prop.ancestors.length, + }); + } + return prop; + }).filter(prop => { + // Filter out folders entirely + if (prop.type === 'folder') return false; + // Filter out decendants of terminating props + return !some(indicesOfTerminatingProps, buffIndex => { + return prop.ancestors[buffIndex.ancestorIndex]?.id === buffIndex.id; + }); + }); + return nodeArrayToTree(decendants); + }, + }, methods: { - click(e){ - this.$emit('click', e); - }, - doAction({advantage}){ + click(e) { + this.$emit('click', e); + }, + doAction({ advantage }) { this.doActionLoading = true; this.shwing(); doAction.call({ @@ -201,13 +238,13 @@ export default { } }, error => { this.doActionLoading = false; - if (error){ + if (error) { console.error(error); - snackbar({text: error.reason}); + snackbar({ text: error.reason }); } }); }, - shwing(){ + shwing() { this.activated = true; setTimeout(() => { this.activated = undefined; @@ -222,9 +259,11 @@ export default { transition: box-shadow .4s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.075s ease; } + .action-card.active { transform: scale(0.92); } + .action-title { font-size: 16px; font-weight: 400; @@ -235,9 +274,10 @@ export default { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - transition: .3s cubic-bezier(.25,.8,.5,1); + transition: .3s cubic-bezier(.25, .8, .5, 1); width: 100%; } + .action-sub-title { color: #9e9e9e; flex-grow: 0; @@ -249,15 +289,19 @@ export default { text-overflow: ellipsis; width: 100%; } + .action-child { height: 32px; } + .theme--light.muted-text { - color: rgba(0,0,0,.3) !important; + color: rgba(0, 0, 0, .3) !important; } + .theme--dark.muted-text { - color: hsla(0,0%,100%,.3) !important; + color: hsla(0, 0%, 100%, .3) !important; } + .action-card { transition: transform 0.15s cubic; } @@ -265,12 +309,14 @@ export default { diff --git a/app/imports/ui/properties/components/attributes/AbilityListTile.vue b/app/imports/ui/properties/components/attributes/AbilityListTile.vue index c4d748da..46d36bb8 100644 --- a/app/imports/ui/properties/components/attributes/AbilityListTile.vue +++ b/app/imports/ui/properties/components/attributes/AbilityListTile.vue @@ -55,7 +55,7 @@ import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; import RollPopup from '/imports/ui/components/RollPopup.vue'; import doCheck from '/imports/api/engine/actions/doCheck.js'; -import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js'; +import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; export default { components: { @@ -66,23 +66,25 @@ export default { default: {}, }, }, - props: { - model: {type: Object, required: true}, - }, - data(){return { - checkLoading: false, - }}, - computed: { - hasClickListener(){ + props: { + model: { type: Object, required: true }, + }, + data() { + return { + checkLoading: false, + } + }, + computed: { + hasClickListener() { return this.$listeners && this.$listeners.click - }, - }, - methods: { - numberToSignedString, - click(e){ - this.$emit('click', e); - }, - check({advantage}){ + }, + }, + methods: { + numberToSignedString, + click(e) { + this.$emit('click', e); + }, + check({ advantage }) { this.checkLoading = true; doCheck.call({ propId: this.model._id, @@ -91,15 +93,15 @@ export default { }, }, error => { this.checkLoading = false; - if (error){ + if (error) { console.error(error); - snackbar({text: error.reason}); + snackbar({ text: error.reason }); } }); }, - }, + }, meteor: { - swapScoresAndMods(){ + swapScoresAndMods() { let user = Meteor.user(); return user && user.preferences && @@ -110,26 +112,32 @@ export default { diff --git a/app/imports/ui/properties/components/attributes/AttributeEffect.vue b/app/imports/ui/properties/components/attributes/AttributeEffect.vue index 687500bc..5725a3ef 100644 --- a/app/imports/ui/properties/components/attributes/AttributeEffect.vue +++ b/app/imports/ui/properties/components/attributes/AttributeEffect.vue @@ -27,9 +27,9 @@
{{ displayedText }}
-
+
diff --git a/app/imports/ui/properties/components/attributes/HealthBarCard.vue b/app/imports/ui/properties/components/attributes/HealthBarCard.vue index f05c7978..c3409ee0 100644 --- a/app/imports/ui/properties/components/attributes/HealthBarCard.vue +++ b/app/imports/ui/properties/components/attributes/HealthBarCard.vue @@ -17,17 +17,17 @@ diff --git a/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue b/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue index 9b8440e7..639beee0 100644 --- a/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue +++ b/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue @@ -12,60 +12,60 @@ diff --git a/app/imports/ui/properties/components/attributes/HitDiceListTile.vue b/app/imports/ui/properties/components/attributes/HitDiceListTile.vue index e26f1abd..7cfff667 100644 --- a/app/imports/ui/properties/components/attributes/HitDiceListTile.vue +++ b/app/imports/ui/properties/components/attributes/HitDiceListTile.vue @@ -31,9 +31,7 @@ - +
{{ model.value }}
@@ -63,60 +61,71 @@ export default { inject: { context: { default: {} } }, - props: { + props: { model: { type: Object, required: true, } - }, - data(){ return{ - hover: false, - }}, + }, + data() { + return { + hover: false, + } + }, computed: { - signedConMod(){ + signedConMod() { return numberToSignedString(this.model.constitutionMod); }, }, - methods: { - click(e){ - this.$emit('click', e); - }, - increment(value){ - this.$emit('change', {type: 'increment', value}) - }, - }, + methods: { + click(e) { + this.$emit('click', e); + }, + increment(value) { + this.$emit('change', { type: 'increment', value }) + }, + }, }; diff --git a/app/imports/ui/properties/components/attributes/ResourceCard.vue b/app/imports/ui/properties/components/attributes/ResourceCard.vue index 04d20220..c5759892 100644 --- a/app/imports/ui/properties/components/attributes/ResourceCard.vue +++ b/app/imports/ui/properties/components/attributes/ResourceCard.vue @@ -8,7 +8,7 @@ mdi-chevron-up @@ -16,19 +16,20 @@ mdi-chevron-down
-
+
{{ model.value }}
-
+
/{{ model.total }}
@@ -50,55 +51,64 @@ diff --git a/app/imports/ui/properties/components/attributes/SpellSlotListTile.vue b/app/imports/ui/properties/components/attributes/SpellSlotListTile.vue index 02afc030..584fc765 100644 --- a/app/imports/ui/properties/components/attributes/SpellSlotListTile.vue +++ b/app/imports/ui/properties/components/attributes/SpellSlotListTile.vue @@ -7,9 +7,7 @@ v-on="hasClickListener ? {click} : {}" > - +
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; export default { - props: { - model: { + props: { + model: { type: Object, required: true, }, dark: Boolean, hideCastButton: Boolean, disabled: Boolean, - }, - computed: { - hasClickListener(){ + }, + computed: { + hasClickListener() { return this.$listeners && !!this.$listeners.click; }, - }, - methods: { - signed: numberToSignedString, - click(e){ - this.$emit('click', e); - }, - }, + }, + methods: { + signed: numberToSignedString, + click(e) { + this.$emit('click', e); + }, + }, }; diff --git a/app/imports/ui/properties/components/features/FeatureCard.vue b/app/imports/ui/properties/components/features/FeatureCard.vue index 0f706b3e..860e11de 100644 --- a/app/imports/ui/properties/components/features/FeatureCard.vue +++ b/app/imports/ui/properties/components/features/FeatureCard.vue @@ -10,7 +10,7 @@ - + diff --git a/app/imports/ui/properties/components/inventory/ItemList.vue b/app/imports/ui/properties/components/inventory/ItemList.vue index 6696ee12..28a07995 100644 --- a/app/imports/ui/properties/components/inventory/ItemList.vue +++ b/app/imports/ui/properties/components/inventory/ItemList.vue @@ -52,40 +52,42 @@ export default { preparingSpells: Boolean, equipment: Boolean, }, - data(){ return { - dataItems: [], - }}, + data() { + return { + dataItems: [], + } + }, computed: { - levels(){ + levels() { let levels = new Set(); this.items.forEach(item => levels.add(item.level)); return levels; }, }, watch: { - items(value){ + items(value) { this.dataItems = value; } }, - mounted(){ + mounted() { this.dataItems = this.items; }, methods: { - clickProperty(_id){ - this.$store.commit('pushDialogStack', { - component: 'creature-property-dialog', - elementId: _id, - data: {_id}, - }); - }, - change({added, moved}){ + clickProperty(_id) { + this.$store.commit('pushDialogStack', { + component: 'creature-property-dialog', + elementId: _id, + data: { _id }, + }); + }, + change({ added, moved }) { let event = added || moved; - if (event){ + if (event) { // If this item is now adjacent to another, set the order accordingly let order; let before = this.dataItems[event.newIndex - 1]; let after = this.dataItems[event.newIndex + 1]; - if (before && before._id){ + if (before && before._id) { order = before.order + 0.5; } else if (after && after._id) { order = after.order - 0.5; @@ -101,7 +103,7 @@ export default { parentRef: this.parentRef, order, }); - if (doc.type === 'item' && doc.equipped != this.equipment){ + if (doc.type === 'item' && doc.equipped != this.equipment) { updateCreatureProperty.call({ _id: doc._id, path: ['equipped'], @@ -111,6 +113,6 @@ export default { } setTimeout(() => this.dataItems = this.items, 0); }, - } + } } diff --git a/app/imports/ui/properties/components/inventory/ItemListTile.vue b/app/imports/ui/properties/components/inventory/ItemListTile.vue index d6e64249..5dfa1b8e 100644 --- a/app/imports/ui/properties/components/inventory/ItemListTile.vue +++ b/app/imports/ui/properties/components/inventory/ItemListTile.vue @@ -49,10 +49,10 @@ import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeView import PROPERTIES from '/imports/constants/PROPERTIES.js'; import adjustQuantity from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js'; import IncrementButton from '/imports/ui/components/IncrementButton.vue'; -import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js'; +import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; export default { - components:{ + components: { IncrementButton, }, mixins: [treeNodeViewMixin], @@ -62,20 +62,22 @@ export default { props: { preparingSpells: Boolean, }, - data(){return { - incrementLoading: false, - }}, + data() { + return { + incrementLoading: false, + } + }, computed: { - hasClickListener(){ + hasClickListener() { return this.$listeners && !!this.$listeners.click; }, - title(){ + title() { let model = this.model; if (!model) return; - if (model.quantity !== 1){ - if (model.plural){ + if (model.quantity !== 1) { + if (model.plural) { return `${model.quantity} ${model.plural}`; - } else if (model.name){ + } else if (model.name) { return `${model.quantity} ${model.name}`; } } else if (model.name) { @@ -86,10 +88,10 @@ export default { } }, methods: { - click(e){ - this.$emit('click', e); - }, - changeQuantity({type, value}) { + click(e) { + this.$emit('click', e); + }, + changeQuantity({ type, value }) { this.incrementLoading = true; adjustQuantity.call({ _id: this.model._id, @@ -97,8 +99,8 @@ export default { value: value }, error => { this.incrementLoading = false; - if (error){ - snackbar({text: error.reason}); + if (error) { + snackbar({ text: error.reason }); console.error(error); } }); @@ -111,6 +113,7 @@ export default { .item-avatar { min-width: 32px; } + .item { background-color: inherit; } diff --git a/app/imports/ui/properties/components/persona/NoteCard.vue b/app/imports/ui/properties/components/persona/NoteCard.vue index f3be2a0e..d07afa1e 100644 --- a/app/imports/ui/properties/components/persona/NoteCard.vue +++ b/app/imports/ui/properties/components/persona/NoteCard.vue @@ -31,10 +31,10 @@ import isDarkColor from '/imports/ui/utility/isDarkColor.js'; import CardHighlight from '/imports/ui/components/CardHighlight.vue'; export default { - components: { - PropertyDescription, + components: { + PropertyDescription, CardHighlight, - }, + }, inject: { theme: { default: { @@ -42,31 +42,34 @@ export default { }, }, }, - props: { - model: { + props: { + model: { type: Object, required: true, }, - }, - data(){ return{ - hover: false, - }}, + }, + data() { + return { + hover: false, + } + }, computed: { - isDark(){ + isDark() { return isDarkColor(this.model.color); }, }, - methods: { - clickProperty(_id){ - this.$store.commit('pushDialogStack', { - component: 'creature-property-dialog', - elementId: `${_id}`, - data: {_id}, - }); - }, - }, + methods: { + clickProperty(_id) { + this.$store.commit('pushDialogStack', { + component: 'creature-property-dialog', + elementId: `${_id}`, + data: { _id }, + }); + }, + }, }; diff --git a/app/imports/ui/properties/components/pointBuy/PointBuyCard.vue b/app/imports/ui/properties/components/pointBuy/PointBuyCard.vue new file mode 100644 index 00000000..cbd3f339 --- /dev/null +++ b/app/imports/ui/properties/components/pointBuy/PointBuyCard.vue @@ -0,0 +1,72 @@ + + + diff --git a/app/imports/ui/properties/components/skills/SkillListTile.vue b/app/imports/ui/properties/components/skills/SkillListTile.vue index 45f8b138..bbcb773e 100644 --- a/app/imports/ui/properties/components/skills/SkillListTile.vue +++ b/app/imports/ui/properties/components/skills/SkillListTile.vue @@ -8,7 +8,7 @@ -
+
{{ model.name }} diff --git a/app/imports/ui/properties/forms/BuffRemoverForm.vue b/app/imports/ui/properties/forms/BuffRemoverForm.vue new file mode 100644 index 00000000..ed5845af --- /dev/null +++ b/app/imports/ui/properties/forms/BuffRemoverForm.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/app/imports/ui/properties/forms/ClassForm.vue b/app/imports/ui/properties/forms/ClassForm.vue new file mode 100644 index 00000000..f4bd101e --- /dev/null +++ b/app/imports/ui/properties/forms/ClassForm.vue @@ -0,0 +1,181 @@ + + + diff --git a/app/imports/ui/properties/forms/ClassLevelForm.vue b/app/imports/ui/properties/forms/ClassLevelForm.vue index e34fe57f..854380e2 100644 --- a/app/imports/ui/properties/forms/ClassLevelForm.vue +++ b/app/imports/ui/properties/forms/ClassLevelForm.vue @@ -55,19 +55,27 @@ :error-messages="errors.tags" @change="change('tags', ...arguments)" /> + + +
diff --git a/app/imports/ui/properties/forms/ConstantForm.vue b/app/imports/ui/properties/forms/ConstantForm.vue index cf160914..27817bf3 100644 --- a/app/imports/ui/properties/forms/ConstantForm.vue +++ b/app/imports/ui/properties/forms/ConstantForm.vue @@ -24,6 +24,13 @@ @change="change('calculation', ...arguments)" /> + + +
diff --git a/app/imports/ui/properties/forms/ContainerForm.vue b/app/imports/ui/properties/forms/ContainerForm.vue index 39b9defd..58f127e0 100644 --- a/app/imports/ui/properties/forms/ContainerForm.vue +++ b/app/imports/ui/properties/forms/ContainerForm.vue @@ -55,30 +55,36 @@ $emit('change', {path: ['description', ...path], value, ack})" /> - - -
-
- + + + + + + + +
+
+ +
-
- + +
@@ -87,9 +93,9 @@ import FormSection from '/imports/ui/properties/forms/shared/FormSection.vue'; import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; export default { - components: { - FormSection, - }, + components: { + FormSection, + }, mixins: [propertyFormMixin], } diff --git a/app/imports/ui/properties/forms/DamageForm.vue b/app/imports/ui/properties/forms/DamageForm.vue index 7acf3e36..1faa66f3 100644 --- a/app/imports/ui/properties/forms/DamageForm.vue +++ b/app/imports/ui/properties/forms/DamageForm.vue @@ -48,11 +48,24 @@ chips deletable-chips hint="" - :items="['magical', 'silvered']" + :items="['magical', 'silvered', 'ignore resistance', 'ignore vulnerability', 'ignore immunity']" :value="model.tags" :error-messages="errors.tags" @change="change('tags', ...arguments)" /> + + + +
@@ -63,45 +76,48 @@ import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; export default { mixins: [propertyFormMixin], - props: { - parentTarget: { - type: String, + props: { + parentTarget: { + type: String, default: undefined, - }, - }, - data(){return{ - DAMAGE_TYPES, - damageTypeRules: [ - value => { - if (!value) return 'Damage type is required'; - if (!VARIABLE_NAME_REGEX.test(value)){ - return `${value} is not a valid damage name` + }, + }, + data() { + return { + DAMAGE_TYPES, + damageTypeRules: [ + value => { + if (!value) return 'Damage type is required'; + if (!VARIABLE_NAME_REGEX.test(value)) { + return `${value} is not a valid damage name` + } } - } - ], - }}, - computed: { - targetOptions(){ - return [ - { - text: 'Self', - value: 'self', - }, { - text: 'Target', - value: 'target', - }, - ]; - }, - targetOptionHint(){ - let hints = { - self: 'The damage will be applied to the character taking the action', - target: 'The damage will be applied to the target of the action', - }; - return hints[this.model.target]; - } - }, + ], + } + }, + computed: { + targetOptions() { + return [ + { + text: 'Self', + value: 'self', + }, { + text: 'Target', + value: 'target', + }, + ]; + }, + targetOptionHint() { + let hints = { + self: 'The damage will be applied to the character taking the action', + target: 'The damage will be applied to the target of the action', + }; + return hints[this.model.target]; + } + }, } diff --git a/app/imports/ui/properties/forms/DamageMultiplierForm.vue b/app/imports/ui/properties/forms/DamageMultiplierForm.vue index 7f0b25bc..551f1514 100644 --- a/app/imports/ui/properties/forms/DamageMultiplierForm.vue +++ b/app/imports/ui/properties/forms/DamageMultiplierForm.vue @@ -47,8 +47,12 @@ + + + diff --git a/app/imports/ui/properties/forms/EffectForm.vue b/app/imports/ui/properties/forms/EffectForm.vue index c4fcecdb..694db21d 100644 --- a/app/imports/ui/properties/forms/EffectForm.vue +++ b/app/imports/ui/properties/forms/EffectForm.vue @@ -29,9 +29,7 @@ slot="item" slot-scope="item" > - + {{ getEffectIcon(item.item.value, 1) }} {{ item.item.text }} @@ -149,183 +147,196 @@
- - - + + + + + + + + + - - - + +
diff --git a/app/imports/ui/properties/forms/FeatureForm.vue b/app/imports/ui/properties/forms/FeatureForm.vue index 77f3a77b..c3f85735 100644 --- a/app/imports/ui/properties/forms/FeatureForm.vue +++ b/app/imports/ui/properties/forms/FeatureForm.vue @@ -35,53 +35,63 @@ :error-messages="errors.tags" @change="change('tags', ...arguments)" /> + + +
diff --git a/app/imports/ui/properties/forms/FolderForm.vue b/app/imports/ui/properties/forms/FolderForm.vue index 8f55fe54..77c96aec 100644 --- a/app/imports/ui/properties/forms/FolderForm.vue +++ b/app/imports/ui/properties/forms/FolderForm.vue @@ -9,34 +9,41 @@ :error-messages="errors.name" @change="change('name', ...arguments)" /> - - - + + + + + + + + +
diff --git a/app/imports/ui/properties/forms/ItemForm.vue b/app/imports/ui/properties/forms/ItemForm.vue index 08a9e586..d8097daa 100644 --- a/app/imports/ui/properties/forms/ItemForm.vue +++ b/app/imports/ui/properties/forms/ItemForm.vue @@ -100,47 +100,56 @@ $emit('change', {path: ['description', ...path], value, ack})" /> - - - - - -
- -
-
-
+ + + + + + + + + + +
+ +
+
+
+
@@ -149,9 +158,9 @@ import FormSection from '/imports/ui/properties/forms/shared/FormSection.vue'; import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; export default { - components: { - FormSection, - }, + components: { + FormSection, + }, mixins: [propertyFormMixin], - } +} diff --git a/app/imports/ui/properties/forms/ItemsConsumedListForm.vue b/app/imports/ui/properties/forms/ItemsConsumedListForm.vue index 80069155..97531ec6 100644 --- a/app/imports/ui/properties/forms/ItemsConsumedListForm.vue +++ b/app/imports/ui/properties/forms/ItemsConsumedListForm.vue @@ -29,13 +29,13 @@ diff --git a/app/imports/ui/properties/forms/NoteForm.vue b/app/imports/ui/properties/forms/NoteForm.vue index a328de80..ba9eeafd 100644 --- a/app/imports/ui/properties/forms/NoteForm.vue +++ b/app/imports/ui/properties/forms/NoteForm.vue @@ -35,6 +35,13 @@ :value="model.tags" @change="change('tags', ...arguments)" /> + + + diff --git a/app/imports/ui/properties/forms/PointBuyForm.vue b/app/imports/ui/properties/forms/PointBuyForm.vue new file mode 100644 index 00000000..41426acb --- /dev/null +++ b/app/imports/ui/properties/forms/PointBuyForm.vue @@ -0,0 +1,219 @@ + + + diff --git a/app/imports/ui/properties/forms/PointBuySpendForm.vue b/app/imports/ui/properties/forms/PointBuySpendForm.vue new file mode 100644 index 00000000..403cd927 --- /dev/null +++ b/app/imports/ui/properties/forms/PointBuySpendForm.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/imports/ui/properties/forms/ProficiencyForm.vue b/app/imports/ui/properties/forms/ProficiencyForm.vue index 41961c69..4fcf2825 100644 --- a/app/imports/ui/properties/forms/ProficiencyForm.vue +++ b/app/imports/ui/properties/forms/ProficiencyForm.vue @@ -37,21 +37,29 @@ :value="model.tags" @change="change('tags', ...arguments)" /> + + + diff --git a/app/imports/ui/properties/forms/ResourcesForm.vue b/app/imports/ui/properties/forms/ResourcesForm.vue index 886ba8c4..e3fc0dcb 100644 --- a/app/imports/ui/properties/forms/ResourcesForm.vue +++ b/app/imports/ui/properties/forms/ResourcesForm.vue @@ -24,87 +24,88 @@ @push="({path, value, ack}) => $emit('push', {path: ['itemsConsumed', ...path], value, ack})" @pull="({path, ack}) => $emit('pull', {path: ['itemsConsumed', ...path], ack})" /> -
- - - - - Add Resource - - - Add Ammo - - - -
+ + + + + Add Resource + + + Add Ammo + + + diff --git a/app/imports/ui/properties/forms/RollForm.vue b/app/imports/ui/properties/forms/RollForm.vue index 0355018f..c06ceaa6 100644 --- a/app/imports/ui/properties/forms/RollForm.vue +++ b/app/imports/ui/properties/forms/RollForm.vue @@ -35,6 +35,13 @@ $emit('change', {path: ['roll', ...path], value, ack})" /> + + + + diff --git a/app/imports/ui/properties/forms/SavingThrowForm.vue b/app/imports/ui/properties/forms/SavingThrowForm.vue index a0e8a02e..8e0c5540 100644 --- a/app/imports/ui/properties/forms/SavingThrowForm.vue +++ b/app/imports/ui/properties/forms/SavingThrowForm.vue @@ -65,6 +65,19 @@ :error-messages="errors.tags" @change="change('tags', ...arguments)" /> + + + + @@ -75,24 +88,24 @@ import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormM 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]; - } - }, + 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/ui/properties/forms/SkillForm.vue b/app/imports/ui/properties/forms/SkillForm.vue index 0bfe5ebb..91d30970 100644 --- a/app/imports/ui/properties/forms/SkillForm.vue +++ b/app/imports/ui/properties/forms/SkillForm.vue @@ -45,79 +45,86 @@ $emit('change', {path: ['description', ...path], value, ack})" /> - - -
- + + + + + + - -
-
+
+ + +
+
+
diff --git a/app/imports/ui/properties/forms/SlotFillerForm.vue b/app/imports/ui/properties/forms/SlotFillerForm.vue index d0f84567..28890a30 100644 --- a/app/imports/ui/properties/forms/SlotFillerForm.vue +++ b/app/imports/ui/properties/forms/SlotFillerForm.vue @@ -1,20 +1,37 @@ diff --git a/app/imports/ui/properties/forms/SlotForm.vue b/app/imports/ui/properties/forms/SlotForm.vue index df857437..f2dc7c9b 100644 --- a/app/imports/ui/properties/forms/SlotForm.vue +++ b/app/imports/ui/properties/forms/SlotForm.vue @@ -77,6 +77,7 @@ v-for="(extras, i) in model.extraTags" :key="extras._id" class="extra-tags layout align-center justify-space-between" + style="transition: all 0.3s !important;" > - -
- + + + + + +
+ + +
+ - -
- -
+ + diff --git a/app/imports/ui/properties/forms/SpellForm.vue b/app/imports/ui/properties/forms/SpellForm.vue index 7fafb173..4544857c 100644 --- a/app/imports/ui/properties/forms/SpellForm.vue +++ b/app/imports/ui/properties/forms/SpellForm.vue @@ -238,7 +238,7 @@ $emit('change', {path: ['description', ...path], value, ack})" /> - + - + + + + + + + diff --git a/app/imports/ui/properties/forms/SpellListForm.vue b/app/imports/ui/properties/forms/SpellListForm.vue index e2040971..b44ea60c 100644 --- a/app/imports/ui/properties/forms/SpellListForm.vue +++ b/app/imports/ui/properties/forms/SpellListForm.vue @@ -54,6 +54,14 @@ :value="model.tags" @change="change('tags', ...arguments)" /> + + + + diff --git a/app/imports/ui/properties/forms/ToggleForm.vue b/app/imports/ui/properties/forms/ToggleForm.vue index a10de795..5f98729c 100644 --- a/app/imports/ui/properties/forms/ToggleForm.vue +++ b/app/imports/ui/properties/forms/ToggleForm.vue @@ -41,9 +41,7 @@ cols="12" md="6" > - + + + + + diff --git a/app/imports/ui/properties/forms/TriggerForm.vue b/app/imports/ui/properties/forms/TriggerForm.vue new file mode 100644 index 00000000..d48932a7 --- /dev/null +++ b/app/imports/ui/properties/forms/TriggerForm.vue @@ -0,0 +1,214 @@ + + + diff --git a/app/imports/ui/properties/forms/shared/CalculationErrorList.vue b/app/imports/ui/properties/forms/shared/CalculationErrorList.vue index 93b2df2d..a3f7d152 100644 --- a/app/imports/ui/properties/forms/shared/CalculationErrorList.vue +++ b/app/imports/ui/properties/forms/shared/CalculationErrorList.vue @@ -73,5 +73,8 @@ export default { } - diff --git a/app/imports/ui/properties/forms/shared/ComputedField.vue b/app/imports/ui/properties/forms/shared/ComputedField.vue index d6b237e4..9d3ac194 100644 --- a/app/imports/ui/properties/forms/shared/ComputedField.vue +++ b/app/imports/ui/properties/forms/shared/ComputedField.vue @@ -6,7 +6,7 @@ @change="(value, ack) => $emit('change', {path: ['calculation'], value, ack})" > diff --git a/app/imports/ui/properties/forms/shared/ProficiencySelect.vue b/app/imports/ui/properties/forms/shared/ProficiencySelect.vue index 51e1dc4c..1af7095a 100644 --- a/app/imports/ui/properties/forms/shared/ProficiencySelect.vue +++ b/app/imports/ui/properties/forms/shared/ProficiencySelect.vue @@ -22,11 +22,11 @@ diff --git a/app/imports/ui/properties/forms/shared/propertyFormIndex.js b/app/imports/ui/properties/forms/shared/propertyFormIndex.js index 2e28b01c..bd6c6ba9 100644 --- a/app/imports/ui/properties/forms/shared/propertyFormIndex.js +++ b/app/imports/ui/properties/forms/shared/propertyFormIndex.js @@ -1,9 +1,10 @@ const ActionForm = () => import('/imports/ui/properties/forms/ActionForm.vue'); const AdjustmentForm = () => import('/imports/ui/properties/forms/AdjustmentForm.vue'); -const AttackForm = () => import('/imports/ui/properties/forms/AttackForm.vue'); const AttributeForm = () => import('/imports/ui/properties/forms/AttributeForm.vue'); const BuffForm = () => import('/imports/ui/properties/forms/BuffForm.vue'); +const BuffRemoverForm = () => import('/imports/ui/properties/forms/BuffRemoverForm.vue'); const BranchForm = () => import('/imports/ui/properties/forms/BranchForm.vue'); +const ClassForm = () => import('/imports/ui/properties/forms/ClassForm.vue'); const ClassLevelForm = () => import('/imports/ui/properties/forms/ClassLevelForm.vue'); const ConstantForm = () => import('/imports/ui/properties/forms/ConstantForm.vue'); const ContainerForm = () => import('/imports/ui/properties/forms/ContainerForm.vue'); @@ -14,6 +15,7 @@ const FeatureForm = () => import('/imports/ui/properties/forms/FeatureForm.vue') const FolderForm = () => import('/imports/ui/properties/forms/FolderForm.vue'); const ItemForm = () => import('/imports/ui/properties/forms/ItemForm.vue'); const NoteForm = () => import('/imports/ui/properties/forms/NoteForm.vue'); +const PointBuyForm = () => import('/imports/ui/properties/forms/PointBuyForm.vue'); const ProficiencyForm = () => import('/imports/ui/properties/forms/ProficiencyForm.vue'); const ReferenceForm = () => import('/imports/ui/properties/forms/ReferenceForm.vue'); const RollForm = () => import('/imports/ui/properties/forms/RollForm.vue'); @@ -24,18 +26,19 @@ const SlotFillerForm = () => import('/imports/ui/properties/forms/SlotFillerForm const SpellListForm = () => import('/imports/ui/properties/forms/SpellListForm.vue'); const SpellForm = () => import('/imports/ui/properties/forms/SpellForm.vue'); const ToggleForm = () => import('/imports/ui/properties/forms/ToggleForm.vue'); +const TriggerForm = () => import('/imports/ui/properties/forms/TriggerForm.vue'); export default { action: ActionForm, adjustment: AdjustmentForm, - attack: AttackForm, attribute: AttributeForm, buff: BuffForm, + buffRemover: BuffRemoverForm, branch: BranchForm, constant: ConstantForm, container: ContainerForm, + class: ClassForm, classLevel: ClassLevelForm, - class: SlotForm, damage: DamageForm, damageMultiplier: DamageMultiplierForm, effect: EffectForm, @@ -43,6 +46,7 @@ export default { folder: FolderForm, item: ItemForm, note: NoteForm, + pointBuy: PointBuyForm, proficiency: ProficiencyForm, propertySlot: SlotForm, reference: ReferenceForm, @@ -53,4 +57,5 @@ export default { spellList: SpellListForm, spell: SpellForm, toggle: ToggleForm, + trigger: TriggerForm, }; diff --git a/app/imports/ui/properties/forms/shared/propertyFormMixin.js b/app/imports/ui/properties/forms/shared/propertyFormMixin.js index e9eed2f4..172db58f 100644 --- a/app/imports/ui/properties/forms/shared/propertyFormMixin.js +++ b/app/imports/ui/properties/forms/shared/propertyFormMixin.js @@ -1,10 +1,13 @@ import ComputedField from '/imports/ui/properties/forms/shared/ComputedField.vue'; import InlineComputationField from '/imports/ui/properties/forms/shared/InlineComputationField.vue'; +import FormSection, { FormSections } from '/imports/ui/properties/forms/shared/FormSection.vue'; export default { components: { ComputedField, InlineComputationField, + FormSection, + FormSections, }, props: { model: { diff --git a/app/imports/ui/properties/forms/shared/schemaFormMixin.js b/app/imports/ui/properties/forms/shared/schemaFormMixin.js index e1d3a349..5db234da 100644 --- a/app/imports/ui/properties/forms/shared/schemaFormMixin.js +++ b/app/imports/ui/properties/forms/shared/schemaFormMixin.js @@ -4,9 +4,9 @@ */ import { get, toPath } from 'lodash'; -function resolvePath(model, path, set){ +function resolvePath(model, path, set) { let arrayPath = toPath(path); - if (arrayPath.length === 1){ + if (arrayPath.length === 1) { return { object: model, key: arrayPath[0] }; } let key = arrayPath.slice(-1); @@ -15,67 +15,69 @@ function resolvePath(model, path, set){ // Ensure that nested objects exist before navigating them objectPath.forEach(pathKey => { let newObject = object[pathKey]; - if (!newObject){ + if (!newObject) { newObject = {}; set(object, pathKey, newObject); } object = newObject; }); - return {object, key}; + return { object, key }; } const schemaFormMixin = { - data(){ return { - valid: true, - };}, - computed: { - errors(){ - this.valid = true; - if (!this.model){ - throw new Error('this.model must be set'); - } - if (!this.validationContext) return {}; - let cleanModel = this.validationContext.clean(this.model, { - getAutoValues: false, - }); - this.validationContext.validate(cleanModel); - let errors = {}; - this.validationContext.validationErrors().forEach(error => { - if (this.valid) this.valid = false; - errors[error.name] = this.schema.messageForError(error); - }); - return errors; - }, - }, - methods: { + data() { + return { + valid: true, + }; + }, + computed: { + errors() { + this.valid = true; + if (!this.model) { + throw new Error('this.model must be set'); + } + if (!this.validationContext) return {}; + let cleanModel = this.validationContext.clean(this.model, { + getAutoValues: false, + }); + this.validationContext.validate(cleanModel); + let errors = {}; + this.validationContext.validationErrors().forEach(error => { + if (this.valid) this.valid = false; + errors[error.name] = this.schema.messageForError(error); + }); + return errors; + }, + }, + methods: { // Sets the value at the given path - change({path, value, ack}){ - let {object, key} = resolvePath(this.model, path, this.$set); + change({ path, value, ack }) { + let { object, key } = resolvePath(this.model, path, this.$set); - this.$set(object, key, value); - if (ack) ack(); - }, - push({path, value, ack}){ + this.$set(object, key, value); + if (ack) ack(); + }, + push({ path, value, ack }) { let array = get(this.model, path); - if (array === undefined){ - let {object, key} = resolvePath(this.model, path, this.$set); + if (array === undefined) { + let { object, key } = resolvePath(this.model, path, this.$set); this.$set(object, key, [value]); - } else if (!array.push){ + } else if (!array.push) { throw `${path.join('.')} is ${array}, doesn't have "push"` } else { array.push(value); } - if (ack) ack(); + if (ack) ack(); }, - pull({path, ack}){ - let {object, key} = resolvePath(this.model, path, this.$set); - if (!object || !object.splice){ + pull({ path, ack }) { + let { object, key } = resolvePath(this.model, path, this.$set); + if (!object || !object.splice) { throw `${path.join('.')} is ${object}, doesnt have "splice"` } object.splice(key, 1); if (ack) ack(); }, - }, + }, }; export default schemaFormMixin; diff --git a/app/imports/ui/properties/shared/ProficiencyIcon.vue b/app/imports/ui/properties/shared/ProficiencyIcon.vue index 91249fc3..223ef725 100644 --- a/app/imports/ui/properties/shared/ProficiencyIcon.vue +++ b/app/imports/ui/properties/shared/ProficiencyIcon.vue @@ -8,16 +8,16 @@ import getProficiencyIcon from '/imports/ui/utility/getProficiencyIcon.js'; export default { - props: { - value: { + props: { + value: { type: Number, default: undefined, }, - }, - computed: { - displayedIcon(){ + }, + computed: { + displayedIcon(){ return getProficiencyIcon(this.value); - } - } + } + } } diff --git a/app/imports/ui/properties/shared/PropertyIcon.vue b/app/imports/ui/properties/shared/PropertyIcon.vue index bf173aef..73a241db 100644 --- a/app/imports/ui/properties/shared/PropertyIcon.vue +++ b/app/imports/ui/properties/shared/PropertyIcon.vue @@ -18,8 +18,8 @@ import { getPropertyIcon } from '/imports/constants/PROPERTIES.js'; export default { - props: { - model: { + props: { + model: { type: Object, default: () => ({}), }, @@ -28,17 +28,18 @@ export default { default: undefined, }, disabled: Boolean, - }, - computed: { - icon(){ - return getPropertyIcon(this.model && this.model.type); - }, - }, + }, + computed: { + icon() { + return getPropertyIcon(this.model && this.model.type); + }, + }, } diff --git a/app/imports/ui/properties/shared/PropertySelector.vue b/app/imports/ui/properties/shared/PropertySelector.vue index 36670fe7..8fc32b7e 100644 --- a/app/imports/ui/properties/shared/PropertySelector.vue +++ b/app/imports/ui/properties/shared/PropertySelector.vue @@ -1,8 +1,6 @@ diff --git a/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js b/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js index 26d52ca0..1f9772e8 100644 --- a/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js +++ b/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js @@ -7,6 +7,7 @@ import EffectTreeNode from '/imports/ui/properties/treeNodeViews/EffectTreeNode. import ClassLevelTreeNode from '/imports/ui/properties/treeNodeViews/ClassLevelTreeNode.vue'; import ProficiencyTreeNode from '/imports/ui/properties/treeNodeViews/ProficiencyTreeNode.vue'; import ReferenceTreeNode from '/imports/ui/properties/treeNodeViews/ReferenceTreeNode.vue'; +import SavingThrowTreeNode from '/imports/ui/properties/treeNodeViews/SavingThrowTreeNode.vue'; export default { default: DefaultTreeNode, @@ -18,4 +19,5 @@ export default { item: ItemTreeNode, proficiency: ProficiencyTreeNode, reference: ReferenceTreeNode, + savingThrow: SavingThrowTreeNode, } diff --git a/app/imports/ui/properties/viewers/ActionViewer.vue b/app/imports/ui/properties/viewers/ActionViewer.vue index 91c6cd33..e9f49f6b 100644 --- a/app/imports/ui/properties/viewers/ActionViewer.vue +++ b/app/imports/ui/properties/viewers/ActionViewer.vue @@ -1,8 +1,6 @@ diff --git a/app/imports/ui/properties/viewers/ClassLevelViewer.vue b/app/imports/ui/properties/viewers/ClassLevelViewer.vue index e475d5fd..e2464b62 100644 --- a/app/imports/ui/properties/viewers/ClassLevelViewer.vue +++ b/app/imports/ui/properties/viewers/ClassLevelViewer.vue @@ -33,7 +33,7 @@ import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyViewerMixin.js' export default { - mixins: [propertyViewerMixin], + mixins: [propertyViewerMixin], inject: { context: { default: {}, @@ -43,4 +43,5 @@ export default { diff --git a/app/imports/ui/properties/viewers/ClassViewer.vue b/app/imports/ui/properties/viewers/ClassViewer.vue new file mode 100644 index 00000000..a03d3431 --- /dev/null +++ b/app/imports/ui/properties/viewers/ClassViewer.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/app/imports/ui/properties/viewers/ConstantViewer.vue b/app/imports/ui/properties/viewers/ConstantViewer.vue index e38839d8..3b13b3ad 100644 --- a/app/imports/ui/properties/viewers/ConstantViewer.vue +++ b/app/imports/ui/properties/viewers/ConstantViewer.vue @@ -15,9 +15,9 @@ diff --git a/app/imports/ui/properties/viewers/ContainerViewer.vue b/app/imports/ui/properties/viewers/ContainerViewer.vue index d66f9f08..0d2a7ab0 100644 --- a/app/imports/ui/properties/viewers/ContainerViewer.vue +++ b/app/imports/ui/properties/viewers/ContainerViewer.vue @@ -22,9 +22,7 @@ :value="model.value" /> - + {{ model.contentsWeight }} lb - + contents @@ -86,9 +82,7 @@ v-if="model.carried" value="Carried" /> - + diff --git a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue index acb7981b..cde9cba9 100644 --- a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue +++ b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue @@ -60,19 +60,20 @@ diff --git a/app/imports/ui/properties/viewers/DamageViewer.vue b/app/imports/ui/properties/viewers/DamageViewer.vue index 524964ab..8862e10c 100644 --- a/app/imports/ui/properties/viewers/DamageViewer.vue +++ b/app/imports/ui/properties/viewers/DamageViewer.vue @@ -21,18 +21,19 @@ diff --git a/app/imports/ui/properties/viewers/EffectViewer.vue b/app/imports/ui/properties/viewers/EffectViewer.vue index 772a02fd..5880fc10 100644 --- a/app/imports/ui/properties/viewers/EffectViewer.vue +++ b/app/imports/ui/properties/viewers/EffectViewer.vue @@ -6,9 +6,7 @@ class="layout" style="overflow: hidden;" > - + {{ effectIcon }} {{ operation }} @@ -77,70 +75,75 @@ diff --git a/app/imports/ui/properties/viewers/FeatureViewer.vue b/app/imports/ui/properties/viewers/FeatureViewer.vue index 4727df07..ae5c405b 100644 --- a/app/imports/ui/properties/viewers/FeatureViewer.vue +++ b/app/imports/ui/properties/viewers/FeatureViewer.vue @@ -16,9 +16,10 @@ diff --git a/app/imports/ui/properties/viewers/FolderViewer.vue b/app/imports/ui/properties/viewers/FolderViewer.vue index 352db109..51a51a76 100644 --- a/app/imports/ui/properties/viewers/FolderViewer.vue +++ b/app/imports/ui/properties/viewers/FolderViewer.vue @@ -5,9 +5,10 @@ diff --git a/app/imports/ui/properties/viewers/ItemViewer.vue b/app/imports/ui/properties/viewers/ItemViewer.vue index d5f07aab..0db65ce7 100644 --- a/app/imports/ui/properties/viewers/ItemViewer.vue +++ b/app/imports/ui/properties/viewers/ItemViewer.vue @@ -24,9 +24,7 @@ v-if="model.value !== undefined" name="value" > -
+
- + -
+
- + Equipped - + diff --git a/app/imports/ui/properties/viewers/RollViewer.vue b/app/imports/ui/properties/viewers/RollViewer.vue index d00aeed1..31e87bbf 100644 --- a/app/imports/ui/properties/viewers/RollViewer.vue +++ b/app/imports/ui/properties/viewers/RollViewer.vue @@ -17,9 +17,9 @@ diff --git a/app/imports/ui/properties/viewers/SavingThrowViewer.vue b/app/imports/ui/properties/viewers/SavingThrowViewer.vue index 02b4fbcb..59feea7a 100644 --- a/app/imports/ui/properties/viewers/SavingThrowViewer.vue +++ b/app/imports/ui/properties/viewers/SavingThrowViewer.vue @@ -22,9 +22,9 @@ diff --git a/app/imports/ui/properties/viewers/SkillViewer.vue b/app/imports/ui/properties/viewers/SkillViewer.vue index 580be486..db6609c5 100644 --- a/app/imports/ui/properties/viewers/SkillViewer.vue +++ b/app/imports/ui/properties/viewers/SkillViewer.vue @@ -47,6 +47,12 @@ name="Passive score" :value="passiveScore" /> + - ({ - _id: prop._id, - name: 'Skill base value', - operation: 'base', - calculation: prop.baseValueCalculation, - amount: {value: prop.baseValue?.value}, - stats: [prop.variableName], - ancestors: prop.ancestors, - }) ).filter(effect => effect.amount?.value); - } else { - return []; - } - }, - effects(){ - if (this.context.creatureId && this.model.variableName){ - let creatureId = this.context.creatureId; - return CreatureProperties.find({ - 'ancestors.id': creatureId, - stats: this.model.variableName, - type: 'effect', - removed: {$ne: true}, - inactive: {$ne: true}, - }).fetch(); - } else { - return []; - } - }, - baseProficiencies(){ - if (this.context.creatureId){ - let creatureId = this.context.creatureId; - return CreatureProperties.find({ - 'ancestors.id': creatureId, - type: 'skill', - variableName: this.model.variableName, - removed: {$ne: true}, - inactive: {$ne: true}, - }).map( prop => ({ + removed: { $ne: true }, + inactive: { $ne: true }, + }).map(prop => ({ _id: prop._id, name: 'Skill base proficiency', value: prop.baseProficiency, stats: [prop.variableName], ancestors: prop.ancestors, - }) ).filter(prof => prof.value); + })).filter(prof => prof.value); } else { return []; } }, - proficiencies(){ + proficiencies() { let creatureId = this.context.creatureId; - if (creatureId){ + if (creatureId) { return CreatureProperties.find({ 'ancestors.id': creatureId, stats: this.model.variableName, type: 'proficiency', - removed: {$ne: true}, - inactive: {$ne: true}, + removed: { $ne: true }, + inactive: { $ne: true }, }).fetch(); } else { return []; } }, - ability(){ + ability() { let creatureId = this.context.creatureId; let ability = this.model.ability; if (!creatureId || !ability) return; @@ -263,31 +234,30 @@ export default { 'ancestors.id': creatureId, variableName: ability, type: 'attribute', - removed: {$ne: true}, - inactive: {$ne: true}, - overridden: {$ne: true}, + removed: { $ne: true }, + inactive: { $ne: true }, + overridden: { $ne: true }, }); if (!abilityProp) return; return { _id: abilityProp._id, name: abilityProp.name, - operation: 'base', - amount: {value: abilityProp.modifier}, + operation: 'add', + amount: { value: abilityProp.modifier }, stats: [this.model.variableName], ancestors: abilityProp.ancestors, } }, - proficiencyBonus(){ + proficiencyBonus() { let creatureId = this.context.creatureId; if (!creatureId) return; - let creature = Creatures.findOne(creatureId) - return creature && - creature.variables.proficiencyBonus && - creature.variables.proficiencyBonus.value; + return this.variables.proficiencyBonus && + this.variables.proficiencyBonus.value; }, }, } diff --git a/app/imports/ui/properties/viewers/SlotFillerViewer.vue b/app/imports/ui/properties/viewers/SlotFillerViewer.vue index 6ebf8a36..d3cb60c5 100644 --- a/app/imports/ui/properties/viewers/SlotFillerViewer.vue +++ b/app/imports/ui/properties/viewers/SlotFillerViewer.vue @@ -32,32 +32,30 @@ name="Description" :cols="{cols: 12}" > - +
diff --git a/app/imports/ui/properties/viewers/SlotViewer.vue b/app/imports/ui/properties/viewers/SlotViewer.vue index bdd07cd5..55ceeb8d 100644 --- a/app/imports/ui/properties/viewers/SlotViewer.vue +++ b/app/imports/ui/properties/viewers/SlotViewer.vue @@ -16,7 +16,7 @@ />
+ + + + + mdi-plus + + Fill Slot + + -
diff --git a/app/imports/ui/properties/viewers/SpellListViewer.vue b/app/imports/ui/properties/viewers/SpellListViewer.vue index 9b4c9d49..f7c35b57 100644 --- a/app/imports/ui/properties/viewers/SpellListViewer.vue +++ b/app/imports/ui/properties/viewers/SpellListViewer.vue @@ -37,6 +37,6 @@ diff --git a/app/imports/ui/properties/viewers/SpellViewer.vue b/app/imports/ui/properties/viewers/SpellViewer.vue index 35b31e7b..ee8085b2 100644 --- a/app/imports/ui/properties/viewers/SpellViewer.vue +++ b/app/imports/ui/properties/viewers/SpellViewer.vue @@ -43,12 +43,12 @@ export default { components: { ActionViewer, }, - mixins: [propertyViewerMixin], - computed:{ - levelText(){ + mixins: [propertyViewerMixin], + computed: { + levelText() { return levelText[this.model.level] }, - spellComponents(){ + spellComponents() { let components = []; if (this.model.ritual) components.push('Ritual'); if (this.model.concentration) components.push('Concentration'); @@ -62,4 +62,5 @@ export default { diff --git a/app/imports/ui/properties/viewers/ToggleViewer.vue b/app/imports/ui/properties/viewers/ToggleViewer.vue index 666c184f..f2b9037a 100644 --- a/app/imports/ui/properties/viewers/ToggleViewer.vue +++ b/app/imports/ui/properties/viewers/ToggleViewer.vue @@ -11,9 +11,7 @@ name="Status" :value="model.enabled ? 'Enabled' : 'Disabled'" /> -