diff --git a/app/.meteor/packages b/app/.meteor/packages
index d0f672c2..232dc1ac 100644
--- a/app/.meteor/packages
+++ b/app/.meteor/packages
@@ -3,44 +3,34 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
-accounts-password
-random
-dburles:collection-helpers
-reactive-var
-underscore
-momentjs:moment
+accounts-password@2.2.0
+random@1.2.0
+underscore@1.0.10
dburles:mongo-collection-instances
-accounts-google
-email
-meteorhacks:subs-manager
-chuangbo:marked
-meteor-base
-mobile-experience
-mongo
-session
-tracker
-logging
-reload
-ejson
-check
-standard-minifier-js
-shell-server
-templates:array
-ecmascript
-es5-shim
-reactive-dict
+accounts-google@1.4.0
+email@2.2.0
+meteor-base@1.5.1
+mobile-experience@1.1.0
+mongo@1.14.0
+session@1.2.0
+tracker@1.2.0
+logging@1.3.1
+reload@1.3.1
+ejson@1.1.1
+check@1.3.1
+standard-minifier-js@2.8.0
+shell-server@0.5.0
+ecmascript@0.16.1
+es5-shim@4.8.0
percolate:synced-cron
-ongoworks:speakingurl
-service-configuration
-dynamic-import
-ddp-rate-limiter
-rate-limit
+service-configuration@1.3.0
+dynamic-import@0.7.2
+ddp-rate-limiter@1.1.0
+rate-limit@1.0.9
mdg:validated-method
-akryum:vue-router2
-static-html
+static-html@1.3.2
aldeed:collection2
aldeed:schema-index
-zer0th:meteor-vuetify-loader
accounts-patreon
bozhao:link-accounts
peerlibrary:reactive-publish
@@ -49,5 +39,11 @@ simple:rest-method-mixin
mikowals:batch-insert
peerlibrary:subscription-data
seba:minifiers-autoprefixer
+zer0th:meteor-vuetify-loader
akryum:vue-component
-akryum:vue-sass
+akryum:vue-router2
+percolate:migrations
+meteortesting:mocha
+ostrio:files
+simple:rest-bearer-token-parser
+simple:rest-json-error-handler
diff --git a/app/.meteor/release b/app/.meteor/release
index 59245ca5..a19cd698 100644
--- a/app/.meteor/release
+++ b/app/.meteor/release
@@ -1 +1 @@
-METEOR@2.2.1
+METEOR@2.6
diff --git a/app/.meteor/versions b/app/.meteor/versions
index 586384a6..1a35a30c 100644
--- a/app/.meteor/versions
+++ b/app/.meteor/versions
@@ -1,19 +1,18 @@
-accounts-base@1.9.0
-accounts-google@1.3.3
-accounts-oauth@1.2.0
-accounts-password@1.7.1
+accounts-base@2.2.1
+accounts-google@1.4.0
+accounts-oauth@1.4.0
+accounts-password@2.2.0
accounts-patreon@0.1.0
akryum:npm-check@0.1.2
akryum:vue-component@0.15.2
akryum:vue-component-dev-client@0.4.7
akryum:vue-component-dev-server@0.1.4
akryum:vue-router2@0.2.3
-akryum:vue-sass@0.1.2
-aldeed:collection2@3.4.1
+aldeed:collection2@3.5.0
aldeed:schema-index@3.0.0
-allow-deny@1.1.0
-autoupdate@1.7.0
-babel-compiler@7.6.2
+allow-deny@1.1.1
+autoupdate@1.8.0
+babel-compiler@7.8.0
babel-runtime@1.5.0
base64@1.0.12
binary-heap@1.0.11
@@ -22,66 +21,63 @@ boilerplate-generator@1.7.1
bozhao:link-accounts@2.4.0
caching-compiler@1.2.2
caching-html-compiler@1.2.1
-callback-hook@1.3.0
+callback-hook@1.4.0
check@1.3.1
-chuangbo:marked@0.3.5_1
coffeescript@2.4.1
coffeescript-compiler@2.4.1
-dburles:collection-helpers@1.1.0
-dburles:mongo-collection-instances@0.3.5
+dburles:mongo-collection-instances@0.3.6
ddp@1.4.0
-ddp-client@2.4.1
+ddp-client@2.5.0
ddp-common@1.4.0
-ddp-rate-limiter@1.0.9
-ddp-server@2.3.3
-deps@1.0.12
+ddp-rate-limiter@1.1.0
+ddp-server@2.5.0
diff-sequence@1.1.1
-dynamic-import@0.6.0
-ecmascript@0.15.1
-ecmascript-runtime@0.7.0
-ecmascript-runtime-client@0.11.1
-ecmascript-runtime-server@0.10.1
+dynamic-import@0.7.2
+ecmascript@0.16.1
+ecmascript-runtime@0.8.0
+ecmascript-runtime-client@0.12.1
+ecmascript-runtime-server@0.11.0
ejson@1.1.1
-email@2.0.0
+email@2.2.0
es5-shim@4.8.0
fetch@0.1.1
geojson-utils@1.0.10
-google-oauth@1.3.0
+google-oauth@1.4.1
hot-code-push@1.0.4
html-tools@1.1.2
htmljs@1.1.1
-http@1.4.4
+http@2.0.0
id-map@1.1.1
inter-process-messaging@0.1.1
-lai:collection-extensions@0.2.1_1
-launch-screen@1.2.1
-livedata@1.0.18
+lai:collection-extensions@0.3.0
+launch-screen@1.3.0
localstorage@1.2.0
-logging@1.2.0
+logging@1.3.1
mdg:validated-method@1.2.0
-meteor@1.9.3
-meteor-base@1.4.0
-meteorhacks:subs-manager@1.6.4
-mikowals:batch-insert@1.2.0
-minifier-css@1.5.4
-minifier-js@2.6.1
-minimongo@1.6.2
+meteor@1.10.0
+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
mobile-experience@1.1.0
mobile-status-bar@1.1.0
-modern-browsers@0.1.5
-modules@0.16.0
+modern-browsers@0.1.7
+modules@0.18.0
modules-runtime@0.12.0
-momentjs:moment@2.29.1
-mongo@1.11.1
+mongo@1.14.4
mongo-decimal@0.1.2
mongo-dev-server@1.1.0
mongo-id@1.0.8
-npm-bcrypt@0.9.4
-npm-mongo@3.9.0
-oauth@1.3.2
-oauth2@1.3.0
-ongoworks:speakingurl@9.0.0
+npm-mongo@4.3.1
+oauth@2.1.1
+oauth2@1.3.1
ordered-dict@1.1.0
+ostrio:cookies@2.7.0
+ostrio:files@2.0.1
patreon-oauth@0.1.0
peerlibrary:assert@0.3.0
peerlibrary:check-extension@0.7.0
@@ -89,41 +85,42 @@ peerlibrary:computed-field@0.10.0
peerlibrary:data-lookup@0.3.0
peerlibrary:extend-publish@0.6.0
peerlibrary:fiber-utils@0.10.0
-peerlibrary:reactive-mongo@0.4.0
+peerlibrary:reactive-mongo@0.4.1
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.11.2
+promise@0.12.0
raix:eventemitter@1.0.0
random@1.2.0
rate-limit@1.0.9
-react-fast-refresh@0.1.1
+react-fast-refresh@0.2.2
reactive-dict@1.3.0
reactive-var@1.0.11
reload@1.3.1
retry@1.1.0
-routepolicy@1.1.0
+routepolicy@1.1.1
seba:minifiers-autoprefixer@2.0.1
-service-configuration@1.0.11
+service-configuration@1.3.0
session@1.2.0
sha@1.0.9
shell-server@0.5.0
-simple:json-routes@2.1.0
-simple:rest@1.1.1
-simple:rest-method-mixin@1.0.1
-socket-stream-client@0.3.3
+simple:json-routes@2.3.1
+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
-srp@1.1.0
-standard-minifier-js@2.6.1
+standard-minifier-js@2.8.0
static-html@1.3.2
-templates:array@1.0.3
templating-tools@1.2.1
tmeasday:check-npm-versions@1.0.2
tracker@1.2.0
-typescript@4.2.2
+typescript@4.4.1
underscore@1.0.10
url@1.3.2
-webapp@1.10.1
+webapp@1.13.0
webapp-hashing@1.1.0
-zer0th:meteor-vuetify-loader@0.1.30
+zer0th:meteor-vuetify-loader@0.1.41
diff --git a/app/client/main.js b/app/client/main.js
index 4836e4d2..d9d770b7 100644
--- a/app/client/main.js
+++ b/app/client/main.js
@@ -1,4 +1,7 @@
+import '/imports/api/simpleSchemaConfig.js';
import '/imports/ui/vueSetup.js';
import '/imports/ui/styles/stylesIndex.js';
import '/imports/client/config.js';
import '/imports/client/serviceWorker.js';
+
+import 'ngraph.graph';
diff --git a/app/imports/api/creature/actions/applyAction.js b/app/imports/api/creature/actions/applyAction.js
deleted file mode 100644
index 1ffb7990..00000000
--- a/app/imports/api/creature/actions/applyAction.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import spendResources from '/imports/api/creature/actions/spendResources.js'
-import embedInlineCalculations from '/imports/api/creature/computation/afterComputation/embedInlineCalculations.js';
-
-export default function applyAction({prop, log}){
- let content = { name: prop.name };
- if (prop.summary){
- content.value = embedInlineCalculations(
- prop.summary, prop.summaryCalculations
- );
- }
- log.content.push(content);
- spendResources({prop, log});
-}
diff --git a/app/imports/api/creature/actions/applyAdjustment.js b/app/imports/api/creature/actions/applyAdjustment.js
deleted file mode 100644
index 5c106a73..00000000
--- a/app/imports/api/creature/actions/applyAdjustment.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
-import damagePropertiesByName from '/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js';
-
-export default function applyAdjustment({
- prop,
- creature,
- targets,
- actionContext,
- log
-}){
- let damageTargets = prop.target === 'self' ? [creature] : targets;
- let scope = {
- ...creature.variables,
- ...actionContext,
- };
- var {result, context} = evaluateString({
- string: prop.amount,
- scope,
- fn: 'reduce'
- });
- context.errors.forEach(e => {
- log.content.push({
- name: 'Attribute damage error',
- value: e.message || e.toString(),
- });
- });
- if (damageTargets) {
- damageTargets.forEach(target => {
- if (prop.target === 'each'){
- ({result} = evaluateString({
- string: prop.amount,
- scope,
- fn: 'reduce'
- }));
- }
- damagePropertiesByName.call({
- creatureId: target._id,
- variableName: prop.stat,
- operation: prop.operation || 'increment',
- value: result.value,
- });
- log.content.push({
- name: 'Attribute damage',
- value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
- ` ${result.isNumber ? -result.value : result.toString()}`,
- });
- });
- } else {
- log.content.push({
- name: 'Attribute damage',
- value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
- ` ${result.isNumber ? -result.value : result.toString()}`,
- });
- }
-}
diff --git a/app/imports/api/creature/actions/applyAttack.js b/app/imports/api/creature/actions/applyAttack.js
deleted file mode 100644
index e3774041..00000000
--- a/app/imports/api/creature/actions/applyAttack.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import roll from '/imports/parser/roll.js';
-
-export default function applyAttack({
- prop,
- log,
- actionContext,
- creature,
-}){
- let value = roll(1, 20)[0];
- actionContext.attackRoll = {value};
- let criticalHitTarget = creature.variables.criticalHitTarget &&
- creature.variables.criticalHitTarget.currentValue || 20;
- let criticalHit = value >= criticalHitTarget;
- if (criticalHit) actionContext.criticalHit = {value: true};
- let result = value + prop.rollBonusResult;
- actionContext.toHit = {value: result};
-
- log.content.push({
- name: criticalHit ? 'Critical Hit!' : 'To Hit',
- value: `1d20 [${value}] + ${prop.rollBonusResult} = ` + result,
- });
-}
diff --git a/app/imports/api/creature/actions/applyBuff.js b/app/imports/api/creature/actions/applyBuff.js
deleted file mode 100644
index 2bde6c85..00000000
--- a/app/imports/api/creature/actions/applyBuff.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import {
- setLineageOfDocs,
- renewDocIds
-} from '/imports/api/parenting/parenting.js';
-import {setDocToLastOrder} from '/imports/api/parenting/order.js';
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-
-export default function applyBuff({
- prop,
- children,
- creature,
- targets = [],
- //actionContext,
-}){
- let buffTargets = prop.target === 'self' ? [creature] : targets;
-
- //let scope = {
- // ...creature.variables,
- // ...actionContext,
- //};
-
- // TODO
- // If the target is not self, walk through all decendants and replace
- // variables in calculations with their values from the creature scope
- // If the target is self, replace all the target.x references with just x
-
- // Then copy the decendants of the buff to the targets
- prop.applied = true;
- let propList = [prop];
- function addChildrenToPropList(children){
- children.forEach(child => {
- propList.push(child.node);
- addChildrenToPropList(child.children);
- });
- }
- addChildrenToPropList(children);
- let oldParent = {
- id: prop.parent.id,
- collection: prop.parent.collection,
- };
- buffTargets.forEach(target => {
- copyNodeListToTarget(propList, target, oldParent);
- });
-}
-
-function copyNodeListToTarget(propList, target, oldParent){
- let ancestry = [{collection: 'creatures', id: target._id}];
- setLineageOfDocs({
- docArray: propList,
- newAncestry: ancestry,
- oldParent,
- });
- renewDocIds({
- docArray: propList,
- });
- setDocToLastOrder({
- collection: CreatureProperties,
- doc: propList[0],
- });
- CreatureProperties.batchInsert(propList);
-}
diff --git a/app/imports/api/creature/actions/applyDamage.js b/app/imports/api/creature/actions/applyDamage.js
deleted file mode 100644
index 04ca05d2..00000000
--- a/app/imports/api/creature/actions/applyDamage.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
-import dealDamage from '/imports/api/creature/creatureProperties/methods/dealDamage.js';
-import {insertCreatureLog} from '/imports/api/creature/log/CreatureLogs.js';
-import { CompilationContext } from '/imports/parser/parser.js';
-
-export default function applyDamage({
- prop,
- creature,
- targets,
- actionContext,
- log,
-}){
- let damageTargets = prop.target === 'self' ? [creature] : targets;
- let scope = {
- ...creature.variables,
- ...actionContext,
- };
- // Add the target's variables to the scope
- if (targets.length === 1){
- scope.target = targets[0].variables;
- }
- // Determine if the hit is critical
- let criticalHit = !!(
- actionContext.criticalHit &&
- actionContext.criticalHit.value &&
- prop.damageType !== 'healing' // Can't critically heal
- );
- // Double the damage rolls if the hit is critical
- let context = new CompilationContext({
- doubleRolls: criticalHit,
- });
-
- // Compute the roll the first time, logging any errors
- var {result} = evaluateString({
- string: prop.amount,
- scope,
- fn: 'reduce',
- context
- });
-
- // If the result is an error bail out now
- if (result.constructor.name === 'ErrorNode'){
- log.content.push({
- name: 'Damage error',
- value: result.toString(),
- });
- return;
- }
-
- // Memoise the damage suffix for the log
- let suffix = (criticalHit ? ' critical ' : ' ') +
- prop.damageType +
- (prop.damageType !== ' healing ' ? ' damage ': '');
-
- if (damageTargets && damageTargets.length) {
- // Iterate through all the targets
- damageTargets.forEach(target => {
- let name = prop.damageType === 'healing' ? 'Healing' : 'Damage';
-
- // Reroll the damage if needed
- if (prop.target === 'each'){
- ({result, context} = evaluateString({
- string: prop.amount,
- scope,
- fn: 'reduce'
- }));
- }
- // If the result is an error or not a number bail out now
- if (result.constructor.name === 'ErrorNode' || !result.isNumber){
- log.content.push({
- name: 'Damage error',
- value: result.toString(),
- });
- return;
- }
-
- // Deal the damage to the target
- let damageDealt = dealDamage.call({
- creatureId: target._id,
- damageType: prop.damageType,
- amount: result.value,
- });
-
- // Log the damage done
- if (target._id === creature._id){
- // Target is same as self, log damage as such
- log.content.push({
- name,
- value: damageDealt + suffix + ' to self',
- });
- } else {
- log.content.push({
- name,
- value: 'Dealt ' + damageDealt + suffix + ` ${target.name && ' to '}${target.name}`,
- });
- // Log the damage received on that creature's log as well
- insertCreatureLog.call({
- log: {
- creatureId: target._id,
- content: [{
- name,
- value: 'Recieved ' + damageDealt + suffix,
- }],
- }
- });
- }
- });
- } else {
- // There are no targets, just log the result
- log.content.push({
- name: prop.damageType === 'healing' ? 'Healing' : 'Damage',
- value: result.toString() + suffix,
- });
- }
-}
diff --git a/app/imports/api/creature/actions/applyProperties.js b/app/imports/api/creature/actions/applyProperties.js
deleted file mode 100644
index e4e72c47..00000000
--- a/app/imports/api/creature/actions/applyProperties.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import applyAction from '/imports/api/creature/actions/applyAction.js';
-import applyAdjustment from '/imports/api/creature/actions/applyAdjustment.js';
-import applyAttack from '/imports/api/creature/actions/applyAttack.js';
-import applyBuff from '/imports/api/creature/actions/applyBuff.js';
-import applyDamage from '/imports/api/creature/actions/applyDamage.js';
-import applyRoll from '/imports/api/creature/actions/applyRoll.js';
-import applyToggle from '/imports/api/creature/actions/applyToggle.js';
-import applySave from '/imports/api/creature/actions/applySave.js';
-
-function applyProperty(options){
- let prop = options.prop;
- if (prop.type === 'buff'){
- // ignore only applied buffs, don't apply them again
- if (prop.applied === true){
- return false;
- }
- // Only ignore toggles if they wont be computed
- } else if (prop.type === 'toggle') {
- if (prop.disabled) return false;
- if (prop.enabled) return true;
- if (!prop.condition) return false;
- // Ignore inactive props of other types
- } else if (prop.deactivatedBySelf === true){
- return false;
- }
- switch (prop.type){
- case 'action':
- case 'spell':
- applyAction(options);
- break;
- case 'attack':
- applyAction(options);
- applyAttack(options);
- break;
- case 'damage':
- applyDamage(options);
- break;
- case 'adjustment':
- applyAdjustment(options);
- break;
- case 'buff':
- applyBuff(options);
- return false;
- case 'toggle':
- return applyToggle(options);
- case 'roll':
- applyRoll(options);
- break;
- case 'savingThrow':
- return applySave(options);
- }
- return true;
-}
-
-function applyPropertyAndWalkChildren({prop, children, targets, ...options}){
- let shouldKeepWalking = applyProperty({ prop, children, targets, ...options });
- if (shouldKeepWalking){
- applyProperties({ forest: children, targets, ...options,});
- }
-}
-
-export default function applyProperties({ forest, targets, ...options}){
- forest.forEach(node => {
- let prop = node.node;
- options.actionContext[`#${prop.type}`] = prop;
- let children = node.children;
- if (shouldSplit(prop) && targets.length){
- targets.forEach(target => {
- let targets = [target]
- applyPropertyAndWalkChildren({ targets, prop, children, ...options});
- });
- } else {
- applyPropertyAndWalkChildren({prop, children, targets, ...options});
- }
- });
-}
-
-function shouldSplit(prop){
- if (prop.target === 'each'){
- return true;
- }
-}
diff --git a/app/imports/api/creature/actions/applyRoll.js b/app/imports/api/creature/actions/applyRoll.js
deleted file mode 100644
index 8653ba7a..00000000
--- a/app/imports/api/creature/actions/applyRoll.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
-
-export default function applyRoll({
- prop,
- creature,
- actionContext,
- log,
-}){
- let scope = {
- ...creature.variables,
- ...actionContext,
- };
- var {result} = evaluateString({
- string: prop.roll,
- scope,
- fn: 'reduce'
- });
- if (result.isNumber){
- actionContext[prop.variableName] = result.value;
- }
- log.content.push({
- name: prop.name,
- value: prop.variableName + ' = ' + prop.roll + ' = ' + result.toString(),
- });
-}
diff --git a/app/imports/api/creature/actions/applySave.js b/app/imports/api/creature/actions/applySave.js
deleted file mode 100644
index bb6ce0d3..00000000
--- a/app/imports/api/creature/actions/applySave.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
-import CreaturesProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import roll from '/imports/parser/roll.js';
-
-export default function applySave({
- prop,
- creature,
- actionContext,
- log,
-}){
- let scope = {
- ...creature.variables,
- ...actionContext,
- };
- try {
- // Calculate the DC
- var {result} = evaluateString({
- string: prop.dc,
- scope,
- fn: 'reduce'
- });
- let dc = result.value;
- log.content.push({
- name: prop.name,
- value: ' DC ' + result.toString(),
- });
- if (prop.target === 'self'){
- let save = CreaturesProperties.findOne({
- 'ancestors.id': creature._id,
- type: 'skill',
- skillType: 'save',
- variableName: prop.stat,
- removed: {$ne: true},
- inactive: {$ne: true},
- });
- if (!save){
- log.content.push({
- name: 'Saving throw error',
- value: 'No saving throw found: ' + prop.stat,
- });
- return;
- }
- let value, values, resultPrefix;
- if (save.advantage === 1){
- values = roll(2, 20).sort().reverse();
- value = values[0];
- resultPrefix = `Advantage: 1d20 [${values[0]},~~${values[1]}~~] + ${save.value} = `
- } else if (save.advantage === -1){
- values = roll(2, 20).sort();
- value = values[0];
- resultPrefix = `Disadvantage: 1d20 [${values[0]},~~${values[1]}~~] + ${save.value} = `
- } else {
- values = roll(1, 20);
- value = values[0];
- resultPrefix = `1d20 [${value}] + ${save.value} = `
- }
- actionContext.savingThrowRoll = {value};
- let result = value + save.value;
- actionContext.savingThrow = {value: result};
- let saveSuccess = result >= dc;
- log.content.push({
- name: 'Save',
- value: resultPrefix + result + (saveSuccess ? 'Passed' : 'Failed')
- });
- return !saveSuccess;
- } else {
- // TODO
- return true;
- }
- } catch (e){
- log.content.push({
- name: 'Save error',
- value: e.toString(),
- });
- }
-}
diff --git a/app/imports/api/creature/actions/applyToggle.js b/app/imports/api/creature/actions/applyToggle.js
deleted file mode 100644
index 79d222ed..00000000
--- a/app/imports/api/creature/actions/applyToggle.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
-
-export default function applyToggle({
- prop,
- creature,
- actionContext,
- log,
-}){
- let scope = {
- ...creature.variables,
- ...actionContext,
- };
- if (Number.isFinite(+prop.condition)){
- return !!+prop.condition;
- }
- var {result} = evaluateString({
- string: prop.condition,
- scope,
- fn: 'reduce'
- });
- if (result.constructor.name === 'ErrorNode') {
- log.content.push({
- name: 'Toggle error',
- value: result.toString(),
- });
- return false;
- }
- log.content.push({
- name: prop.name || 'Toggle',
- value: prop.condition + ' = ' + result.toString(),
- });
- return !!result.value;
-}
diff --git a/app/imports/api/creature/actions/castSpellWithSlot.js b/app/imports/api/creature/actions/castSpellWithSlot.js
deleted file mode 100644
index 9e2b3570..00000000
--- a/app/imports/api/creature/actions/castSpellWithSlot.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-import { ValidatedMethod } from 'meteor/mdg:validated-method';
-import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
-import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import { doActionWork } from '/imports/api/creature/actions/doAction.js';
-import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
-import getAncestorContext from '/imports/api/creature/actions/getAncestorContext.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties';
-
-const castSpellWithSlot = new ValidatedMethod({
- name: 'creatureProperties.castSpellWithSlot',
- validate: new SimpleSchema({
- spellId: SimpleSchema.RegEx.Id,
- slotId: {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- optional: true,
- },
- targetId: {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- optional: true,
- },
- }).validator(),
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 10,
- timeInterval: 5000,
- },
- run({spellId, slotId, targetId}) {
- let spell = CreatureProperties.findOne(spellId);
- // Check permissions
- let creature = getRootCreatureAncestor(spell);
- assertEditPermission(creature, this.userId);
- let target = undefined;
- if (targetId) {
- target = Creatures.findOne(targetId);
- assertEditPermission(target, this.userId);
- }
- let slotLevel = spell.level || 0;
- if (slotLevel !== 0){
- let slot = CreatureProperties.findOne(slotId);
- if (!slot){
- throw new Meteor.Error('No slot',
- 'Slot not found to cast spell');
- }
- if (!slot.currentValue){
- throw new Meteor.Error('No slot',
- 'Slot depleted');
- }
- if (!(slot.spellSlotLevelValue >= spell.level)){
- throw new Meteor.Error('Slot too small',
- 'Slot is not large enough to cast spell');
- }
- slotLevel = slot.spellSlotLevelValue;
- damagePropertyWork({
- property: slot,
- operation: 'increment',
- value: 1,
- });
- }
- let actionContext = getAncestorContext(spell);
-
- doActionWork({
- action: spell,
- actionContext: {slotLevel, ...actionContext},
- creature,
- targets: target ? [target] : [],
- method: this,
- });
-
- // Note these lines only recompute the top-level creature, not the nearest one
- // The acting creature might have a new item
- recomputeInventory(creature._id);
- // The spell might add properties which need to be activated
- recomputeInactiveProperties(creature._id);
- recomputeCreatureByDoc(creature);
-
- if (target){
- recomputeInventory(target._id);
- recomputeInactiveProperties(target._id);
- recomputeCreatureByDoc(target);
- }
- },
-});
-
-export default castSpellWithSlot;
diff --git a/app/imports/api/creature/actions/doCheck.js b/app/imports/api/creature/actions/doCheck.js
deleted file mode 100644
index 410462a7..00000000
--- a/app/imports/api/creature/actions/doCheck.js
+++ /dev/null
@@ -1,56 +0,0 @@
-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 { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
-import roll from '/imports/parser/roll.js';
-
-const doCheck = new ValidatedMethod({
- name: 'creature.doCheck',
- validate: new SimpleSchema({
- creatureId: {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- optional: true,
- },
- attributeName: {
- type: String,
- optional: true,
- },
- }).validator(),
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 10,
- timeInterval: 5000,
- },
- run({creatureId, attributeName}) {
- let creature = Creatures.findOne(creatureId);
- assertEditPermission(creature, this.userId);
- let bonus = getAttributeValue({creature, attributeName})
- return doCheckWork({bonus});
- },
-});
-
-function getAttributeValue({creature, attributeName}){
- let att = creature.variables[attributeName];
- if (!att) throw new Meteor.Error('No such attribute',
- `This creature does not have a ${attributeName} property`);
- let bonus = att.attributeType === 'ability'? att.modifier : att.value;
- return bonus || 0;
-}
-
-export function doCheckWork({bonus, advantage = 0}){
- let rolls = roll(2,20);
- let chosenRoll;
- if (advantage === 1){
- chosenRoll = Math.max.apply(rolls);
- } else if (advantage === -1){
- chosenRoll = Math.min.apply(rolls);
- } else {
- chosenRoll = rolls[0];
- }
- let result = chosenRoll + bonus;
- return {rolls, bonus, chosenRoll, result};
-}
-
-export default doCheck;
diff --git a/app/imports/api/creature/actions/getAncestorContext.js b/app/imports/api/creature/actions/getAncestorContext.js
deleted file mode 100644
index 9508107b..00000000
--- a/app/imports/api/creature/actions/getAncestorContext.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-
-export default function getAncestorContext(prop){
- // Build ancestor context
- const actionContext = {};
- let ancestorIds = prop.ancestors.map(ref => ref.id);
- CreatureProperties.find({
- _id: {$in: ancestorIds}
- }, {
- sort: {order: 1},
- }).forEach(ancestor => {
- actionContext[`#${ancestor.type}`] = ancestor;
- });
- return actionContext;
-}
diff --git a/app/imports/api/creature/actions/spendResources.js b/app/imports/api/creature/actions/spendResources.js
deleted file mode 100644
index ef71c04a..00000000
--- a/app/imports/api/creature/actions/spendResources.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js';
-import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
-
-export default function spendResources({prop, log}){
- // Check Uses
- if (prop.usesUsed >= prop.usesResult){
- throw new Meteor.Error('Insufficient Uses',
- 'This prop has no uses left');
- }
- // Resources
- if (prop.insufficientResources){
- throw new Meteor.Error('Insufficient Resources',
- 'This creature doesn\'t have sufficient resources to perform this prop');
- }
- // Items
- let itemQuantityAdjustments = [];
- let spendLog = [];
- let gainLog = [];
- prop.resources.itemsConsumed.forEach(itemConsumed => {
- if (!itemConsumed.itemId){
- throw new Meteor.Error('Ammo not selected',
- 'No ammo was selected for this prop');
- }
- let item = CreatureProperties.findOne(itemConsumed.itemId);
- if (!item || item.ancestors[0].id !== prop.ancestors[0].id){
- throw new Meteor.Error('Ammo not found',
- 'The prop\'s ammo was not found on the creature');
- }
- if (!item.equipped){
- throw new Meteor.Error('Ammo not equipped',
- 'The selected ammo is not equipped');
- }
- if (!itemConsumed.quantity) return;
- itemQuantityAdjustments.push({
- property: item,
- operation: 'increment',
- value: itemConsumed.quantity,
- });
- let logName = item.name;
- if (itemConsumed.quantity > 1 || itemConsumed.quantity < -1){
- logName = item.plural || logName;
- }
- if (itemConsumed.quantity > 0){
- spendLog.push(logName + ': ' + itemConsumed.quantity);
- } else if (itemConsumed.quantity < 0){
- gainLog.push(logName + ': ' + -itemConsumed.quantity);
- }
- });
- // No more errors should be thrown after this line
- // Now that we have confirmed that there are no errors, do actual work
- //Items
- itemQuantityAdjustments.forEach(adjustQuantityWork);
-
- // Use uses
- if (prop.usesResult){
- CreatureProperties.update(prop._id, {
- $inc: {usesUsed: 1}
- }, {
- selector: prop
- });
- log.content.push({
- name: 'Uses left',
- value: prop.usesResult - (prop.usesUsed || 0) - 1,
- });
- }
-
- // Damage stats
- prop.resources.attributesConsumed.forEach(attConsumed => {
- if (!attConsumed.quantity) return;
- let stat = CreatureProperties.findOne(attConsumed.statId);
- damagePropertyWork({
- property: stat,
- operation: 'increment',
- value: attConsumed.quantity,
- });
- if (attConsumed.quantity > 0){
- spendLog.push(stat.name + ': ' + attConsumed.quantity);
- } else if (attConsumed.quantity < 0){
- gainLog.push(stat.name + ': ' + -attConsumed.quantity);
- }
- });
-
- // Log all the spending
- if (gainLog.length) log.content.push({
- name: 'Gained',
- value: gainLog.join('\n'),
- });
- if (spendLog.length) log.content.push({
- name: 'Spent',
- value: spendLog.join('\n'),
- });
-}
diff --git a/app/imports/api/creature/archive/ArchiveCreatureFiles.js b/app/imports/api/creature/archive/ArchiveCreatureFiles.js
new file mode 100644
index 00000000..f4fe867c
--- /dev/null
+++ b/app/imports/api/creature/archive/ArchiveCreatureFiles.js
@@ -0,0 +1,17 @@
+import { createS3FilesCollection } from '/imports/api/files/s3FileStorage.js';
+
+const ArchiveCreatureFiles = createS3FilesCollection({
+ collectionName: 'archiveCreatureFiles',
+ storagePath: '/DiceCloud/archiveCreatures/',
+ onBeforeUpload(file) {
+ // Allow upload files under 10MB, and only in json format
+ if (file.size > 10485760) {
+ return 'Please upload with size equal or less than 10MB';
+ }
+ if (!/json/i.test(file.extension)){
+ return 'Please upload only a JSON file';
+ }
+ }
+});
+
+export default ArchiveCreatureFiles;
diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js
new file mode 100644
index 00000000..7599299c
--- /dev/null
+++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js
@@ -0,0 +1,78 @@
+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 { assertOwnership } from '/imports/api/creature/creatures/creaturePermissions.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 { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js';
+import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js';
+
+export function getArchiveObj(creatureId){
+ // Build the archive document
+ const creature = Creatures.findOne(creatureId);
+ const properties = CreatureProperties.find({'ancestors.id': creatureId}).fetch();
+ const experiences = Experiences.find({creatureId}).fetch();
+ const logs = CreatureLogs.find({creatureId}).fetch();
+ let archiveCreature = {
+ meta: {
+ type: 'DiceCloud V2 Creature Archive',
+ schemaVersion: SCHEMA_VERSION,
+ archiveDate: new Date(),
+ },
+ creature,
+ properties,
+ experiences,
+ logs,
+ };
+
+ return archiveCreature;
+}
+
+export function archiveCreature(creatureId){
+ const archive = getArchiveObj(creatureId);
+ const buffer = Buffer.from(JSON.stringify(archive, null, 2));
+ ArchiveCreatureFiles.write(buffer, {
+ fileName: `${archive.creature.name || archive.creature._id}.json`,
+ type: 'application/json',
+ userId: archive.creature.owner,
+ meta: {
+ schemaVersion: SCHEMA_VERSION,
+ creatureId: archive.creature._id,
+ creatureName: archive.creature.name,
+ },
+ }, (error) => {
+ if (error){
+ throw error;
+ } else {
+ removeCreatureWork(creatureId);
+ }
+ }, true);
+}
+
+const archiveCreatureToFile = new ValidatedMethod({
+ name: 'Creatures.methods.archiveCreatureToFile',
+ validate: new SimpleSchema({
+ 'creatureId': {
+ type: String,
+ regEx: SimpleSchema.RegEx.Id,
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 10,
+ timeInterval: 5000,
+ },
+ async run({creatureId}) {
+ assertOwnership(creatureId, this.userId);
+ if (Meteor.isServer){
+ archiveCreature(creatureId);
+ } else {
+ removeCreatureWork(creatureId);
+ }
+ },
+});
+
+export default archiveCreatureToFile;
diff --git a/app/imports/api/creature/archive/methods/archiveCreatures.js b/app/imports/api/creature/archive/methods/archiveCreatures.js
deleted file mode 100644
index 304cce11..00000000
--- a/app/imports/api/creature/archive/methods/archiveCreatures.js
+++ /dev/null
@@ -1,66 +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/creature/creatures/creaturePermissions.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 { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js';
-import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js';
-
-function archiveCreature(creatureId){
- // Build the archive document
- const creature = Creatures.findOne(creatureId);
- const properties = CreatureProperties.find({'ancestors.id': creatureId}).fetch();
- const experiences = Experiences.find({creatureId}).fetch();
- const logs = CreatureLogs.find({creatureId}).fetch();
- let archiveCreature = {
- owner: creature.owner,
- archiveDate: new Date(),
- creature,
- properties,
- experiences,
- logs,
- };
-
- // Insert it
- let id = ArchivedCreatures.insert(archiveCreature);
-
- // Remove the original creature
- removeCreatureWork(creatureId);
-
- return id;
-}
-
-const archiveCreatures = new ValidatedMethod({
- name: 'Creatures.methods.archiveCreatures',
- validate: new SimpleSchema({
- creatureIds: {
- type: Array,
- max: 10,
- },
- 'creatureIds.$': {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- },
- }).validator(),
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 1,
- timeInterval: 5000,
- },
- run({creatureIds}) {
- for (let id of creatureIds){
- assertOwnership(id, this.userId)
- }
- let archivedIds = [];
- for (let id of creatureIds){
- let archivedId = archiveCreature(id);
- archivedIds.push(archivedId);
- }
- return archivedIds;
- },
-});
-
-export default archiveCreatures;
diff --git a/app/imports/api/creature/archive/methods/index.js b/app/imports/api/creature/archive/methods/index.js
index 62d8bd8d..d3784bde 100644
--- a/app/imports/api/creature/archive/methods/index.js
+++ b/app/imports/api/creature/archive/methods/index.js
@@ -1,2 +1,4 @@
-import '/imports/api/creature/archive/methods/archiveCreatures.js';
+// 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';
diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js
new file mode 100644
index 00000000..986bb9ca
--- /dev/null
+++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js
@@ -0,0 +1,82 @@
+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';
+let migrateArchive;
+if (Meteor.isServer){
+ migrateArchive = require('/imports/migrations/server/migrateArchive.js').default;
+}
+
+function restoreCreature(archive){
+ if (SCHEMA_VERSION < archive.meta.schemaVersion){
+ throw new Meteor.Error('Incompatible',
+ 'The archive file is from a newer version. Update required to read.')
+ }
+
+ // Migrate and verify the archive meets the current schema
+ migrateArchive(archive);
+
+ // Insert the creature sub documents
+ // They still have their original _id's
+ Creatures.insert(archive.creature);
+ try {
+ // Add all the properties
+ if (archive.properties && archive.properties.length){
+ CreatureProperties.batchInsert(archive.properties);
+ }
+ if (archive.experiences && archive.experiences.length){
+ Experiences.batchInsert(archive.experiences);
+ }
+ if (archive.logs && archive.logs.length){
+ CreatureLogs.batchInsert(archive.logs);
+ }
+ } catch (e) {
+ // If the above fails, delete the inserted creature
+ removeCreatureWork(archive.creature._id);
+ throw e;
+ }
+}
+
+const restoreCreaturefromFile = new ValidatedMethod({
+ name: 'Creatures.methods.restoreCreaturefromFile',
+ validate: new SimpleSchema({
+ 'fileId': {
+ type: String,
+ regEx: SimpleSchema.RegEx.Id,
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 10,
+ timeInterval: 5000,
+ },
+ async run({fileId}) {
+ // fetch the file
+ const file = ArchiveCreatureFiles.findOne({_id: fileId}).get();
+ if (!file){
+ throw new Meteor.Error('File not found',
+ 'The requested creature archive does not exist');
+ }
+ // Assert ownership
+ const userId = file?.userId;
+ if (!userId || userId !== this.userId){
+ throw new Meteor.Error('Permission denied',
+ 'You can only restore creatures you own');
+ }
+ if (Meteor.isServer){
+ // Read the file data
+ const archive = await ArchiveCreatureFiles.readJSONFile(file);
+ restoreCreature(archive);
+ }
+ //Remove the archive once the restore succeeded
+ ArchiveCreatureFiles.remove({_id: fileId});
+ },
+});
+
+export default restoreCreaturefromFile;
diff --git a/app/imports/api/creature/archive/methods/restoreCreatures.js b/app/imports/api/creature/archive/methods/restoreCreatures.js
index b4ec2c44..5da270e7 100644
--- a/app/imports/api/creature/archive/methods/restoreCreatures.js
+++ b/app/imports/api/creature/archive/methods/restoreCreatures.js
@@ -9,7 +9,7 @@ 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';
-function restoreCreature(archiveId){
+export function restoreCreature(archiveId){
// Get the archive
const archivedCreature = ArchivedCreatures.findOne(archiveId);
diff --git a/app/imports/api/creature/computation/afterComputation/embedInlineCalculations.js b/app/imports/api/creature/computation/afterComputation/embedInlineCalculations.js
deleted file mode 100644
index 10841dba..00000000
--- a/app/imports/api/creature/computation/afterComputation/embedInlineCalculations.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
-
-export default function embedInlineCalculations(string, calculations){
- if (!string) return '';
- if (!calculations) return string;
- let index = 0;
- return string.replace(INLINE_CALCULATION_REGEX, substring => {
- let comp = calculations && calculations[index++];
- return (comp && 'result' in comp) ? comp.result : substring;
- });
-}
diff --git a/app/imports/api/creature/computation/afterComputation/evaluateString.js b/app/imports/api/creature/computation/afterComputation/evaluateString.js
deleted file mode 100644
index f193d341..00000000
--- a/app/imports/api/creature/computation/afterComputation/evaluateString.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { parse, CompilationContext } from '/imports/parser/parser.js';
-import ConstantNode from '/imports/parser/parseTree/ConstantNode.js';
-import SymbolNode from '/imports/parser/parseTree/SymbolNode.js';
-import ErrorNode from '/imports/parser/parseTree/ErrorNode.js';
-
-//TODO replace constants with their parsed node
-
-export default function evaluateString({string, scope, fn = 'compile', context}){
- if (!context){
- context = new CompilationContext({});
- }
- if (!string){
- context.storeError('No string provided');
- return {result: {value: string}, context};
- }
-
- if (!scope) context.storeError('No scope provided');
-
- // Parse the string using mathjs
- let node;
- try {
- node = parse(string);
- } catch (e) {
- context.storeError(e);
- return {result: {value: string}, context};
- }
- node = replaceConstants({calc: node, context, scope});
- let result = node[fn](scope, context);
- return {result, context};
-}
-
-// Replace constants in the calc with the right ParseNodes
-function replaceConstants({calc, context, scope}){
- let constFailed = [];
- calc = calc.replaceNodes(node => {
- if (!(node instanceof SymbolNode)) return;
- let constant = scope[node.name];
- // replace constants that aren't overridden by stats or disabled by a toggle
- if (constant && constant.type === 'constant'){
- // Fail if the constant has errors
- if (constant.errors && constant.errors.length){
- constFailed.push(node.name);
- return;
- }
- let parsedConstantNode;
- try {
- parsedConstantNode = parse(constant.calculation);
- } catch(e){
- constFailed.push(node.name);
- return;
- }
- if (!parsedConstantNode) constFailed.push(node.name);
- return parsedConstantNode;
- }
- });
- constFailed.forEach(name => {
- context.storeError({
- type: 'error',
- message: `${name} is a constant property with parsing errors`
- });
- });
- let failed = !!constFailed.length;
- if (failed){
- calc = new ErrorNode({error: 'Failed to replace constants'});
- }
- return calc;
-}
diff --git a/app/imports/api/creature/computation/creatureComputation.test.js b/app/imports/api/creature/computation/creatureComputation.test.js
deleted file mode 100644
index 783d8cae..00000000
--- a/app/imports/api/creature/computation/creatureComputation.test.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import {computeCreature} from "./recomputeCreature.js";
-import assert from "assert";
-
-const makeEffect = function(operation, value){
- let effect = {computed: false, result: 0, operation}
- if (_.isFinite(value)){
- effect.value = +value;
- } else {
- effect.calculation = value;
- }
- return effect;
-}
-
-describe('computeCreature', function () {
- it('computes an aritrary creature', function () {
- let char = {
- atts: {
- attribute1: {
- computed: false,
- busyComputing: false,
- type: "attribute",
- attributeType: "ability",
- result: 0,
- mod: 0, // The resulting modifier if this is an ability
- base: 0,
- add: 0,
- mul: 1,
- min: Number.NEGATIVE_INFINITY,
- max: Number.POSITIVE_INFINITY,
- effects: [
- makeEffect("base", 10),
- makeEffect("add", 5),
- makeEffect("mul", 2),
- ],
- },
- attribute2: {
- computed: false,
- busyComputing: false,
- type: "attribute",
- result: 0,
- mod: 0, // The resulting modifier if this is an ability
- base: 0,
- add: 0,
- mul: 1,
- min: Number.NEGATIVE_INFINITY,
- max: Number.POSITIVE_INFINITY,
- effects: [
- makeEffect("base", "attribute1"),
- makeEffect("max", 2),
- ],
- },
- },
- skills: {
- skill1: {
- computed: false,
- busyComputing: false,
- type: "skill",
- ability: "attribute1",
- result: 0,
- proficiency: 0,
- add: 0,
- mul: 1,
- min: Number.NEGATIVE_INFINITY,
- max: Number.POSITIVE_INFINITY,
- advantage: 0,
- disadvantage: 0,
- passiveAdd: 0,
- fail: 0,
- conditional: 0,
- effects: [],
- proficiencies: [],
- },
- },
- dms: {
- dm1: {
- computed: false,
- busyComputing: false,
- type: "damageMultiplier",
- result: 0,
- immunityCount: 0,
- ressistanceCount: 0,
- vulnerabilityCount: 0,
- effects: [],
- }
- },
- classes: {
- Barbarian: {
- level: 5,
- },
- },
- level: 5,
- };
- char = computeCreature(char);
- console.log(char);
- assert(true);
- });
-});
diff --git a/app/imports/api/creature/computation/engine/ComputationMemo.js b/app/imports/api/creature/computation/engine/ComputationMemo.js
deleted file mode 100644
index 75d270da..00000000
--- a/app/imports/api/creature/computation/engine/ComputationMemo.js
+++ /dev/null
@@ -1,289 +0,0 @@
-import { includes, cloneDeep } from 'lodash';
-import findAncestorByType from '/imports/api/creature/computation/engine/findAncestorByType.js';
-
-// The computation memo is an in-memory data structure used only during the
-// computation process
-export default class ComputationMemo {
- constructor(props, creature){
- this.statsByVariableName = {};
- this.constantsByVariableName = {};
- this.constantsById = {};
- this.extraStatsByVariableName = {};
- this.statsById = {};
- this.originalPropsById = {};
- this.propsById = {};
- this.skillsByAbility = {};
- this.unassignedEffects = [];
- this.classLevelsById = {};
- this.classes = {};
- this.togglesById = {};
- this.toggleIds = new Set();
- // Equipped items that might be used as ammo
- this.equipmentById = {};
- // Properties that have calculations, but don't impact other properties
- this.endStepPropsById = {};
- // First note all the ids of all the toggles
- props.forEach((prop) => {
- if (
- prop.type === 'toggle'
- ) {
- this.toggleIds.add(prop._id);
- }
- });
- props.filter((prop) => {
- if (
- prop.type === 'toggle'
- ) {
- this.addToggle(prop);
- } else {
- return true;
- }
- }).filter((prop) => {
- if (
- prop.type === 'attribute' ||
- prop.type === 'skill'
- ) {
- // Add all the stats
- this.addStat(prop);
- } else if (
- prop.type === 'item'
- ) {
- this.addEquipment(prop);
- } else {
- return true;
- }
- }).forEach((prop) => {
- // Now add everything else
- if (prop.type === 'effect'){
- this.addEffect(prop);
- } else if (prop.type === 'proficiency') {
- this.addProficiency(prop);
- } else if (prop.type === 'classLevel'){
- this.addClassLevel(prop);
- } else if (prop.type === 'constant'){
- this.addConstant(prop);
- } else {
- this.addEndStepProp(prop);
- }
- });
- for (let name in creature.denormalizedStats){
- if (!this.statsByVariableName[name]){
- this.statsByVariableName[name] = {
- variableName: name,
- value: creature.denormalizedStats[name],
- computationDetails: propDetailsByType.denormalizedStat(),
- }
- }
- }
- }
- addConstant(prop){
- prop = this.registerProperty(prop);
- this.constantsById[prop._id] = prop;
- }
- registerProperty(prop){
- this.originalPropsById[prop._id] = cloneDeep(prop);
- this.propsById[prop._id] = prop;
- prop.dependencies = [];
- prop.computationDetails = propDetails(prop);
- prop.ancestors.forEach(ancestor => {
- if (this.toggleIds.has(ancestor.id)){
- prop.computationDetails.toggleAncestors.push(ancestor.id);
- }
- });
- return prop;
- }
- addToggle(prop){
- prop = this.registerProperty(prop);
- this.togglesById[prop._id] = prop;
- }
- addClassLevel(prop){
- prop = this.registerProperty(prop);
- this.classLevelsById[prop._id] = prop;
- }
- addStat(prop){
- let variableName = prop.variableName;
- if (!variableName) return;
- let existingStat = this.statsByVariableName[variableName];
- prop = this.registerProperty(prop);
- if (existingStat){
- existingStat.computationDetails.idsOfSameName.push(prop._id);
- } else {
- this.statsById[prop._id] = prop;
- this.statsByVariableName[variableName] = prop;
- if (
- prop.type === 'skill' &&
- isSkillCheck(prop) &&
- prop.ability
- ){
- this.addSkillToAbility(prop, prop.ability)
- }
- }
- }
- addSkillToAbility(prop, ability){
- if (!this.skillsByAbility[ability]){
- this.skillsByAbility[ability] = [];
- }
- this.skillsByAbility[ability].push(prop);
- }
- addEffect(prop){
- prop = this.registerProperty(prop);
- let targets = this.getEffectTargets(prop);
- targets.forEach(target => {
- if (target.computationDetails && target.computationDetails.effects){
- target.computationDetails.effects.push(prop);
- }
- });
- if (!targets.size){
- this.unassignedEffects.push(prop);
- }
- }
- getEffectTargets(prop){
- let targets = new Set();
- if (!prop.stats) return targets;
- prop.stats.forEach((statName) => {
- let target;
- if (statName[0] === '#'){
- target = findAncestorByType({
- type: statName.slice(1),
- prop,
- memo: this
- });
- } else {
- target = this.statsByVariableName[statName];
- }
- if (!target) return;
- targets.add(target);
- if (isSkillOperation(prop) && isAbility(target)){
- let extras = this.skillsByAbility[statName] || [];
- extras.forEach(ex =>{
- // Only pass on ability effects to skills and checks
- if (ex.skillType === 'skill' || ex.skillType === 'check'){
- targets.add(ex)
- }
- });
- }
- });
- return targets;
- }
- addProficiency(prop){
- prop = this.registerProperty(prop);
- let targets = this.getProficiencyTargets(prop);
- targets.forEach(target => {
- if(target.computationDetails.proficiencies){
- target.computationDetails.proficiencies.push(prop);
- }
- });
- }
- getProficiencyTargets(prop){
- let targets = new Set();
- if (!prop.stats) return targets;
- prop.stats.forEach(statName => {
- let target = this.statsByVariableName[statName];
- if (!target) return;
- targets.add(target);
- if (isAbility(target)) {
- let extras = this.skillsByAbility[statName] || [];
- extras.forEach(ex =>{
- // Only pass on ability proficiencies to skills and checks
- if (ex.skillType === 'skill' || ex.skillType === 'check'){
- targets.add(ex)
- }
- });
- }
- });
- return targets;
- }
- addEquipment(prop){
- prop = this.registerProperty(prop);
- this.equipmentById[prop._id] = prop;
- }
- addEndStepProp(prop){
- prop = this.registerProperty(prop);
- this.endStepPropsById[prop._id] = prop;
- }
-}
-
-function isAbility(prop){
- return prop.type === 'attribute' &&
- prop.attributeType === 'ability'
-}
-
-function isSkillCheck(prop){
- return includes(['skill', 'check', 'save', 'utility'], prop.skillType);
-}
-
-const skillOperations = [
- 'advantage',
- 'disadvantage',
- 'passiveAdd',
- 'fail',
- 'conditional',
- 'rollBonus',
-];
-
-function isSkillOperation(prop){
- return skillOperations.includes(prop.operation);
-}
-
-function propDetails(prop){
- return propDetailsByType[prop.type] && propDetailsByType[prop.type]() ||
- propDetailsByType.default();
-}
-
-const propDetailsByType = {
- default(){
- return {
- toggleAncestors: [],
- };
- },
- toggle(){
- return {
- computed: false,
- busyComputing: false,
- toggleAncestors: [],
- };
- },
- attribute(){
- return {
- computed: false,
- busyComputing: false,
- effects: [],
- proficiencies: [],
- toggleAncestors: [],
- idsOfSameName: [],
- };
- },
- skill(){
- return {
- computed: false,
- busyComputing: false,
- effects: [],
- proficiencies: [],
- toggleAncestors: [],
- idsOfSameName: [],
- };
- },
- effect(){
- return {
- computed: false,
- busyComputing: false,
- toggleAncestors: [],
- };
- },
- classLevel(){
- return {
- computed: true,
- toggleAncestors: [],
- };
- },
- proficiency(){
- return {
- toggleAncestors: [],
- };
- },
- denormalizedStat(){
- return {
- toggleAncestors: [],
- };
- }
-}
diff --git a/app/imports/api/creature/computation/engine/EffectAggregator.js b/app/imports/api/creature/computation/engine/EffectAggregator.js
deleted file mode 100644
index 83e30f32..00000000
--- a/app/imports/api/creature/computation/engine/EffectAggregator.js
+++ /dev/null
@@ -1,78 +0,0 @@
-export default class EffectAggregator{
- constructor(){
- this.base = undefined;
- this.add = 0;
- this.mul = 1;
- this.min = Number.NEGATIVE_INFINITY;
- this.max = Number.POSITIVE_INFINITY;
- this.advantage = 0;
- this.disadvantage = 0;
- this.passiveAdd = undefined;
- this.fail = 0;
- this.set = undefined;
- this.conditional = [];
- this.rollBonus = [];
- this.hasNoEffects = true;
- }
- addEffect(effect){
- let result = effect.result;
- if (this.hasNoEffects) this.hasNoEffects = false;
- switch(effect.operation){
- case 'base':
- // Take the largest base value
- if (Number.isFinite(result)){
- if(Number.isFinite(this.base)){
- this.base = Math.max(this.base, result);
- } else {
- this.base = result;
- }
- }
- break;
- case 'add':
- // Add all adds together
- this.add += result;
- break;
- case 'mul':
- // Multiply the muls together
- this.mul *= result;
- break;
- case 'min':
- // Take the largest min value
- this.min = result > this.min ? result : this.min;
- break;
- case 'max':
- // Take the smallest max value
- this.max = result < this.max ? result : this.max;
- break;
- case 'set':
- // Take the highest set value
- this.set = this.set === undefined || result > this.set ? result : this.set;
- break;
- case 'advantage':
- // Sum number of advantages
- this.advantage++;
- break;
- case 'disadvantage':
- // Sum number of disadvantages
- this.disadvantage++;
- break;
- case 'passiveAdd':
- // Add all passive adds together
- if (this.passiveAdd === undefined) this.passiveAdd = 0;
- this.passiveAdd += result;
- break;
- case 'fail':
- // Sum number of fails
- this.fail++;
- break;
- case 'conditional':
- // Store array of conditionals
- this.conditional.push(result);
- break;
- case 'rollBonus':
- // Store array of roll bonuses
- this.rollBonus.push(result);
- break;
- }
- }
-}
diff --git a/app/imports/api/creature/computation/engine/applyToggles.js b/app/imports/api/creature/computation/engine/applyToggles.js
deleted file mode 100644
index 74839c35..00000000
--- a/app/imports/api/creature/computation/engine/applyToggles.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import computeToggle from '/imports/api/creature/computation/engine/computeToggle.js';
-import { union } from 'lodash';
-
-export default function applyToggles(prop, memo){
- // If it used to be inactive delete those fields
- if (prop.inactive) prop.inactive = undefined;
- if (prop.deactivatedByAncestor) prop.deactivatedByAncestor = undefined;
- if (prop.deactivatedByToggle) prop.deactivatedByToggle = undefined;
- // Iterate through the toggle ancestors from oldest to nearest
- prop.computationDetails.toggleAncestors.forEach(toggleId => {
- let toggle = memo.togglesById[toggleId];
- computeToggle(toggle, memo);
- prop.dependencies = union(
- prop.dependencies,
- [toggle._id],
- toggle.dependencies,
- );
- // Deactivate if the toggle is false
- if (!toggle.toggleResult){
- prop.inactive = true;
- prop.deactivatedByAncestor = true;
- prop.deactivatedByToggle = true;
- }
- });
-}
diff --git a/app/imports/api/creature/computation/engine/combineStat.js b/app/imports/api/creature/computation/engine/combineStat.js
deleted file mode 100644
index db612d78..00000000
--- a/app/imports/api/creature/computation/engine/combineStat.js
+++ /dev/null
@@ -1,197 +0,0 @@
-import computeStat from '/imports/api/creature/computation/engine/computeStat.js';
-import computeProficiency from '/imports/api/creature/computation/engine/computeProficiency.js';
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import stripFloatingPointOddities from '/imports/ui/utility/stripFloatingPointOddities.js';
-import { union } from 'lodash';
-
-export default function combineStat(stat, aggregator, memo){
- if (stat.type === 'attribute'){
- combineAttribute(stat, aggregator, memo);
- } else if (stat.type === 'skill'){
- combineSkill(stat, aggregator, memo);
- } else if (stat.type === 'damageMultiplier'){
- combineDamageMultiplier(stat, memo);
- }
-}
-
-function getAggregatorResult(stat, aggregator){
- let base;
- if (!Number.isFinite(aggregator.base)){
- base = stat.baseValue || 0;
- } else if (!Number.isFinite(stat.baseValue)){
- base = aggregator.base || 0;
- } else {
- base = Math.max(aggregator.base, stat.baseValue);
- }
- let result = (base + aggregator.add) * aggregator.mul;
- if (result < aggregator.min) {
- result = aggregator.min;
- }
- if (result > aggregator.max) {
- result = aggregator.max;
- }
- if (aggregator.set !== undefined) {
- result = aggregator.set;
- }
- if (!stat.decimal && Number.isFinite(result)){
- result = Math.floor(result);
- } else if (Number.isFinite(result)){
- result = stripFloatingPointOddities(result);
- }
- return result;
-}
-
-function combineAttribute(stat, aggregator, memo){
- stat.value = getAggregatorResult(stat, aggregator);
- if (stat.attributeType === 'spellSlot'){
- let {
- result,
- context,
- dependencies
- } = evaluateCalculation({
- string: stat.spellSlotLevelCalculation,
- memo,
- prop: stat,
- });
- stat.spellSlotLevelValue = result.value;
- stat.spellSlotLevelErrors = context.errors;
- stat.dependencies = union(stat.dependencies, dependencies);
- }
- stat.currentValue = stat.value - (stat.damage || 0);
- // Ability scores get modifiers
- if (stat.attributeType === 'ability') {
- stat.modifier = Math.floor((stat.currentValue - 10) / 2);
- } else {
- stat.modifier = undefined;
- }
- // Hit dice get constitution modifiers
- stat.constitutionMod = undefined;
- if (stat.attributeType === 'hitDice') {
- let conStat = memo.statsByVariableName['constitution'];
- if (conStat && 'modifier' in conStat){
- stat.constitutionMod = conStat.modifier;
- stat.dependencies = union(
- stat.dependencies,
- [conStat._id],
- conStat.dependencies,
- );
- }
- }
- // Stats that have no effects can be hidden based on a sheet setting
- stat.hide = aggregator.hasNoEffects &&
- stat.baseValue === undefined ||
- undefined
-}
-
-function combineSkill(stat, aggregator, memo){
- // Skills are based on some ability Modifier
- let ability = stat.ability && memo.statsByVariableName[stat.ability]
- if (stat.ability && ability){
- computeStat(ability, memo);
- stat.abilityMod = ability.modifier;
- stat.dependencies = union(
- stat.dependencies,
- [ability._id],
- ability.dependencies,
- );
- } else {
- stat.abilityMod = 0;
- }
- // Combine all the child proficiencies
- stat.proficiency = 0;
- for (let i in stat.computationDetails.proficiencies){
- let prof = stat.computationDetails.proficiencies[i];
- computeProficiency(prof, memo);
- if (
- !prof.deactivatedByToggle &&
- prof.value > stat.proficiency
- ){
- stat.proficiency = prof.value;
- stat.dependencies = union(
- stat.dependencies,
- [prof._id],
- prof.dependencies,
- );
- }
- }
- // Get the character's proficiency bonus to apply
- let profBonusStat = memo.statsByVariableName['proficiencyBonus'];
- let profBonus = profBonusStat && profBonusStat.value;
-
- if (profBonusStat){
- stat.dependencies = union(
- stat.dependencies,
- [profBonusStat._id],
- profBonusStat.dependencies,
- );
- }
-
- if (typeof profBonus !== 'number' && memo.statsByVariableName['level']){
- let levelProp = memo.statsByVariableName['level'];
- let level = levelProp.value;
- profBonus = Math.ceil(level / 4) + 1;
- if (levelProp._id){
- stat.dependencies = union(stat.dependencies, [levelProp._id]);
- }
- if (levelProp.dependencies){
- stat.dependencies = union(stat.dependencies, levelProp.dependencies);
- }
- }
-
- // Multiply the proficiency bonus by the actual proficiency
- if(stat.proficiency === 0.49){
- // Round down proficiency bonus in the special case
- profBonus = Math.floor(profBonus * 0.5);
- } else {
- profBonus = Math.ceil(profBonus * stat.proficiency);
- }
-
- // Combine everything to get the final result
- let base = aggregator.base || 0;
- let result = (base + stat.abilityMod + profBonus + aggregator.add) * aggregator.mul;
- if (result < aggregator.min) result = aggregator.min;
- if (result > aggregator.max) result = aggregator.max;
- if (aggregator.set !== undefined) {
- result = aggregator.set;
- }
- if (Number.isFinite(result)){
- result = Math.floor(result);
- }
- stat.value = result;
- // Advantage/disadvantage
- if (aggregator.advantage && !aggregator.disadvantage){
- stat.advantage = 1;
- } else if (aggregator.disadvantage && !aggregator.advantage){
- stat.advantage = -1;
- } else {
- stat.advantage = 0;
- }
- // Passive bonus
- stat.passiveBonus = aggregator.passiveAdd;
- // conditional benefits
- stat.conditionalBenefits = aggregator.conditional;
- // Roll bonuses
- stat.rollBonus = aggregator.rollBonus;
- // Forced to fail
- stat.fail = aggregator.fail;
- // Rollbonus
- stat.rollBonuses = aggregator.rollBonus;
- // Hide
- stat.hide = aggregator.hasNoEffects &&
- stat.baseValue === undefined &&
- stat.proficiency == 0 ||
- undefined;
-}
-
-function combineDamageMultiplier(stat){
- if (stat.immunityCount) return 0;
- let result;
- if (stat.ressistanceCount && !stat.vulnerabilityCount){
- result = 0.5;
- } else if (!stat.ressistanceCount && stat.vulnerabilityCount){
- result = 2;
- } else {
- result = 1;
- }
- stat.value = result;
-}
diff --git a/app/imports/api/creature/computation/engine/computeConstant.js b/app/imports/api/creature/computation/engine/computeConstant.js
deleted file mode 100644
index 4d972c57..00000000
--- a/app/imports/api/creature/computation/engine/computeConstant.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-
-export default function computeConstant(constant, memo){
- // Apply any toggles
- applyToggles(constant, memo);
- if (constant.deactivatedByToggle) return;
- if (
- !memo.constantsByVariableName[constant.variableName]
- ){
- memo.constantsByVariableName[constant.variableName] = constant
- }
-}
diff --git a/app/imports/api/creature/computation/engine/computeEffect.js b/app/imports/api/creature/computation/engine/computeEffect.js
deleted file mode 100644
index 75d880b4..00000000
--- a/app/imports/api/creature/computation/engine/computeEffect.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-import { union } from 'lodash';
-
-export default function computeEffect(effect, memo){
- if (effect.computationDetails.computed) return;
- if (effect.computationDetails.busyComputing){
- // Trying to compute this effect again while it is already computing.
- // We must be in a dependency loop.
- effect.computationDetails.computed = true;
- effect.result = NaN;
- effect.computationDetails.busyComputing = false;
- effect.computationDetails.error = 'dependencyLoop';
- if (Meteor.isClient) console.warn('dependencyLoop', effect);
- return;
- }
- // Before doing any work, mark this effect as busy
- effect.computationDetails.busyComputing = true;
-
- // Apply any toggles
- applyToggles(effect, memo);
-
- // Determine result of effect calculation
- delete effect.errors;
- if (!effect.calculation){
- if(effect.operation === 'add' || effect.operation === 'base'){
- effect.result = 0;
- } else {
- delete effect.result
- }
- } else if (Number.isFinite(+effect.calculation)){
- effect.result = +effect.calculation;
- } else if(effect.operation === 'conditional' || effect.operation === 'rollBonus'){
- effect.result = effect.calculation;
- } else if(_.contains(['advantage', 'disadvantage', 'fail'], effect.operation)){
- effect.result = 1;
- } else {
- let {
- result,
- context,
- dependencies,
- } = evaluateCalculation({
- string: effect.calculation,
- prop: effect,
- memo
- });
- effect.result = result.value;
- effect.dependencies = union(effect.dependencies, dependencies);
- if (context.errors.length){
- effect.errors = context.errors;
- }
- }
- effect.computationDetails.computed = true;
- effect.computationDetails.busyComputing = false;
-}
diff --git a/app/imports/api/creature/computation/engine/computeEndStepProperty.js b/app/imports/api/creature/computation/engine/computeEndStepProperty.js
deleted file mode 100644
index fad2afd4..00000000
--- a/app/imports/api/creature/computation/engine/computeEndStepProperty.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import ConstantNode from '/imports/parser/parseTree/ConstantNode.js';
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-import { union } from 'lodash';
-
-export default function computeEndStepProperty(prop, memo){
- applyToggles(prop, memo);
-
- switch (prop.type){
- case 'action':
- case 'spell':
- computeAction(prop, memo);
- break;
- case 'adjustment':
- case 'damage':
- computePropertyField(prop, memo, 'amount', 'compile');
- break;
- case 'attack':
- computeAction(prop, memo);
- computePropertyField(prop, memo, 'rollBonus');
- break;
- case 'savingThrow':
- computePropertyField(prop, memo, 'dc');
- break;
- case 'spellList':
- computePropertyField(prop, memo, 'maxPrepared');
- computePropertyField(prop, memo, 'attackRollBonus');
- computePropertyField(prop, memo, 'dc');
- break;
- case 'propertySlot':
- computePropertyField(prop, memo, 'quantityExpected');
- computePropertyField(prop, memo, 'slotCondition');
- break;
- case 'roll':
- computePropertyField(prop, memo, 'roll', 'compile');
- break;
- }
-}
-
-function computeAction(prop, memo){
- // Uses
- let {
- result,
- context,
- dependencies,
- } = evaluateCalculation({ string: prop.uses, prop, memo});
- prop.usesResult = result.value;
- prop.dependencies = union(prop.dependencies, dependencies);
- if (context.errors.length){
- prop.usesErrors = context.errors;
- } else {
- delete prop.usesErrors;
- }
- prop.insufficientResources = undefined;
- if (prop.usesUsed >= prop.usesResult){
- prop.insufficientResources = true;
- }
- if (!prop.resources) return;
- // Attributes consumed
- prop.resources.attributesConsumed.forEach((attConsumed, i) => {
- if (attConsumed.variableName){
- let stat = memo.statsByVariableName[attConsumed.variableName];
- prop.resources.attributesConsumed[i].statId = stat && stat._id;
- prop.resources.attributesConsumed[i].statName = stat && stat.name;
- let available = stat && stat.currentValue || 0;
- prop.resources.attributesConsumed[i].available = available;
- if (available < attConsumed.quantity){
- prop.insufficientResources = true;
- }
- if (stat){
- prop.dependencies = union(
- prop.dependencies,
- [stat._id],
- stat.dependencies
- );
- }
- }
- });
- // Items consumed
- prop.resources.itemsConsumed.forEach((itemConsumed, i) => {
- let item = itemConsumed.itemId ?
- memo.equipmentById[itemConsumed.itemId] :
- undefined;
- let available = item ? item.quantity : 0;
- prop.resources.itemsConsumed[i].available = available;
- if (!item || available < itemConsumed.quantity){
- prop.insufficientResources = true;
- }
- if (item){
- prop.resources.itemsConsumed[i].itemId = item._id;
- let name = item.name;
- if (item.quantity !== 1 && item.plural){
- name = item.plural;
- }
- if (name) prop.resources.itemsConsumed[i].itemName = name;
- if (item.icon) prop.resources.itemsConsumed[i].itemIcon = item.icon;
- if (item.color) prop.resources.itemsConsumed[i].itemColor = item.color;
- prop.dependencies = union(
- prop.dependencies,
- [item._id],
- item.dependencies
- );
- } else {
- delete prop.resources.itemsConsumed[i].itemId;
- delete prop.resources.itemsConsumed[i].itemName;
- delete prop.resources.itemsConsumed[i].itemIcon;
- delete prop.resources.itemsConsumed[i].itemColor;
- }
- });
-}
-
-function computePropertyField(prop, memo, fieldName, fn){
- let {
- result,
- context,
- dependencies,
- } = evaluateCalculation({string: prop[fieldName], prop, memo, fn});
- if (result instanceof ConstantNode){
- prop[`${fieldName}Result`] = result.value;
- } else {
- prop[`${fieldName}Result`] = result.toString();
- }
- prop.dependencies = union(prop.dependencies, dependencies);
- if (context.errors.length){
- prop[`${fieldName}Errors`] = context.errors;
- } else {
- delete prop[`${fieldName}Errors`];
- }
-}
diff --git a/app/imports/api/creature/computation/engine/computeInlineCalculations.js b/app/imports/api/creature/computation/engine/computeInlineCalculations.js
deleted file mode 100644
index eb54d998..00000000
--- a/app/imports/api/creature/computation/engine/computeInlineCalculations.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
-import ErrorNode from '/imports/parser/parseTree/ErrorNode.js';
-import { union } from 'lodash';
-
-export default function computeInlineCalculations(prop, memo){
- if (prop.summary){
- computeInlineCalcsForField(prop, memo, 'summary');
- }
- if (prop.description){
- computeInlineCalcsForField(prop, memo, 'description');
- }
-}
-
-function computeInlineCalcsForField(prop, memo, field){
- let string = prop[field];
- let inlineComputations = [];
- let matches = string.matchAll(INLINE_CALCULATION_REGEX);
- for (let match of matches){
- let calculation = match[1];
- let {
- result,
- context,
- dependencies,
- } = evaluateCalculation({string: calculation, prop, memo, fn: 'compile'});
- if (result instanceof ErrorNode){
- result = '`Calculation Error`';
- }
- let computation = {
- calculation,
- result: result && result.toString(),
- };
- if (context.errors.length){
- computation.errors = context.errors;
- }
- inlineComputations.push(computation);
- prop.dependencies = union(prop.dependencies, dependencies);
- }
- prop[`${field}Calculations`] = inlineComputations;
-}
diff --git a/app/imports/api/creature/computation/engine/computeLevels.js b/app/imports/api/creature/computation/engine/computeLevels.js
deleted file mode 100644
index 21757025..00000000
--- a/app/imports/api/creature/computation/engine/computeLevels.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { forOwn, has, union } from 'lodash';
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-
-export default function computeLevels(memo){
- computeClassLevels(memo);
- computeTotalLevel(memo);
-}
-
-function computeClassLevels(memo){
- forOwn(memo.classLevelsById, classLevel => {
- applyToggles(classLevel, memo);
- // class levels are mutually dependent
- classLevel.dependencies = union(
- classLevel.dependencies,
- Object.keys(memo.classLevelsById)
- );
- if (classLevel.deactivatedByToggle) return;
- let name = classLevel.variableName;
- let stat = memo.statsByVariableName[name];
- if (!stat){
- memo.statsByVariableName[name] = classLevel;
- memo.classes[name] = classLevel;
- } else if (!has(stat, 'level')){
- // Stat is overriden by an attribute
- return;
- } else if (stat.level < classLevel.level) {
- memo.statsByVariableName[name] = classLevel;
- memo.classes[name] = classLevel;
- }
- });
-}
-
-function computeTotalLevel(memo){
- let currentLevel = memo.statsByVariableName['level'];
- if (!currentLevel || currentLevel.deactivatedByToggle){
- currentLevel = {
- value: 0,
- dependencies: [],
- computationDetails: {
- builtIn: true,
- computed: true,
- }
- };
- memo.statsByVariableName['level'] = currentLevel;
- }
- // bail out if overriden by an attribute
- if (!currentLevel.computationDetails.builtIn) return;
- let level = 0;
- for (let name in memo.classes){
- let cls = memo.classes[name];
- level += cls.level || 0;
- if (cls._id){
- currentLevel.dependencies = union(
- currentLevel.dependencies,
- [cls._id]
- )
- }
- if (cls.dependencies){
- currentLevel.dependencies = union(
- currentLevel.dependencies,
- cls.dependencies,
- )
- }
- }
- currentLevel.value = level;
-}
diff --git a/app/imports/api/creature/computation/engine/computeMemo.js b/app/imports/api/creature/computation/engine/computeMemo.js
deleted file mode 100644
index 5eeb32e1..00000000
--- a/app/imports/api/creature/computation/engine/computeMemo.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { each, forOwn } from 'lodash';
-import computeLevels from '/imports/api/creature/computation/engine/computeLevels.js';
-import computeStat from '/imports/api/creature/computation/engine/computeStat.js';
-import computeEffect from '/imports/api/creature/computation/engine/computeEffect.js';
-import computeToggle from '/imports/api/creature/computation/engine/computeToggle.js';
-import computeEndStepProperty from '/imports/api/creature/computation/engine/computeEndStepProperty.js';
-import computeInlineCalculations from '/imports/api/creature/computation/engine/computeInlineCalculations.js';
-import computeConstant from '/imports/api/creature/computation/engine/computeConstant.js';
-
-export default function computeMemo(memo){
- // Compute level
- computeLevels(memo);
- // Compute all constants that could be used
- forOwn(memo.constantsById, constant => {
- computeConstant (constant, memo);
- });
- // Compute all stats, even if they are overriden
- forOwn(memo.statsById, stat => {
- computeStat (stat, memo);
- });
- // Compute effects which didn't end up targeting a stat
- each(memo.unassignedEffects, effect => {
- computeEffect(effect, memo);
- });
- // Compute toggles which didn't already get computed by dependencies
- forOwn(memo.togglesById, toggle => {
- computeToggle(toggle, memo);
- });
- // Compute end step properties
- forOwn(memo.endStepPropsById, prop => {
- computeEndStepProperty(prop, memo);
- });
- // Compute inline calculations
- forOwn(memo.propsById, prop => {
- computeInlineCalculations(prop, memo);
- });
-}
diff --git a/app/imports/api/creature/computation/engine/computeProficiency.js b/app/imports/api/creature/computation/engine/computeProficiency.js
deleted file mode 100644
index 112a594b..00000000
--- a/app/imports/api/creature/computation/engine/computeProficiency.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-
-export default function computeEffect(proficiency, memo){
- if (proficiency.computationDetails.computed) return;
- if (proficiency.computationDetails.busyComputing){
- // Trying to compute this proficiency again while it is already computing.
- // We must be in a dependency loop.
- proficiency.computationDetails.computed = true;
- proficiency.result = NaN;
- proficiency.computationDetails.busyComputing = false;
- proficiency.computationDetails.error = 'dependencyLoop';
- if (Meteor.isClient) console.warn('dependencyLoop', proficiency);
- return;
- }
- // Before doing any work, mark this proficiency as busy
- proficiency.computationDetails.busyComputing = true;
-
- // Apply any toggles
- applyToggles(proficiency, memo);
-
- proficiency.computationDetails.computed = true;
- proficiency.computationDetails.busyComputing = false;
-}
diff --git a/app/imports/api/creature/computation/engine/computeStat.js b/app/imports/api/creature/computation/engine/computeStat.js
deleted file mode 100644
index a455a43a..00000000
--- a/app/imports/api/creature/computation/engine/computeStat.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import combineStat from '/imports/api/creature/computation/engine/combineStat.js';
-import computeEffect from '/imports/api/creature/computation/engine/computeEffect.js';
-import EffectAggregator from '/imports/api/creature/computation/engine/EffectAggregator.js';
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-import { each, union, without } from 'lodash';
-
-export default function computeStat(stat, memo){
- // If the stat is already computed, skip it
- if (stat.computationDetails.computed) return;
- if (stat.computationDetails.busyComputing){
- // Trying to compute this stat again while it is already computing.
- // We must be in a dependency loop.
- stat.computationDetails.computed = true;
- stat.value = NaN;
- stat.computationDetails.busyComputing = false;
- stat.computationDetails.error = 'dependencyLoop';
- if (Meteor.isClient) console.warn('dependencyLoop', stat);
- return;
- }
- // Before doing any work, mark this stat as busy
- stat.computationDetails.busyComputing = true;
-
- let effects = stat.computationDetails.effects || [];
- let proficiencies = stat.computationDetails.proficiencies || [];
-
- // Get references to all the stats that share the variable name
- let sameNameStats
-
- if (stat.computationDetails.idsOfSameName){
- sameNameStats = stat.computationDetails.idsOfSameName.map(
- id => memo.propsById[id]
- );
- } else {
- sameNameStats = [];
- }
-
- let allStats = [stat, ...sameNameStats];
-
- // Decide which stat is the last active stat
- // The last active stat is considered the cannonical stat
- let lastActiveStat;
- allStats.forEach(candidateStat => {
- applyToggles(candidateStat, memo);
- if (!candidateStat.inactive) lastActiveStat = candidateStat;
- candidateStat.overridden = undefined;
- });
- if (!lastActiveStat){
- delete memo.statsByVariableName[stat.variableName];
- return;
- }
- // Make sure the active stat has all the effects and proficiencies
- lastActiveStat.computationDetails.effects = effects;
- lastActiveStat.computationDetails.proficiencies = proficiencies;
-
- // Update the memo's stat with the chosen stat
- memo.statsByVariableName[stat.variableName] = lastActiveStat;
-
- // Recreate list of the non-cannonical stats
- sameNameStats = without(allStats, lastActiveStat);
-
- sameNameStats.forEach(statInstance => {
- // Mark the non-cannonical stats as overridden
- statInstance.overridden = true;
-
- // Apply the cannonical damage
- statInstance.damage = lastActiveStat.damage;
- });
-
- let baseDependencies = [];
- allStats.forEach(statInstance => {
- // Add this stat and its deps to the dependencies
- baseDependencies = union(
- baseDependencies,
- [statInstance._id],
- statInstance.dependencies,
- );
-
- // Apply all the base proficiencies
- if (statInstance.baseProficiency && !statInstance.inactive){
- proficiencies.push({
- value: statInstance.baseProficiency,
- stats: [statInstance.variableName],
- type: 'proficiency',
- dependencies: statInstance.overridden ?
- union(statInstance.dependencies, [statInstance._id]) :
- [],
- computationDetails: {
- computed: true,
- }
- });
- }
-
- // Compute each active stat's baseValue calculation and apply it
- if (!statInstance.inactive) {
- delete statInstance.baseValueErrors;
- let {
- result,
- context,
- dependencies
- } = evaluateCalculation({
- string: statInstance.baseValueCalculation,
- prop: statInstance,
- memo
- });
- result.value = +result.value;
- if (!isNaN(result.value)){
- statInstance.baseValue = result.value;
- } else {
- statInstance.baseValue = undefined;
- }
- statInstance.dependencies = union(statInstance.dependencies, dependencies);
- if (context.errors.length){
- statInstance.baseValueErrors = context.errors;
- }
- // Apply all the base values
- if (Number.isFinite(statInstance.baseValue)){
- effects.push({
- operation: 'base',
- calculation: statInstance.baseValueCalculation,
- result: statInstance.baseValue,
- stats: [statInstance.variableName],
- dependencies: statInstance.overridden ?
- union(statInstance.dependencies, [statInstance._id]) :
- [],
- computationDetails: {
- computed: true,
- },
- });
- }
- }
- });
-
- // Compute and aggregate all the effects
- let aggregator = new EffectAggregator();
- let effectDeps = [];
- each(effects, (effect) => {
- // Compute
- computeEffect(effect, memo);
- if (effect.deactivatedByToggle) return;
-
- // dependencies
- if (effect._id) effectDeps = union(effectDeps, [effect._id]);
- effectDeps = union(effectDeps, effect.dependencies);
-
- // Add computed effect to aggregator
- aggregator.addEffect(effect);
- });
-
- // Combine the effects into the stats
- allStats.forEach(statInstance => {
- // Conglomerate all the effects to compute the final stat values
- combineStat(statInstance, aggregator, memo);
- // Mark the stats as computed
- statInstance.computationDetails.computed = true;
- statInstance.computationDetails.busyComputing = false;
- // Only the active stat instance depeneds on the effects
- if (!statInstance.overridden){
- statInstance.dependencies = union(statInstance.dependencies, effectDeps);
- }
- });
-}
diff --git a/app/imports/api/creature/computation/engine/computeToggle.js b/app/imports/api/creature/computation/engine/computeToggle.js
deleted file mode 100644
index dad8174b..00000000
--- a/app/imports/api/creature/computation/engine/computeToggle.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
-import applyToggles from '/imports/api/creature/computation/engine/applyToggles.js';
-import { union } from 'lodash';
-
-export default function computeToggle(toggle, memo){
- if (toggle.computationDetails.computed) return;
- if (toggle.computationDetails.busyComputing){
- // Trying to compute this effect again while it is already computing.
- // We must be in a dependency loop.
- toggle.computationDetails.computed = true;
- toggle.result = false;
- toggle.computationDetails.busyComputing = false;
- toggle.computationDetails.error = 'dependencyLoop';
- if (Meteor.isClient) console.warn('dependencyLoop', toggle);
- return;
- }
- // Before doing any work, mark this toggle as busy
- toggle.computationDetails.busyComputing = true;
-
- // Apply any parent toggles
- applyToggles(toggle, memo);
-
- // Do work
- delete toggle.errors;
- if (toggle.enabled){
- toggle.toggleResult = true;
- } else if (toggle.disabled){
- toggle.toggleResult = false;
- } else if (!toggle.condition){
- toggle.toggleResult = false;
- } else if (Number.isFinite(+toggle.condition)){
- toggle.toggleResult = !!+toggle.condition;
- } else {
- let {
- result,
- context,
- dependencies,
- } = evaluateCalculation({string: toggle.condition, prop: toggle, memo});
- toggle.toggleResult = !!result.value;
- toggle.dependencies = union(
- toggle.dependencies,
- dependencies,
- );
- if (context.errors.length){
- toggle.errors = context.errors;
- }
- }
- if (!toggle.toggleResult){
- toggle.inactive = true;
- toggle.deactivatedBySelf = true;
- toggle.deactivatedByToggle = true;
- }
- toggle.computationDetails.computed = true;
- toggle.computationDetails.busyComputing = false;
-}
diff --git a/app/imports/api/creature/computation/engine/evaluateCalculation.js b/app/imports/api/creature/computation/engine/evaluateCalculation.js
deleted file mode 100644
index 9266efd3..00000000
--- a/app/imports/api/creature/computation/engine/evaluateCalculation.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import computeStat from '/imports/api/creature/computation/engine/computeStat.js';
-import { prettifyParseError, parse, CompilationContext } from '/imports/parser/parser.js';
-import SymbolNode from '/imports/parser/parseTree/SymbolNode.js';
-import AccessorNode from '/imports/parser/parseTree/AccessorNode.js';
-import ConstantNode from '/imports/parser/parseTree/ConstantNode.js';
-import ErrorNode from '/imports/parser/parseTree/ErrorNode.js';
-import findAncestorByType from '/imports/api/creature/computation/engine/findAncestorByType.js';
-import { union } from 'lodash';
-
-/* Convert a calculation into a constant output and errors*/
-export default function evaluateCalculation({
- string,
- prop,
- memo,
- fn = 'reduce',
-}){
- let dependencies = [];
- let context = new CompilationContext();
- if (!string) return {
- result: new ConstantNode({value: string, type: 'string'}),
- context,
- dependencies,
- };
- if (typeof string !== 'string'){
- string = string.toString();
- }
- // Parse the string
- let calc;
- try {
- calc = parse(string);
- } catch (e) {
- let error = prettifyParseError(e);
- return {
- result: new ErrorNode({context, error}),
- context,
- dependencies,
- };
- }
-
- // Replace constants with their parsed constant
- let replaceResults = replaceConstants({
- calc, memo, prop, dependencies, context
- });
- dependencies = replaceResults.dependencies;
- calc = replaceResults.calc;
- if (replaceResults.failed){
- return {
- result: new ConstantNode({value: string, type: 'string'}),
- context,
- dependencies,
- };
- }
-
- // Ensure all symbol nodes are defined and computed
- dependencies = computeSymbols({calc, memo, prop, dependencies})
-
- // Evaluate
- let result = calc[fn](memo.statsByVariableName, context);
- return {result, context, dependencies};
-}
-
-// Replace constants in the calc with the right ParseNodes
-function replaceConstants({calc, memo, prop, dependencies, context}){
- let constFailed = [];
- calc = calc.replaceNodes(node => {
- if (!(node instanceof SymbolNode)) return;
- let stat, constant;
- if (node.name[0] !== '#'){
- stat = memo.statsByVariableName[node.name]
- constant = memo.constantsByVariableName[node.name];
- } else if (node.name === '#constant'){
- constant = findAncestorByType({type: 'constant', prop, memo});
- }
- // replace constants that aren't overridden by stats or disabled by a toggle
- if (constant && !constant.deactivatedByToggle && !stat){
- dependencies = union(dependencies, [
- constant._id,
- ...constant.dependencies
- ]);
- // Fail if the constant has errors
- if (constant.errors && constant.errors.length){
- constFailed.push(node.name);
- return;
- }
- let parsedConstantNode;
- try {
- parsedConstantNode = parse(constant.calculation);
- } catch(e){
- constFailed.push(node.name);
- return;
- }
- if (!parsedConstantNode) constFailed.push(node.name);
- return parsedConstantNode;
- }
- });
- constFailed.forEach(name => {
- context.storeError({
- type: 'error',
- message: `${name} is a constant property with parsing errors`
- });
- });
- let failed = !!constFailed.length;
- if (failed){
- calc = new ErrorNode({error: 'Failed to replace constants'});
- }
- return { failed, dependencies, calc };
-}
-
- // Ensure all symbol nodes are defined and computed
-function computeSymbols({calc, memo, prop, dependencies}){
- calc.traverse(node => {
- if (node instanceof SymbolNode || node instanceof AccessorNode){
- let stat;
- // References up the tree start with #
- if (node.name[0] === '#'){
- stat = findAncestorByType({type: node.name.slice(1), prop, memo});
- memo.statsByVariableName[node.name] = stat;
- } else {
- stat = memo.statsByVariableName[node.name];
- }
- if (stat && stat.computationDetails && !stat.computationDetails.computed){
- computeStat(stat, memo);
- }
- if (stat){
- if (stat.dependencies){
- dependencies = union(dependencies, [
- stat._id || node.name,
- ...stat.dependencies
- ]);
- } else {
- dependencies = union(dependencies, [stat._id || node.name]);
- }
- }
- }
- });
- return dependencies;
-}
diff --git a/app/imports/api/creature/computation/engine/getComputationProperties.js b/app/imports/api/creature/computation/engine/getComputationProperties.js
deleted file mode 100644
index aee7575e..00000000
--- a/app/imports/api/creature/computation/engine/getComputationProperties.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-
-export default function getComputationProperties(creatureId){
- // Find all the relevant properties
- return CreatureProperties.find({
- 'ancestors.id': creatureId,
- removed: {$ne: true},
- $or: [
- // All active properties
- {inactive: {$ne: true}},
- // Unless they were deactivated because of a toggle
- {deactivatedByToggle: true},
- ]
- }, {
- // Filter out fields never used by calculations
- fields: {
- icon: 0,
- },
- // Obey tree order
- sort: {
- order: 1,
- }
- }).fetch();
-}
diff --git a/app/imports/api/creature/computation/engine/getDependentProperties.js b/app/imports/api/creature/computation/engine/getDependentProperties.js
deleted file mode 100644
index d87a70ed..00000000
--- a/app/imports/api/creature/computation/engine/getDependentProperties.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import { union } from 'lodash';
-
-export default function getDependentProperties({
- creatureId,
- propertyIds,
- propertiesDependedAponIds,
- }){
- // find ids of all dependant toggles that have conditions, even if inactive
- let toggleIds = CreatureProperties.find({
- 'ancestors.id': creatureId,
- type: 'toggle',
- removed: {$ne: true},
- condition: { $exists: true },
- dependencies: {$in: propertyIds},
- }, {
- fields: {_id: 1},
- }).map(t => t._id);
- // Find all the dependant properties
- let props = CreatureProperties.find({
- 'ancestors.id': creatureId,
- removed: {$ne: true},
- dependencies: {$in: propertyIds},
- $or: [
- // All active properties
- {inactive: {$ne: true}},
- // All active and inactive toggles with conditions
- // Same as {$in: toggleIds}, but should be slightly faster
- {type: 'toggle', condition: { $exists: true }},
- // All decendents of the above toggles
- {'ancestors.id': {$in: toggleIds}},
- ]
- }, { fields: {_id: 1, dependencies: 1} }).fetch();
- // Add all the properties that changing props depend on, but haven't yet been
- // included to make an array of every property we need
- let allConnectedPropIds = [...propertyIds, ...propertiesDependedAponIds];
- props.forEach(prop => {
- allConnectedPropIds = union(
- allConnectedPropIds,
- prop.dependencies,
- [prop._id]);
- });
- // Add on all the properties and the objects they depend apon
- return CreatureProperties.find({
- _id: {$in: allConnectedPropIds}
- }, {
- // Ignore fields not used in computations
- fields: {icon: 0},
- sort: {order: 1},
- }).fetch();
-}
diff --git a/app/imports/api/creature/computation/engine/writeCreatureVariables.js b/app/imports/api/creature/computation/engine/writeCreatureVariables.js
deleted file mode 100644
index c8ccb7d8..00000000
--- a/app/imports/api/creature/computation/engine/writeCreatureVariables.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { pick, forOwn } from 'lodash';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import VERSION from '/imports/constants/VERSION.js';
-
-export default function writeCreatureVariables(memo, creatureId, fullRecompute = true) {
- const fields = [
- 'ability',
- 'abilityMod',
- 'advantage',
- 'attributeType',
- 'baseProficiency',
- 'baseValue',
- 'calculation',
- 'conditionalBenefits',
- 'currentValue',
- 'damage',
- 'decimal',
- 'fail',
- 'level',
- 'modifier',
- 'name',
- 'passiveBonus',
- 'proficiency',
- 'reset',
- 'resetMultiplier',
- 'rollBonuses',
- 'skillType',
- 'spellSlotLevelValue',
- 'type',
- 'value',
- ];
-
- if (fullRecompute){
- memo.creatureVariables = {};
- forOwn(memo.statsByVariableName, (stat, variableName) => {
- // Don't save context variables
- if (variableName[0] === '#') return;
- let condensedStat = pick(stat, fields);
- memo.creatureVariables[variableName] = condensedStat;
- });
- forOwn(memo.constantsByVariableName, (stat, variableName) => {
- let condensedStat = pick(stat, fields);
- if (!memo.creatureVariables[variableName]){
- memo.creatureVariables[variableName] = condensedStat;
- }
- });
- Creatures.update(creatureId, {$set: {
- variables: memo.creatureVariables,
- computeVersion: VERSION,
- }});
- } else {
- let $set = {};
- forOwn(memo.statsByVariableName, (stat, variableName) => {
- let condensedStat = pick(stat, fields);
- $set[`variables.${variableName}`] = condensedStat;
- });
- Creatures.update(creatureId, {$set});
- }
-}
diff --git a/app/imports/api/creature/computation/methods/recomputeCreature.js b/app/imports/api/creature/computation/methods/recomputeCreature.js
deleted file mode 100644
index 781304ae..00000000
--- a/app/imports/api/creature/computation/methods/recomputeCreature.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { ValidatedMethod } from 'meteor/mdg:validated-method';
-import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import SimpleSchema from 'simpl-schema';
-import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
-import ComputationMemo from '/imports/api/creature/computation/engine/ComputationMemo.js';
-import getComputationProperties from '/imports/api/creature/computation/engine/getComputationProperties.js';
-import computeMemo from '/imports/api/creature/computation/engine/computeMemo.js';
-import writeAlteredProperties from '/imports/api/creature/computation/engine/writeAlteredProperties.js';
-import writeCreatureVariables from '/imports/api/creature/computation/engine/writeCreatureVariables.js';
-import { recomputeDamageMultipliersById } from '/imports/api/creature/denormalise/recomputeDamageMultipliers.js';
-import recomputeSlotFullness from '/imports/api/creature/denormalise/recomputeSlotFullness.js';
-import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
-import getDependentProperties from '/imports/api/creature/computation/engine/getDependentProperties.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-
-export const recomputeCreature = new ValidatedMethod({
-
- name: 'creatures.recomputeCreature',
-
- validate: new SimpleSchema({
- charId: { type: String }
- }).validator(),
-
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 5,
- timeInterval: 5000,
- },
-
- run({charId}) {
- let creature = Creatures.findOne(charId);
- // Permission
- assertEditPermission(creature, this.userId);
- // Work, call this direcly if you are already in a method that has checked
- // for permission to edit a given character
- recomputeCreatureById(charId);
- },
-
-});
-
-export function recomputeCreatureById(creatureId){
- let creature = Creatures.findOne(creatureId);
- recomputeCreatureByDoc(creature);
-}
-
-/**
- * This function is the heart of DiceCloud. It recomputes a creature's stats,
- * distilling down effects and proficiencies into the final stats that make up
- * a creature.
- *
- * Essentially this is a depth first tree traversal algorithm that computes
- * stats' dependencies before computing stats themselves, while detecting
- * dependency loops.
- *
- * At the moment it makes no effort to limit recomputation to just what was
- * changed.
- *
- * Attempting to implement dependency management to limit recomputation to just
- * change affected stats should only happen as a last resort, when this function
- * can no longer be performed more efficiently, and server resources can not be
- * expanded to meet demand.
- *
- * A brief overview:
- * - Fetch the stats of the creature and add them to
- * an object for quick lookup
- * - Fetch the effects and proficiencies which apply to each stat and store them with the stat
- * - Fetch the class levels and store them as well
- * - Mark each stat and effect as uncomputed
- * - Iterate over each stat in order and compute it
- * - If the stat is already computed, skip it
- * - If the stat is busy being computed, we are in a dependency loop, make it NaN and mark computed
- * - Mark the stat as busy computing
- * - Iterate over each effect which applies to the attribute
- * - If the effect is not computed compute it
- * - If the effect relies on another attribute, get its computed value
- * - Recurse if that attribute is uncomputed
- * - apply the effect to the attribute
- * - Conglomerate all the effects to compute the final stat values
- * - Mark the stat as computed
- * - Write the computed results back to the database
- */
-export function recomputeCreatureByDoc(creature){
- const creatureId = creature._id;
- let props = getComputationProperties(creatureId);
- let computationMemo = new ComputationMemo(props, creature);
- computeMemo(computationMemo);
- writeAlteredProperties(computationMemo);
- writeCreatureVariables(computationMemo, creatureId);
- recomputeDamageMultipliersById(creatureId);
- recomputeSlotFullness(creatureId);
- return computationMemo;
-}
-
-export function recomputePropertyDependencies(property){
- let creature = getRootCreatureAncestor(property);
- recomputeCreatureByDependencies({
- creature,
- propertyIds: [property._id],
- propertiesDependedAponIds: property.dependencies,
- });
-}
-
-export function recomputeCreatureByDependencies({
- creature,
- propertyIds,
- propertiesDependedAponIds
-}){
- let props = getDependentProperties({
- creatureId: creature._id,
- propertyIds,
- propertiesDependedAponIds,
- });
- let computationMemo = new ComputationMemo(props, creature);
- computeMemo(computationMemo);
- writeAlteredProperties(computationMemo);
- writeCreatureVariables(computationMemo, creature._id, false)
- recomputeInactiveProperties(creature._id);
- return computationMemo;
-}
diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.js b/app/imports/api/creature/creatureProperties/CreatureProperties.js
index d690b620..b70d96d5 100644
--- a/app/imports/api/creature/creatureProperties/CreatureProperties.js
+++ b/app/imports/api/creature/creatureProperties/CreatureProperties.js
@@ -10,6 +10,14 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let CreatureProperties = new Mongo.Collection('creatureProperties');
let CreaturePropertySchema = new SimpleSchema({
+ _id: {
+ type: String,
+ regEx: SimpleSchema.RegEx.Id,
+ },
+ _migrationError: {
+ type: String,
+ optional: true,
+ },
type: {
type: String,
allowedValues: Object.keys(propertySchemasIndex),
@@ -38,12 +46,16 @@ let CreaturePropertySchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
+});
+
+const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({
// Denormalised flag if this property is inactive on the sheet for any reason
// Including being disabled, or a decendent of a disabled property
inactive: {
type: Boolean,
optional: true,
index: 1,
+ removeBeforeCompute: true,
},
// Denormalised flag if this property was made inactive by an inactive
// ancestor. True if this property has an inactive ancestor even if this
@@ -52,6 +64,7 @@ let CreaturePropertySchema = new SimpleSchema({
type: Boolean,
optional: true,
index: 1,
+ removeBeforeCompute: true,
},
// Denormalised flag if this property was made inactive because of its own
// state
@@ -59,6 +72,7 @@ let CreaturePropertySchema = new SimpleSchema({
type: Boolean,
optional: true,
index: 1,
+ removeBeforeCompute: true,
},
// Denormalised flag if this property was made inactive because of a toggle
// calculation. Either an ancestor toggle calculation or its own.
@@ -66,18 +80,12 @@ let CreaturePropertySchema = new SimpleSchema({
type: Boolean,
optional: true,
index: 1,
- },
- // Denormalised list of all properties or creatures this property depends on
- dependencies: {
- type: Array,
- defaultValue: [],
- index: 1,
- },
- 'dependencies.$': {
- type: String,
+ removeBeforeCompute: true,
},
});
+CreaturePropertySchema.extend(DenormalisedOnlyCreaturePropertySchema);
+
for (let key in propertySchemasIndex){
let schema = new SimpleSchema({});
schema.extend(propertySchemasIndex[key]);
@@ -91,10 +99,11 @@ for (let key in propertySchemasIndex){
}
import '/imports/api/creature/creatureProperties/methods/index.js';
-import '/imports/api/creature/actions/doAction.js';
-import '/imports/api/creature/actions/castSpellWithSlot.js';
+//import '/imports/api/creature/actions/doAction.js';
+//import '/imports/api/creature/actions/castSpellWithSlot.js';
export default CreatureProperties;
export {
+ DenormalisedOnlyCreaturePropertySchema,
CreaturePropertySchema,
};
diff --git a/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js b/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js
index 430e37fe..b9958fcb 100644
--- a/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js
+++ b/app/imports/api/creature/creatureProperties/methods/adjustQuantity.js
@@ -4,8 +4,7 @@ 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 recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const adjustQuantity = new ValidatedMethod({
name: 'creatureProperties.adjustQuantity',
@@ -33,8 +32,7 @@ const adjustQuantity = new ValidatedMethod({
// Changing quantity does not change dependencies, but recomputing the
// inventory changes many deps at once, so recompute fully
- recomputeCreatureByDoc(rootCreature);
- recomputeInventory(rootCreature._id);
+ computeCreature(rootCreature._id);
},
});
diff --git a/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js b/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js
index 2a67299c..6428eeb3 100644
--- a/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js
+++ b/app/imports/api/creature/creatureProperties/methods/damagePropertiesByName.js
@@ -5,7 +5,6 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
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 { recomputePropertyDependencies } from '/imports/api/creature/computation/methods/recomputeCreature.js';
const damagePropertiesByName = new ValidatedMethod({
name: 'CreatureProperties.damagePropertiesByName',
@@ -29,14 +28,13 @@ const damagePropertiesByName = new ValidatedMethod({
// Check permissions
let creature = Creatures.findOne(creatureId, {
fields: {
- damageMultipliers: 1,
+ variables: 1,
owner: 1,
readers: 1,
writers: 1,
},
});
assertEditPermission(creature, this.userId);
- let lastProperty;
CreatureProperties.find({
'ancestors.id': creatureId,
variableName,
@@ -48,9 +46,7 @@ const damagePropertiesByName = new ValidatedMethod({
if (!schema.allowsKey('damage')) return;
// Damage the property
damagePropertyWork({property, operation, value});
- lastProperty = property;
});
- if (lastProperty) recomputePropertyDependencies(lastProperty);
}
});
diff --git a/app/imports/api/creature/creatureProperties/methods/damageProperty.js b/app/imports/api/creature/creatureProperties/methods/damageProperty.js
index da6b64da..b05823a9 100644
--- a/app/imports/api/creature/creatureProperties/methods/damageProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/damageProperty.js
@@ -4,7 +4,7 @@ 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 { recomputePropertyDependencies } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import { computeCreatureDependencyGroup } from '/imports/api/engine/computeCreature.js';
const damageProperty = new ValidatedMethod({
name: 'creatureProperties.damage',
@@ -39,42 +39,42 @@ const damageProperty = new ValidatedMethod({
}
let result = damagePropertyWork({property, operation, value});
// Dependencies can't be changed through damage, only recompute deps
- recomputePropertyDependencies(property);
+ computeCreatureDependencyGroup(property);
return result;
},
});
export function damagePropertyWork({property, operation, value}){
+ let damage, newValue;
if (operation === 'set'){
- let currentValue = property.value;
+ const total = property.total;
// Set represents what we want the value to be after damage
// So we need the actual damage to get to that value
- let damage = currentValue - value;
+ damage = total - value;
// Damage can't exceed total value
- if (damage > currentValue) damage = currentValue;
+ if (damage > total) damage = total;
// Damage must be positive
if (damage < 0) damage = 0;
- CreatureProperties.update(property._id, {
- $set: {damage}
- }, {
- selector: property
- });
- return currentValue - damage;
+ newValue = property.total - damage;
} else if (operation === 'increment'){
- let currentValue = property.value - (property.damage || 0);
+ let currentValue = property.value;
let currentDamage = property.damage;
let increment = value;
// Can't increase damage above the remaining value
if (increment > currentValue) increment = currentValue;
// Can't decrease damage below zero
if (-increment > currentDamage) increment = -currentDamage;
- CreatureProperties.update(property._id, {
- $inc: {damage: increment}
- }, {
- selector: property
- });
- return increment;
+ damage = currentDamage + increment;
+ newValue = property.total - damage;
}
+
+ // Write the results
+ CreatureProperties.update(property._id, {
+ $set: {damage, value: newValue}
+ }, {
+ selector: property
+ });
+ return damage;
}
export default damageProperty;
diff --git a/app/imports/api/creature/creatureProperties/methods/dealDamage.js b/app/imports/api/creature/creatureProperties/methods/dealDamage.js
index ea3f3607..b93aa495 100644
--- a/app/imports/api/creature/creatureProperties/methods/dealDamage.js
+++ b/app/imports/api/creature/creatureProperties/methods/dealDamage.js
@@ -5,7 +5,7 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
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 { recomputeCreatureByDependencies } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const dealDamage = new ValidatedMethod({
name: 'creatureProperties.dealDamage',
@@ -25,7 +25,6 @@ const dealDamage = new ValidatedMethod({
// permissions
let creature = Creatures.findOne(creatureId, {
fields: {
- damageMultipliers: 1,
owner: 1,
readers: 1,
writers: 1,
@@ -33,41 +32,41 @@ const dealDamage = new ValidatedMethod({
});
assertEditPermission(creature, this.userId);
- // Get all the health bars and do damage to them
- let healthBars = CreatureProperties.find({
- 'ancestors.id': creatureId,
- 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);
- let damageLeft = totalDamage;
- if (damageType === 'healing') damageLeft = -totalDamage;
- let propertyIds = [];
- let propertiesDependedAponIds = [];
- healthBars.forEach(healthBar => {
- if (damageLeft === 0) return;
- let damageAdded = damagePropertyWork({
- property: healthBar,
- operation: 'increment',
- value: damageLeft,
- });
- damageLeft -= damageAdded;
- propertyIds.push(healthBar._id);
- propertiesDependedAponIds.push(...healthBar.dependencies);
- });
- recomputeCreatureByDependencies({
- creature,
- propertyIds,
- propertiesDependedAponIds,
- });
+ 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 60761aa8..03c505c2 100644
--- a/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/duplicateProperty.js
@@ -8,10 +8,8 @@ import {
setLineageOfDocs,
renewDocIds
} from '/imports/api/parenting/parenting.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
import { reorderDocs } from '/imports/api/parenting/order.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
var snackbar;
if (Meteor.isClient){
snackbar = require(
@@ -89,14 +87,8 @@ const duplicateProperty = new ValidatedMethod({
ancestorId: property.ancestors[0].id,
});
- // Inserting the active status of the property needs to be denormalised
- recomputeInactiveProperties(creature._id);
-
- // Recompute the inventory
- recomputeInventory(creature._id);
-
// Inserting a creature property invalidates dependencies: full recompute
- recomputeCreatureByDoc(creature);
+ 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 ba1a0444..24cb51fa 100644
--- a/app/imports/api/creature/creatureProperties/methods/equipItem.js
+++ b/app/imports/api/creature/creatureProperties/methods/equipItem.js
@@ -4,9 +4,7 @@ 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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.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';
@@ -49,9 +47,7 @@ const equipItem = new ValidatedMethod({
skipRecompute: true,
});
- recomputeInactiveProperties(creature._id);
- recomputeInventory(creature._id);
- recomputeCreatureByDoc(creature);
+ computeCreature(creature._id);
},
});
diff --git a/app/imports/api/creature/creatureProperties/methods/flipToggle.js b/app/imports/api/creature/creatureProperties/methods/flipToggle.js
new file mode 100644
index 00000000..e39d0c75
--- /dev/null
+++ b/app/imports/api/creature/creatureProperties/methods/flipToggle.js
@@ -0,0 +1,48 @@
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+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');
+ },
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 5,
+ timeInterval: 5000,
+ },
+ run({_id}) {
+ // Permission
+ let property = CreatureProperties.findOne(_id, {
+ fields: {type: 1, ancestors: 1, enabled: 1, disabled: 1}
+ });
+ if (property.type !== 'toggle'){
+ throw new Meteor.Error('wrong property',
+ 'This method can only be applied to toggles');
+ }
+ if (!property.enabled && !property.disabled){
+ throw new Meteor.Error('Computed toggle',
+ 'Can\'t flip a toggle that is computed')
+ }
+ let rootCreature = getRootCreatureAncestor(property);
+ assertEditPermission(rootCreature, this.userId);
+
+ // 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);
+ },
+});
+
+export default flipToggle;
diff --git a/app/imports/api/creature/creatureProperties/methods/index.js b/app/imports/api/creature/creatureProperties/methods/index.js
index 72fe3da3..45a0e323 100644
--- a/app/imports/api/creature/creatureProperties/methods/index.js
+++ b/app/imports/api/creature/creatureProperties/methods/index.js
@@ -12,3 +12,4 @@ import '/imports/api/creature/creatureProperties/methods/restoreProperty.js';
import '/imports/api/creature/creatureProperties/methods/selectAmmoItem.js';
import '/imports/api/creature/creatureProperties/methods/softRemoveProperty.js';
import '/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js';
+import '/imports/api/creature/creatureProperties/methods/flipToggle.js';
diff --git a/app/imports/api/creature/creatureProperties/methods/insertProperty.js b/app/imports/api/creature/creatureProperties/methods/insertProperty.js
index 1dec5491..4535e1fe 100644
--- a/app/imports/api/creature/creatureProperties/methods/insertProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/insertProperty.js
@@ -5,9 +5,7 @@ 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 recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.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';
@@ -140,15 +138,8 @@ export function insertPropertyWork({property, creature}){
collection: CreatureProperties,
ancestorId: creature._id,
});
- // Inserting the active status of the property needs to be denormalised
- recomputeInactiveProperties(creature._id);
-
- // Recompute the inventory if it has changed
- if (property.type === 'item' || property.type === 'container'){
- recomputeInventory(creature._id);
- }
// Inserting a creature property invalidates dependencies: full recompute
- recomputeCreatureByDoc(creature);
+ 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 39d410c0..e73cbe33 100644
--- a/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js
+++ b/app/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js
@@ -5,8 +5,7 @@ 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 recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import {
setLineageOfDocs,
@@ -15,7 +14,6 @@ import {
} from '/imports/api/parenting/parenting.js';
import { reorderDocs } from '/imports/api/parenting/order.js';
import { setDocToLastOrder } from '/imports/api/parenting/order.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
const insertPropertyFromLibraryNode = new ValidatedMethod({
@@ -74,12 +72,8 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
ancestorId: rootCreature._id,
});
- // The library properties need to denormalise which of them are inactive
- recomputeInactiveProperties(rootCreature._id);
- // Some of the library properties may be items or containers
- recomputeInventory(rootCreature._id);
// Inserting a creature property invalidates dependencies: full recompute
- recomputeCreatureByDoc(rootCreature);
+ computeCreature(rootCreature._id);
// Return the docId of the last property, the inserted root property
return rootId;
},
@@ -116,7 +110,7 @@ function insertPropertyFromNode(nodeId, ancestors, order){
nodes = [node, ...nodes];
// set libraryNodeIds
- storeLibraryNodeReferences(nodes, nodeId);
+ storeLibraryNodeReferences(nodes);
// re-map all the ancestors
setLineageOfDocs({
@@ -149,6 +143,7 @@ function insertPropertyFromNode(nodeId, ancestors, order){
function storeLibraryNodeReferences(nodes){
nodes.forEach(node => {
+ if (node.libraryNodeId) return;
node.libraryNodeId = node._id;
});
}
@@ -162,20 +157,11 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
// Filter out the reference nodes we replace
let resultingNodes = nodes.filter(node => {
-
- // We have already visited this ref and replaced it
- if (visitedRefs.has(node._id)) return false;
-
- // Already replaced an ancestor node
- for (let i; i < node.ancestors.length; i++){
- if (visitedRefs.has(node.ancestors[i].id)) return false;
- }
-
// This isn't a reference node, continue as normal
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'};
return true;
@@ -211,26 +197,31 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
oldParent: referencedNode.parent,
});
- // Remove all the looped references and descendents from the new nodes
- // We can't rely on the reify recursion to do this, since the IDs are
- // getting renewed before it is called
- addedNodes = addedNodes.filter(node => {
- // Exclude removed referenced
- if (visitedRefs.has(node._id)) return false;
-
- // Exclude descendants of removed references
- for (let i; i < node.ancestors.length; i++){
- if (visitedRefs.has(node.ancestors[i].id)) return false;
+ // Filter all the looped references
+ addedNodes = addedNodes.filter(addedNode => {
+ // Add all non-reference nodes
+ if (addedNode.type !== 'reference'){
+ return true;
+ }
+ // If this exact reference has already been resolved before, filter it out
+ if (visitedRefs.has(addedNode._id)){
+ return false;
+ } else {
+ // Otherwise mark it as visited, and keep it
+ visitedRefs.add(addedNode._id);
+ return true;
}
- return true;
});
- // TODO: Force the referencedNode to take the old id of the reference
- // such that the reference's children can be kept
+ // Before renewing Ids make sure the library node reference is stored
+ storeLibraryNodeReferences(addedNodes);
// Give the new referenced sub-tree new ids
+ // The referenced node must get the id of the ref node so that the
+ // descendants of the ref node keep their ancestry intact
renewDocIds({
docArray: addedNodes,
+ idMap: { [referencedNode._id]: node._id },
});
// Reify the subtree as well with recursion
diff --git a/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js b/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js
index a5ad2929..fa2acf78 100644
--- a/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/pullFromProperty.js
@@ -3,7 +3,7 @@ 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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const pullFromProperty = new ValidatedMethod({
name: 'creatureProperties.pull',
@@ -28,7 +28,7 @@ const pullFromProperty = new ValidatedMethod({
});
// TODO figure out if this method can change deps or not
- recomputeCreatureByDoc(rootCreature);
+ computeCreature(rootCreature._id);
// recomputePropertyDependencies(property);
}
});
diff --git a/app/imports/api/creature/creatureProperties/methods/pushToProperty.js b/app/imports/api/creature/creatureProperties/methods/pushToProperty.js
index c1054490..e730065c 100644
--- a/app/imports/api/creature/creatureProperties/methods/pushToProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/pushToProperty.js
@@ -3,7 +3,7 @@ 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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
import { get } from 'lodash';
const pushToProperty = new ValidatedMethod({
@@ -45,8 +45,7 @@ const pushToProperty = new ValidatedMethod({
});
// TODO figure out if this method can change deps or not
- recomputeCreatureByDoc(rootCreature);
- // recomputePropertyDependencies(property);
+ computeCreature(rootCreature._id);
}
});
diff --git a/app/imports/api/creature/creatureProperties/methods/restoreProperty.js b/app/imports/api/creature/creatureProperties/methods/restoreProperty.js
index 864bdf36..b612e6c7 100644
--- a/app/imports/api/creature/creatureProperties/methods/restoreProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/restoreProperty.js
@@ -5,9 +5,7 @@ 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 recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const restoreProperty = new ValidatedMethod({
name: 'creatureProperties.restore',
@@ -28,12 +26,8 @@ const restoreProperty = new ValidatedMethod({
// Do work
restore({_id, collection: CreatureProperties});
- // Items and containers might be restored
- recomputeInventory(rootCreature._id);
- // Parents active status may have changed while it was deleted
- recomputeInactiveProperties(rootCreature._id);
// Changes dependency tree by restoring children
- recomputeCreatureByDoc(rootCreature);
+ computeCreature(rootCreature._id);
}
});
diff --git a/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js b/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js
index 3c735ca1..c1e4baa3 100644
--- a/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js
+++ b/app/imports/api/creature/creatureProperties/methods/selectAmmoItem.js
@@ -4,7 +4,7 @@ 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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const selectAmmoItem = new ValidatedMethod({
name: 'creatureProperties.selectAmmoItem',
@@ -45,7 +45,7 @@ const selectAmmoItem = new ValidatedMethod({
// 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
- recomputeCreatureByDoc(rootCreature);
+ computeCreature(rootCreature._id);
},
});
diff --git a/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js b/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js
index 328d3d8e..86df9247 100644
--- a/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/softRemoveProperty.js
@@ -5,8 +5,7 @@ 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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const softRemoveProperty = new ValidatedMethod({
name: 'creatureProperties.softRemove',
@@ -27,10 +26,8 @@ const softRemoveProperty = new ValidatedMethod({
// Do work
softRemove({_id, collection: CreatureProperties});
- // Potentially changes items and containers
- recomputeInventory(rootCreature._id);
// Changes dependency tree by removing children
- recomputeCreatureByDoc(rootCreature);
+ computeCreature(rootCreature._id);
}
});
diff --git a/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js b/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js
index 19ff8047..d53ddc6e 100644
--- a/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js
+++ b/app/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js
@@ -3,9 +3,7 @@ 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 { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const updateCreatureProperty = new ValidatedMethod({
name: 'creatureProperties.update',
@@ -47,20 +45,9 @@ const updateCreatureProperty = new ValidatedMethod({
selector: {type: property.type},
});
- // Some updates might cause other properties to become inactive
- if ([
- 'applied', 'equipped', 'prepared', 'alwaysPrepared', 'disabled'
- ].includes(path[0])){
- recomputeInactiveProperties(rootCreature._id);
- }
-
- if (property.type === 'item' || property.type === 'container'){
- // Potentially changes items and containers
- recomputeInventory(rootCreature._id);
- }
// Updating a property is likely to change dependencies, do a full recompute
// denormalised stats might change, so fetch the creature again
- recomputeCreatureById(rootCreature._id);
+ computeCreature(rootCreature._id);
},
});
diff --git a/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js b/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js
index aa71e48f..cee1237d 100644
--- a/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js
+++ b/app/imports/api/creature/creatureProperties/recomputeCreaturesByProperty.js
@@ -1,4 +1,4 @@
-import { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
/**
* Recomputes all ancestor creatures of this property
@@ -6,7 +6,7 @@ import { recomputeCreatureById } from '/imports/api/creature/computation/methods
export default function recomputeCreaturesByProperty(property){
for (let ref of property.ancestors){
if (ref.collection === 'creatures') {
- recomputeCreatureById.call(ref.id);
+ computeCreature.call(ref.id);
}
}
}
diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js
index f4a07dba..375d29b9 100644
--- a/app/imports/api/creature/creatures/Creatures.js
+++ b/app/imports/api/creature/creatures/Creatures.js
@@ -100,35 +100,6 @@ let CreatureSchema = new SimpleSchema({
type: SimpleSchema.Integer,
defaultValue: 0,
},
- // Inventory
- 'denormalizedStats.weightTotal': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.weightEquipment': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.weightCarried': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.valueTotal': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.valueEquipment': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.valueCarried': {
- type: Number,
- defaultValue: 0,
- },
- 'denormalizedStats.itemsAttuned': {
- type: Number,
- defaultValue: 0,
- },
// Version of computation engine that was last used to compute this creature
computeVersion: {
type: String,
@@ -175,6 +146,7 @@ Creatures.attachSchema(CreatureSchema);
import '/imports/api/creature/creatures/methods/index.js';
+import '/imports/api/engine/actions/doAction.js';
export default Creatures;
export { CreatureSchema };
diff --git a/app/imports/api/creature/creatures/defaultCharacterProperties.js b/app/imports/api/creature/creatures/defaultCharacterProperties.js
index a38f24ea..dd3d5499 100644
--- a/app/imports/api/creature/creatures/defaultCharacterProperties.js
+++ b/app/imports/api/creature/creatures/defaultCharacterProperties.js
@@ -10,10 +10,10 @@ export default function defaultCharacterProperties(creatureId){
{
type: 'propertySlot',
name: 'Ruleset',
- description: '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, your sheet will be empty.'},
slotTags: ['base'],
tags: [],
- quantityExpected: 1,
+ quantityExpected: {calculation: '1'},
hideWhenFull: true,
spaceLeft: 1,
totalFilled: 0,
diff --git a/app/imports/api/creature/creatures/methods/restCreature.js b/app/imports/api/creature/creatures/methods/restCreature.js
index a0464a00..07a607ee 100644
--- a/app/imports/api/creature/creatures/methods/restCreature.js
+++ b/app/imports/api/creature/creatures/methods/restCreature.js
@@ -4,7 +4,7 @@ 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 { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const restCreature = new ValidatedMethod({
name: 'creature.methods.longRest',
@@ -109,7 +109,7 @@ const restCreature = new ValidatedMethod({
});
});
}
- recomputeCreatureById(creatureId);
+ computeCreature(creatureId);
},
});
diff --git a/app/imports/api/creature/denormalise/recomputeDamageMultipliers.js b/app/imports/api/creature/denormalise/recomputeDamageMultipliers.js
deleted file mode 100644
index b31b1ece..00000000
--- a/app/imports/api/creature/denormalise/recomputeDamageMultipliers.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { ValidatedMethod } from 'meteor/mdg:validated-method';
-import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import SimpleSchema from 'simpl-schema';
-import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-
-export const recomputeDamageMultipliers = new ValidatedMethod({
-
- name: 'creatures.recomputeDamageMultipliers',
-
- validate: new SimpleSchema({
- creatureId: { type: String }
- }).validator(),
-
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 5,
- timeInterval: 5000,
- },
-
- run({creatureId}) {
- // Permission
- assertEditPermission(creatureId, this.userId);
- // Work, call this direcly if you are already in a method that has checked
- // for permission to edit a given character
- recomputeDamageMultipliersById(creatureId);
- },
-
-});
-
-export function recomputeDamageMultipliersById(creatureId){
- if (!creatureId) throw 'Creature ID is required';
- let props = CreatureProperties.find({
- 'ancestors.id': creatureId,
- type: 'damageMultiplier',
- removed: {$ne: true},
- inactive: {$ne: true},
- }, {
- sort: {order: 1}
- });
-
- // Count of how many weakness, resistances and immunities each damage type has
- let multipliersByName = {};
- props.forEach(dm => {
- dm.damageTypes.forEach(damageType => {
- if (!multipliersByName[damageType]){
- multipliersByName[damageType] = {
- weaknesses: 0,
- resistances: 0,
- immunities: 0,
- };
- }
- if (dm.value === 0){
- multipliersByName[damageType].immunities++;
- } else if (dm.value === 0.5){
- multipliersByName[damageType].resistances++;
- } else if (dm.value === 2){
- multipliersByName[damageType].weaknesses++;
- }
- });
- });
- // Make an Object with keys of all the damage types that have a resulting
- // immunity, weakness, or resistance
- let damageMultipliers = {};
- for (let damageType in multipliersByName){
- let multiplier = multipliersByName[damageType];
- if (multiplier.immunities){
- damageMultipliers[damageType] = 0;
- } else if (multiplier.resistances && !multiplier.weaknesses){
- damageMultipliers[damageType] = 0.5;
- } else if (multiplier.weaknesses && !multiplier.resistances){
- damageMultipliers[damageType] = 2;
- }
- }
- // Store the Object on the creature document
- Creatures.update(creatureId, {$set: {damageMultipliers}});
-}
diff --git a/app/imports/api/creature/denormalise/recomputeInactiveProperties.js b/app/imports/api/creature/denormalise/recomputeInactiveProperties.js
deleted file mode 100644
index 8cff24a5..00000000
--- a/app/imports/api/creature/denormalise/recomputeInactiveProperties.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-
-export default function recomputeInactiveProperties(ancestorId){
- let disabledFilter = {
- 'ancestors.id': ancestorId,
- $or: [
- {disabled: true}, // Everything can be disabled
- {type: 'buff', applied: false}, // Buffs can be applied
- {type: 'item', equipped: {$ne: true}},
- {type: 'spell', prepared: {$ne: true}, alwaysPrepared: {$ne: true}},
- ],
- };
- let disabledIds = CreatureProperties.find(disabledFilter, {
- fields: {_id: 1},
- }).map(prop => prop._id);
-
- // Deactivate relevant properties
- // Inactive properties
- CreatureProperties.update({
- 'ancestors.id': ancestorId,
- '_id': {$in: disabledIds},
- $or: [
- {inactive: {$ne: true}},
- {deactivatedBySelf: {$ne: true}},
- {deactivatedByAncestor: true},
- ],
- }, {
- $set: {
- inactive: true,
- deactivatedBySelf: true,
- },
- $unset: {deactivatedByAncestor: 1},
- }, {
- multi: true,
- selector: {type: 'any'},
- });
- // Decendants of inactive properties
- CreatureProperties.update({
- 'ancestors.id': {$eq: ancestorId, $in: disabledIds},
- $or: [
- {inactive: {$ne: true}},
- {deactivatedByAncestor: {$ne: true}},
- ],
- }, {
- $set: {
- inactive: true,
- deactivatedByAncestor: true,
- },
- }, {
- multi: true,
- selector: {type: 'any'},
- });
-
- // Remove inactive from all the properties that are inactive but shouldn't be
- CreatureProperties.update({
- 'ancestors.id': {$eq: ancestorId, $nin: disabledIds},
- '_id': {$nin: disabledIds},
- // if it was a toggle responsible, we leave it alone
- deactivatedByToggle: {$ne: true},
- $or: [
- {inactive: true},
- {deactivatedByAncestor: true},
- {deactivatedBySelf: true}
- ],
- }, {
- $unset: {
- inactive: 1,
- deactivatedByAncestor: 1,
- deactivatedBySelf: 1,
- },
- }, {
- multi: true,
- selector: {type: 'any'},
- });
-}
diff --git a/app/imports/api/creature/denormalise/recomputeInventory.js b/app/imports/api/creature/denormalise/recomputeInventory.js
deleted file mode 100644
index 3bf7d1a2..00000000
--- a/app/imports/api/creature/denormalise/recomputeInventory.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import nodesToTree from '/imports/api/parenting/nodesToTree.js';
-
-export default function recomputeInventory(creatureId){
- let inventoryForest = nodesToTree({
- collection: CreatureProperties,
- ancestorId: creatureId,
- filter: {
- type: {$in: ['container', 'item']},
- },
- deactivatedByAncestor: {$ne: true},
- });
- let containersToWrite = [];
- let data = getChildrenInventoryData(inventoryForest, containersToWrite);
- containersToWrite.forEach(container => {
- CreatureProperties.update(container._id, {$set: {
- contentsWeight: container.contentsWeight,
- contentsValue: container.contentsValue,
- }}, {selector: {type: 'container'}});
- });
- Creatures.update(creatureId, {$set: {
- 'denormalizedStats.weightTotal': data.weightTotal,
- 'denormalizedStats.weightEquipment': data.weightEquipment,
- 'denormalizedStats.weightCarried': data.weightCarried,
- 'denormalizedStats.valueTotal': data.valueTotal,
- 'denormalizedStats.valueEquipment': data.valueEquipment,
- 'denormalizedStats.valueCarried': data.valueCarried,
- 'denormalizedStats.itemsAttuned': data.itemsAttuned,
- }});
- return data;
-}
-
-function getChildrenInventoryData(forest, containersToWrite){
- let data = {
- weightTotal: 0,
- weightEquipment: 0,
- weightCarried: 0,
- valueTotal: 0,
- valueEquipment: 0,
- valueCarried: 0,
- itemsAttuned: 0,
- }
- forest.forEach(tree => {
- let treeData = getInventoryData(tree, containersToWrite);
- for (let key in data){
- data[key] += treeData[key] || 0;
- }
- });
- return data;
-}
-
-function getInventoryData(tree, containersToWrite){
- let data = {
- weightTotal: 0,
- weightEquipment: 0,
- weightCarried: 0,
- valueTotal: 0,
- valueEquipment: 0,
- valueCarried: 0,
- itemsAttuned: 0,
- }
- let childData = getChildrenInventoryData(tree.children, containersToWrite);
- let node = tree.node;
- if (node.type === 'container'){
- data.weightTotal += node.weight || 0;
- data.valueTotal += node.value || 0;
- data.weightCarried += node.weight || 0;
- data.valueCarried += node.value || 0;
- storeContentsData(node, childData, containersToWrite);
- } else if (node.type === 'item'){
- data.weightTotal += (node.weight * node.quantity) || 0;
- data.valueTotal += (node.value * node.quantity) || 0;
- data.weightCarried += (node.weight * node.quantity) || 0;
- data.valueCarried += (node.value * node.quantity) || 0;
- if (node.equipped){
- data.weightEquipment += (node.weight * node.quantity) || 0;
- data.valueEquipment += (node.value * node.quantity) || 0;
- }
- if (node.attuned){
- data.itemsAttuned += 1;
- }
- }
- for (let key in data){
- data[key] += childData[key];
- }
- if (node.contentsWeightless){
- data.weightCarried = node.weight;
- }
- if (node.carried === false){
- data.weightCarried = 0;
- data.valueCarried = 0;
- }
- return data
-}
-
-function storeContentsData(node, childData, containersToWrite){
- let newContentsWeight = childData.weightCarried
- if (node.contentsWeight !== newContentsWeight){
- node.contentsWeight = newContentsWeight;
- node.contentsWeightChanged = true;
- }
- let newContentsValue = childData.valueCarried;
- if (node.contentsValue !== newContentsValue){
- node.contentsValue = newContentsValue;
- node.contentsValueChanged = true;
- }
- if (node.contentsWeightChanged || node.contentsValueChanged){
- containersToWrite.push(node);
- }
-}
diff --git a/app/imports/api/creature/denormalise/recomputeSlotFullness.js b/app/imports/api/creature/denormalise/recomputeSlotFullness.js
deleted file mode 100644
index a99731c7..00000000
--- a/app/imports/api/creature/denormalise/recomputeSlotFullness.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-// n + 1 database queries + n potential updates for n slots. Could be sped up.
-export default function recomputeSlotFullness(ancestorId){
- CreatureProperties.find({
- 'ancestors.id': ancestorId,
- type: 'propertySlot',
- }).forEach(slot => {
- let children = CreatureProperties.find({
- 'parent.id': slot._id,
- removed: {$ne: true},
- }, {
- fields: {
- slotQuantityFilled: 1,
- type: 1
- }
- }).fetch();
- let totalFilled = 0;
- children.forEach(child => {
- if (child.type === 'slotFiller'){
- totalFilled += child.slotQuantityFilled;
- } else {
- totalFilled++;
- }
- });
- let spaceLeft;
- let expected = slot.quantityExpectedResult;
- if (typeof expected !== 'number'){
- expected = 1;
- }
- if (expected === 0){
- spaceLeft = null;
- } else {
- spaceLeft = expected - totalFilled;
- }
- if (slot.totalFilled !== totalFilled || slot.spaceLeft !== spaceLeft){
- CreatureProperties.update(slot._id, {
- $set: {totalFilled, spaceLeft},
- }, {
- selector: {type: 'propertySlot'}
- });
- }
- });
-}
diff --git a/app/imports/api/creature/experience/Experiences.js b/app/imports/api/creature/experience/Experiences.js
index 100e87c3..5f3a8ea2 100644
--- a/app/imports/api/creature/experience/Experiences.js
+++ b/app/imports/api/creature/experience/Experiences.js
@@ -3,7 +3,7 @@ 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 { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let Experiences = new Mongo.Collection('experiences');
@@ -175,7 +175,7 @@ const recomputeExperiences = new ValidatedMethod({
'denormalizedStats.xp': xp,
'denormalizedStats.milestoneLevels': milestoneLevels
}});
- recomputeCreatureById(creatureId);
+ computeCreature(creatureId);
},
});
diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js
index 5b10a938..9ada6cf2 100644
--- a/app/imports/api/creature/log/CreatureLogs.js
+++ b/app/imports/api/creature/log/CreatureLogs.js
@@ -4,11 +4,8 @@ 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 {
- parse,
- CompilationContext,
- 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';
@@ -121,11 +118,12 @@ export function insertCreatureLogWork({log, creature, method}){
if (typeof log === 'string'){
log = {content: [{value: log}]};
}
+ if (!log.content?.length) return;
log.date = new Date();
// Insert it
let id = CreatureLogs.insert(log);
if (Meteor.isServer){
- method.unblock();
+ method?.unblock();
removeOldLogs(creature._id);
logWebhook({log, creature});
}
@@ -174,26 +172,29 @@ const logRoll = new ValidatedMethod({
logContent.push({name: 'Parse Error', value: error});
}
if (parsedResult) try {
- let rollContext = new CompilationContext();
- let compiled = parsedResult.compile(creature.variables, rollContext);
- let compiledString = compiled.toString();
+ let {
+ result: compiled,
+ context
+ } = resolve('compile', parsedResult, creature.variables);
+ const compiledString = toString(compiled);
if (!equalIgnoringWhitespace(compiledString, roll)) logContent.push({
value: roll
});
logContent.push({
value: compiledString
});
- let rolled = compiled.roll(creature.variables, rollContext);
- let rolledString = rolled.toString();
+ let {result: rolled} = resolve('roll', compiled, creature.variables, context);
+ let rolledString = toString(rolled);
if (rolledString !== compiledString) logContent.push({
value: rolled.toString()
});
- let result = rolled.reduce(creature.variables, rollContext);
- let resultString = result.toString();
+ let {result} = resolve('reduce', rolled, creature.variables, context);
+ let resultString = toString(result);
if (resultString !== rolledString) logContent.push({
value: resultString
});
} catch (e){
+ console.error(e);
logContent = [{name: 'Calculation error'}];
}
const log = {
diff --git a/app/imports/api/creature/mixins/recomputeCreatureMixin.js b/app/imports/api/creature/mixins/recomputeCreatureMixin.js
index 05c0abde..a6b991a7 100644
--- a/app/imports/api/creature/mixins/recomputeCreatureMixin.js
+++ b/app/imports/api/creature/mixins/recomputeCreatureMixin.js
@@ -1,4 +1,4 @@
-import { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
export default function recomputeCreatureMixin(methodOptions){
let runFunc = methodOptions.run;
@@ -10,7 +10,7 @@ export default function recomputeCreatureMixin(methodOptions){
) {
return result;
}
- recomputeCreatureById(charId);
+ computeCreature(charId);
return result;
};
return methodOptions;
diff --git a/app/imports/api/engine/actions/applyProperty.js b/app/imports/api/engine/actions/applyProperty.js
new file mode 100644
index 00000000..32542390
--- /dev/null
+++ b/app/imports/api/engine/actions/applyProperty.js
@@ -0,0 +1,27 @@
+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 damage from './applyPropertyByType/applyDamage.js';
+import note from './applyPropertyByType/applyNote.js';
+import roll from './applyPropertyByType/applyRoll.js';
+import savingThrow from './applyPropertyByType/applySavingThrow.js';
+import toggle from './applyPropertyByType/applyToggle.js';
+
+const applyPropertyByType = {
+ action,
+ adjustment,
+ branch,
+ buff,
+ damage,
+ note,
+ roll,
+ savingThrow,
+ spell: action,
+ toggle,
+};
+
+export default function applyProperty(node, opts, ...rest){
+ opts.scope[`#${node.node.type}`] = node.node;
+ return applyPropertyByType[node.node.type]?.(node, opts, ...rest);
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js
new file mode 100644
index 00000000..2f5a5be0
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js
@@ -0,0 +1,240 @@
+import recalculateInlineCalculations from './shared/recalculateInlineCalculations.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+import rollDice from '/imports/parser/rollDice.js';
+import applyProperty from '../applyProperty.js';
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js';
+import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
+
+export default function applyAction(node, {creature, targets, scope, log}){
+ const prop = node.node;
+ if (prop.target === 'self') targets = [creature];
+
+ // Log the name and description
+ let content = { name: prop.name };
+ if (prop.description?.text){
+ recalculateInlineCalculations(prop.description, scope, log);
+ content.value = prop.description.value;
+ }
+ if (content.name || content.value){
+ log.content.push(content);
+ }
+
+ // Spend the resources
+ const failed = spendResources({prop, log, scope});
+ if (failed) return;
+
+ // Attack if there is an attack roll
+ if (prop.attackRoll && prop.attackRoll.calculation){
+ if (targets.length){
+ targets.forEach(target => {
+ applyAttackToTarget({prop, target, scope, log});
+ // Apply the children, but only to the current target
+ applyChildren(node, {targets: [target], scope, log});
+ });
+ } else {
+ applyAttackWithoutTarget({prop, scope, log});
+ applyChildren(node, {creature, targets, scope, log});
+ }
+ } else {
+ applyChildren(node, {creature, targets, scope, log});
+ }
+}
+
+function applyAttackWithoutTarget({prop, scope, log}){
+ delete scope['$attackHit'];
+ delete scope['$attackMiss'];
+ delete scope['$criticalHit'];
+ delete scope['$criticalMiss'];
+ delete scope['$attackRoll'];
+
+ recalculateCalculation(prop.attackRoll, scope, log);
+
+ let value = rollDice(1, 20)[0];
+ scope['$attackRoll'] = {value};
+ let criticalHitTarget = scope.criticalHitTarget?.value || 20;
+ let criticalHit = value >= criticalHitTarget;
+ if (criticalHit){
+ scope['$criticalHit'] = {value: true};
+ scope['$attackHit'] = {value: true};
+ } else {
+ let criticalMiss = value === 1;
+ if (criticalMiss){
+ scope['$criticalMiss'] = 1;
+ log.content.push({
+ name: 'Critical Miss!',
+ });
+ scope['$attackMiss'] = {value: true};
+ } else {
+ // Untargeted attacks hit by default
+ scope['$attackHit'] = {value: true}
+ }
+ }
+ let result = value + prop.attackRoll.value;
+ scope['$attackRoll'] = {value: result};
+ log.content.push({
+ name: criticalHit ? 'Critical Hit!' : 'To Hit',
+ value: `1d20 [${value}] + ${prop.attackRoll.value} = ` + result,
+ });
+}
+
+function applyAttackToTarget({prop, target, scope, log}){
+ delete scope['$attackHit'];
+ delete scope['$attackMiss'];
+ delete scope['$criticalHit'];
+ delete scope['$criticalMiss'];
+ delete scope['$attackDiceRoll'];
+ delete scope['$attackRoll'];
+
+ recalculateCalculation(prop.attackRoll, scope, log);
+
+ const value = rollDice(1, 20)[0];
+ scope['$attackDiceRoll'] = {value};
+ const criticalHitTarget = scope.criticalHitTarget?.value || 20;
+ const criticalHit = value >= criticalHitTarget;
+ const criticalMiss = value === 1;
+ if (criticalHit) scope['$criticalHit'] = {value: true};
+ if (criticalMiss) scope['$criticalMiss'] = {value: true};
+ const result = value + prop.attackRoll.value;
+ scope['$attackRoll'] = {value: result};
+ if (target.variables.armor){
+ const armor = target.variables.armor.value;
+ const name = criticalHit ? 'Critical Hit!' :
+ criticalMiss ? 'Critical miss!' :
+ result > armor ? 'Hit!' :
+ 'Miss!'
+ log.content.push({
+ name,
+ value: `1d20 {${value}} + ${prop.attackRoll.value} = ` + result,
+ });
+ if ((result > armor) || (criticalHit)){
+ scope['$attackHit'] = true;
+ } else {
+ scope['$attackMiss'] = true;
+ }
+ } else {
+ log.content.push({
+ name: 'Error',
+ value:'Target has no `armor`',
+ });
+ log.content.push({
+ name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical miss!' : 'To Hit',
+ value: `1d20 {${value}} + ${prop.attackRoll.value} = ` + result,
+ });
+ }
+}
+
+function applyChildren(node, args){
+ node.children.forEach(child => applyProperty(child, args));
+}
+
+function spendResources({prop, log, scope}){
+ // Check Uses
+ if (prop.usesLeft < 0){
+ log.content.push({
+ name: 'Error',
+ value: `${prop.name || 'action'} does not have enough uses left`,
+ });
+ return true;
+ }
+ // Resources
+ if (prop.insufficientResources){
+ log.content.push({
+ name: 'Error',
+ value: 'This creature doesn\'t have sufficient resources to perform this action',
+ });
+ return true;
+ }
+ // Items
+ let itemQuantityAdjustments = [];
+ let spendLog = [];
+ let gainLog = [];
+ try {
+ prop.resources.itemsConsumed.forEach(itemConsumed => {
+ recalculateCalculation(itemConsumed.quantity, scope, log);
+ if (!itemConsumed.itemId){
+ throw 'No ammo was selected for this prop';
+ }
+ let item = CreatureProperties.findOne(itemConsumed.itemId);
+ if (!item || item.ancestors[0].id !== prop.ancestors[0].id){
+ throw 'The prop\'s ammo was not found on the creature';
+ }
+ if (!item.equipped){
+ throw 'The selected ammo is not equipped';
+ }
+ if (
+ !itemConsumed.quantity.value ||
+ !isFinite(itemConsumed.quantity.value)
+ ) return;
+ itemQuantityAdjustments.push({
+ property: item,
+ operation: 'increment',
+ value: itemConsumed.quantity.value,
+ });
+ let logName = item.name;
+ if (itemConsumed.quantity.value > 1 || itemConsumed.quantity.value < -1){
+ logName = item.plural || logName;
+ }
+ if (itemConsumed.quantity.value > 0){
+ spendLog.push(logName + ': ' + itemConsumed.quantity.value);
+ } else if (itemConsumed.quantity.value < 0){
+ gainLog.push(logName + ': ' + -itemConsumed.quantity.value);
+ }
+ });
+ } catch (e){
+ log.content.push({
+ name: 'Error',
+ value: e,
+ });
+ return true;
+ }
+ // No more errors should be thrown after this line
+ // Now that we have confirmed that there are no errors, do actual work
+ //Items
+ itemQuantityAdjustments.forEach(adjustQuantityWork);
+
+ // Use uses
+ if (prop.usesLeft){
+ CreatureProperties.update(prop._id, {
+ $inc: {usesUsed: 1}
+ }, {
+ selector: prop
+ });
+ log.content.push({
+ name: 'Uses left',
+ value: prop.usesLeft - (prop.usesUsed || 0) - 1,
+ });
+ }
+
+ // Damage stats
+ prop.resources.attributesConsumed.forEach(attConsumed => {
+ recalculateCalculation(attConsumed.quantity, scope, log);
+
+ if (!attConsumed.quantity?.value) return;
+ let stat = scope[attConsumed.variableName];
+ if (!stat){
+ spendLog.push(stat.name + ': ' + ' not found');
+ return;
+ }
+ damagePropertyWork({
+ property: stat,
+ operation: 'increment',
+ value: attConsumed.quantity.value,
+ });
+ if (attConsumed.quantity.value > 0){
+ spendLog.push(stat.name + ': ' + attConsumed.quantity.value);
+ } else if (attConsumed.quantity.value < 0){
+ gainLog.push(stat.name + ': ' + -attConsumed.quantity.value);
+ }
+ });
+
+ // Log all the spending
+ if (gainLog.length) log.content.push({
+ name: 'Gained',
+ value: gainLog.join('\n'),
+ });
+ if (spendLog.length) log.content.push({
+ name: 'Spent',
+ value: spendLog.join('\n'),
+ });
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js b/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js
new file mode 100644
index 00000000..bea1c945
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js
@@ -0,0 +1,57 @@
+import applyProperty from '../applyProperty.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
+
+export default function applyAdjustment(node, {
+ creature, targets, scope, log
+}){
+ const prop = node.node;
+ const damageTargets = prop.target === 'self' ? [creature] : targets;
+
+ if (!prop.amount) {
+ return applyChildren(node, {creature, targets, scope, log});
+ }
+
+ // Evaluate the amount
+ recalculateCalculation(prop.amount, scope, log);
+
+ const value = +prop.amount.value;
+ if (!isFinite(value)) {
+ return applyChildren(node, {creature, targets, scope, log});
+ }
+
+ if (damageTargets?.length) {
+ damageTargets.forEach(target => {
+ let stat = target.variables[prop.stat];
+ if (!stat) {
+ log({
+ name: 'Error',
+ value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`
+ });
+ return applyChildren(node, {creature, targets, scope, log});
+ }
+ damagePropertyWork({
+ property: stat,
+ operation: prop.operation,
+ value: value,
+ });
+ log.content.push({
+ name: 'Attribute damage',
+ value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
+ ` ${value}`,
+ });
+ });
+ } else {
+ log.content.push({
+ name: 'Attribute damage',
+ value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` +
+ ` ${value}`,
+ });
+ }
+
+ return applyChildren(node, {creature, targets, scope, log});
+}
+
+function applyChildren(node, args){
+ node.children.forEach(child => applyProperty(child, args));
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js
new file mode 100644
index 00000000..63aa2e1d
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js
@@ -0,0 +1,51 @@
+import applyProperty from '../applyProperty.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+import rollDice from '/imports/parser/rollDice.js';
+
+export default function applyBranch(node, {
+ creature, targets, scope, log
+}){
+ const applyChildren = function(){
+ node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+ };
+ const prop = node.node;
+ switch(prop.branchType){
+ case 'if':
+ recalculateCalculation(prop.condition, scope, log);
+ if (prop.condition?.value) applyChildren();
+ break;
+ case 'hit':
+ if (scope['$attackHit']?.value) applyChildren();
+ break;
+ case 'miss':
+ if (scope['$attackMiss']?.value) applyChildren();
+ break;
+ case 'failedSave':
+ if (scope['$saveFailed']?.value) applyChildren();
+ break;
+ case 'successfulSave':
+ if (scope['$saveSucceeded']?.value) 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
+ });
+ }
+ break;
+ case 'eachTarget':
+ if (targets.length){
+ targets.forEach(target => {
+ node.children.forEach(child => applyProperty(child, {
+ creature, targets: [target], scope, log
+ }));
+ });
+ } else {
+ applyChildren();
+ }
+ break;
+ }
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js
new file mode 100644
index 00000000..af4210f6
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js
@@ -0,0 +1,94 @@
+import {
+ setLineageOfDocs,
+ renewDocIds
+} from '/imports/api/parenting/parenting.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';
+import { get } from 'lodash';
+import resolve, { map } from '/imports/parser/resolve.js';
+import logErrors from './shared/logErrors.js';
+
+export default function applyBuff(node, {creature, targets, scope, log}){
+ const prop = node.node;
+ let buffTargets = prop.target === 'self' ? [creature] : targets;
+
+ // Then copy the decendants of the buff to the targets
+ let propList = [prop];
+ function addChildrenToPropList(children){
+ children.forEach(child => {
+ propList.push(child.node);
+ addChildrenToPropList(child.children);
+ });
+ }
+ addChildrenToPropList(node.children);
+ crystalizeVariables({propList, scope, log});
+
+ let oldParent = {
+ id: prop.parent.id,
+ collection: prop.parent.collection,
+ };
+ buffTargets.forEach(target => {
+ copyNodeListToTarget(propList, target, oldParent);
+ });
+
+ // 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}];
+ setLineageOfDocs({
+ docArray: propList,
+ newAncestry: ancestry,
+ oldParent,
+ });
+ renewDocIds({
+ docArray: propList,
+ });
+ setDocToLastOrder({
+ collection: CreatureProperties,
+ doc: propList[0],
+ });
+ CreatureProperties.batchInsert(propList);
+}
+
+/**
+ * Replaces all variables with their resolved values
+ * except variables of the form `$target.thing.total` become `thing.total`
+ */
+function crystalizeVariables({propList, scope, log}){
+ propList.forEach(prop => {
+ computedSchemas[prop.type].computedFields().forEach( calcKey => {
+ applyFnToKey(prop, calcKey, (prop, key) => {
+ const calcObj = get(prop, key);
+ if (!calcObj?.parseNode) return;
+ map(calcObj.parseNode, node => {
+ // Skip nodes that aren't symbols or accessors
+ if (
+ node.parseType !== 'accessor' && node.parseType !== 'symbol'
+ ) return node;
+ // Handle variables
+ if (node.name === '$target'){
+ // strip $target
+ if (node.parseType === 'accessor'){
+ node.name = node.path.shift();
+ } else {
+ // Can't strip symbols
+ log.content.push({
+ name: 'Error',
+ value: 'Variable `$target` should not be used without a property: $target.property'
+ });
+ }
+ return node;
+ } else {
+ // Resolve all other variables
+ const {result, context} = resolve('reduce', node, scope);
+ logErrors(context.errors, log);
+ return result;
+ }
+ });
+ });
+ });
+ });
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js
new file mode 100644
index 00000000..2368b0cb
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js
@@ -0,0 +1,115 @@
+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';
+
+export default function applyDamage(node, {
+ creature, targets, scope, log
+}){
+ const applyChildren = function(){
+ node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+ };
+
+ const prop = node.node;
+
+ // Skip if there is no parse node to work with
+ if (!prop.amount.parseNode) return;
+
+ // Choose target
+
+ let damageTargets = prop.target === 'self' ? [creature] : targets;
+ // Determine if the hit is critical
+ let criticalHit = scope['$criticalHit']?.value &&
+ prop.damageType !== 'healing' // Can't critically heal
+ ;
+ // Double the damage rolls if the hit is critical
+ let context = new Context({
+ options: {doubleRolls: criticalHit},
+ });
+
+ // Gather all the lines we need to log into an array
+ const logValue = [];
+ const logName = prop.damageType === 'healing' ? 'Healing' : 'Damage';
+
+ // Compile the dice roll and store that string first
+ // const {result: compiled} = resolve('compiled', prop.amount.parseNode, scope, context);
+ // logValue.push(toString(compiled));
+ // logErrors(context.errors, log);
+
+ // roll the dice only and store that string
+ applyEffectsToCalculationParseNode(prop.amount, log);
+ const {result: rolled} = resolve('roll', prop.amount.parseNode, scope, context);
+ logValue.push(toString(rolled));
+ logErrors(context.errors, log);
+
+ // 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);
+
+ // Store the result
+ if (reduced.parseType === 'constant'){
+ prop.amount.value = reduced.value;
+ } else if (reduced.parseType === 'error'){
+ prop.amount.value = null;
+ } else {
+ prop.amount.value = toString(reduced);
+ }
+
+ const damage = +reduced.value;
+
+ // If we didn't end up with a constant of finite amount, give up
+ if (reduced?.parseType !== 'constant' && !isFinite(reduced.value)){
+ return applyChildren();
+ }
+
+ // Memoise the damage suffix for the log
+ let suffix = (criticalHit ? ' critical ' : ' ') +
+ prop.damageType +
+ (prop.damageType !== 'healing' ? ' damage ': '');
+
+ if (damageTargets && damageTargets.length) {
+ // Iterate through all the targets
+ damageTargets.forEach(target => {
+
+ // Deal the damage to the target
+ let damageDealt = dealDamageWork({
+ creature: target,
+ damageType: prop.damageType,
+ amount: damage,
+ });
+
+ // Log the damage done
+ if (target._id === creature._id){
+ // Target is same as self, log damage as such
+ logValue.push(damageDealt + suffix + ' to self');
+ } else {
+ logValue.push('Dealt ' + damageDealt + suffix + ` ${target.name && ' to '}${target.name}`);
+ // Log the damage received on that creature's log as well
+ insertCreatureLog.call({
+ log: {
+ creatureId: target._id,
+ content: [{
+ name,
+ value: 'Recieved ' + damageDealt + suffix,
+ }],
+ }
+ });
+ }
+ });
+ } else {
+ // There are no targets, just log the result
+ logValue.push(damage + suffix);
+ }
+ log.content.push({
+ name: logName,
+ value: logValue.join('\n'),
+ });
+ return applyChildren();
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyNote.js b/app/imports/api/engine/actions/applyPropertyByType/applyNote.js
new file mode 100644
index 00000000..2d460ee2
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyNote.js
@@ -0,0 +1,25 @@
+import recalculateInlineCalculations from './shared/recalculateInlineCalculations.js';
+import applyProperty from '../applyProperty.js';
+
+export default function applyNote(node, {creature, targets, scope, log}){
+ const prop = node.node;
+
+ // Log Name, summary
+ let content = { name: prop.name };
+ if (prop.summary?.text){
+ recalculateInlineCalculations(prop.summary, scope, log);
+ content.value = prop.summary.value;
+ }
+ if (content.name || content.value){
+ log.content.push(content);
+ }
+ // Log description
+ if (prop.description?.text){
+ recalculateInlineCalculations(prop.description, scope, log);
+ log.content.push({value: prop.description.value});
+ }
+ // Apply children
+ node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js
new file mode 100644
index 00000000..67e2e42f
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js
@@ -0,0 +1,21 @@
+import applyProperty from '../applyProperty.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+
+export default function applyRoll(node, {creature, targets, scope, log}){
+ const prop = node.node;
+
+ if (prop.roll?.calculation){
+ recalculateCalculation(prop.roll, scope, log);
+
+ if (isFinite(prop.roll.value)){
+ scope[prop.variableName] = prop.roll.value;
+ }
+ log.content.push({
+ name: prop.name,
+ value: prop.variableName + ' = ' + prop.roll.calculation + ' = ' + prop.roll.value,
+ });
+ }
+ return node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js
new file mode 100644
index 00000000..b6855e19
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js
@@ -0,0 +1,78 @@
+import rollDice from '/imports/parser/rollDice.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+import applyProperty from '../applyProperty.js';
+
+export default function applySavingThrow(node, {creature, targets, scope, log}){
+ const prop = node.node;
+
+ let saveTargets = prop.target === 'self' ? [creature] : targets;
+
+ recalculateCalculation(prop.dc, scope, log);
+
+ const dc = (prop.dc?.value);
+ if (!isFinite(dc)){
+ log.content.push({
+ name: 'Error',
+ value: 'Saving throw requires a DC',
+ });
+ return node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+ }
+ log.content.push({
+ name: prop.name,
+ value: ' DC ' + dc,
+ });
+
+ saveTargets.forEach(target => {
+ delete scope['$saveFailed'];
+ delete scope['$saveSucceeded'];
+ delete scope['$saveDiceRoll'];
+ delete scope['$saveRoll'];
+
+ const applyChildren = function(){
+ node.children.forEach(child => applyProperty(child, {
+ creature, targets: [target], scope, log
+ }));
+ };
+
+ const save = target.variables[prop.stat];
+
+ if (!save){
+ log.content.push({
+ name: 'Saving throw error',
+ value: 'No saving throw found: ' + prop.stat,
+ });
+ return applyChildren();
+ }
+
+ let value, values, resultPrefix;
+ if (save.advantage === 1){
+ values = rollDice(2, 20).sort().reverse();
+ value = values[0];
+ resultPrefix = `Advantage: 1d20 [${values[0]},~~${values[1]}~~] + ${save.value} = `
+ } else if (save.advantage === -1){
+ values = rollDice(2, 20).sort();
+ value = values[0];
+ resultPrefix = `Disadvantage: 1d20 [${values[0]},~~${values[1]}~~] + ${save.value} = `
+ } else {
+ values = rollDice(1, 20);
+ value = values[0];
+ resultPrefix = `1d20 [${value}] + ${save.value} = `
+ }
+ scope['$saveDiceRoll'] = {value};
+ const result = value + save.value || 0;
+ scope['$saveRoll'] = {value: result};
+ const saveSuccess = result >= dc;
+ if (saveSuccess){
+ scope['$saveSucceeded'] = {value: true};
+ } else {
+ scope['$saveFailed'] = {value: true};
+ }
+ log.content.push({
+ name: 'Save',
+ value: resultPrefix + result + (saveSuccess ? 'Passed' : 'Failed')
+ });
+ return applyChildren();
+ });
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js b/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js
new file mode 100644
index 00000000..5162c41f
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js
@@ -0,0 +1,14 @@
+import applyProperty from '../applyProperty.js';
+import recalculateCalculation from './shared/recalculateCalculation.js';
+
+export default function applyToggle(node, {
+ creature, targets, scope, log
+}){
+ const prop = node.node;
+ recalculateCalculation(prop.condition, scope, log);
+ if (prop.condition?.value) {
+ return node.children.forEach(child => applyProperty(child, {
+ creature, targets, scope, log
+ }));
+ }
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js b/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js
new file mode 100644
index 00000000..be625eea
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js
@@ -0,0 +1,24 @@
+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){
+ if (!calcObj.effects) return;
+ calcObj.effects.forEach(effect => {
+ if (effect.operation !== 'add') return;
+ if (!effect.amount) return;
+ if (effect.amount.value === null) return;
+ let effectParseNode;
+ try {
+ effectParseNode = parse(effect.amount.value.toString());
+ calcObj.parseNode = operator.create({
+ left: calcObj.parseNode,
+ right: effectParseNode,
+ operator: '+',
+ fn: 'add'
+ });
+ } catch (e){
+ logErrors([e], log)
+ }
+ });
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js b/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js
new file mode 100644
index 00000000..219fddd5
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js
@@ -0,0 +1,7 @@
+export default function logErrors(errors, log){
+ errors?.forEach(error => {
+ if (error.type !== 'info'){
+ log.content.push({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
new file mode 100644
index 00000000..a10340be
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js
@@ -0,0 +1,11 @@
+import evaluateCalculation from '/imports/api/engine/computation/utility/evaluateCalculation.js';
+import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js';
+import logErrors from './logErrors.js';
+
+export default function recalculateCalculation(calc, scope, log, context){
+ if (!calc?.parseNode) return;
+ calc._parseLevel = 'reduce';
+ applyEffectsToCalculationParseNode(calc, log);
+ evaluateCalculation(calc, scope, context);
+ logErrors(calc.errors, log);
+}
diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js
new file mode 100644
index 00000000..3e25072f
--- /dev/null
+++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js
@@ -0,0 +1,13 @@
+import embedInlineCalculations from '/imports/api/engine/computation/utility/embedInlineCalculations.js';
+import recalculateCalculation from './recalculateCalculation.js'
+
+export default function recalculateInlineCalculations(inlineCalcObj, scope, log){
+ // Skip if there are no calculations
+ if (!inlineCalcObj?.calculations?.length) return;
+ // Recalculate each calculation with the current scope
+ inlineCalcObj.inlineCalculations.forEach(calc => {
+ recalculateCalculation(calc, scope, log);
+ });
+ // Embed the new calculated values
+ embedInlineCalculations(inlineCalcObj);
+}
diff --git a/app/imports/api/creature/actions/doAction.js b/app/imports/api/engine/actions/doAction.js
similarity index 50%
rename from app/imports/api/creature/actions/doAction.js
rename to app/imports/api/engine/actions/doAction.js
index 8cbe6690..6a275b34 100644
--- a/app/imports/api/creature/actions/doAction.js
+++ b/app/imports/api/engine/actions/doAction.js
@@ -1,17 +1,14 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
-import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs.js';
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 { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import nodesToTree from '/imports/api/parenting/nodesToTree.js';
-import applyProperties from '/imports/api/creature/actions/applyProperties.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import getAncestorContext from '/imports/api/creature/actions/getAncestorContext.js';
+import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
+import applyProperty from './applyProperty.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
const doAction = new ValidatedMethod({
name: 'creatureProperties.doAction',
@@ -20,7 +17,7 @@ const doAction = new ValidatedMethod({
targetIds: {
type: Array,
defaultValue: [],
- maxCount: 10,
+ maxCount: 20,
optional: true,
},
'targetIds.$': {
@@ -38,62 +35,90 @@ const doAction = new ValidatedMethod({
// Check permissions
let creature = getRootCreatureAncestor(action);
- // Build ancestor context
- let actionContext = getAncestorContext(action);
-
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);
assertEditPermission(target, this.userId);
targets.push(target);
});
- doActionWork({action, creature, targets, actionContext, method: this});
- // The acting creature might have used ammo
- recomputeInventory(creature._id);
+ // Fetch all the action's ancestor creatureProperties
+ const ancestorIds = [];
+ action.ancestors.forEach(ref => {
+ if (ref.collection === 'creatureProperties') {
+ ancestorIds.push(ref.id);
+ }
+ });
- // The action might add properties which need to be activated
- recomputeInactiveProperties(creature._id);
+ // Get cursor of ancestors
+ const ancestors = CreatureProperties.find({
+ _id: {$in: ancestorIds},
+ }, {
+ sort: {order: 1},
+ });
- // recompute creatures
- recomputeCreatureByDoc(creature);
+ // Get cursor of the properties
+ const properties = CreatureProperties.find({
+ $or: [{_id: action._id}, {'ancestors.id': action._id}],
+ removed: {$ne: true},
+ }, {
+ sort: {order: 1},
+ });
+
+ // Do the action
+ doActionWork({creature, targets, properties, ancestors, method: this});
+
+ // Recompute all involved creatures
+ computeCreature(creature._id);
targets.forEach(target => {
- recomputeInactiveProperties(target._id);
- recomputeCreatureByDoc(target);
+ computeCreature(target._id);
});
},
});
+export default doAction;
+
export function doActionWork({
- action,
- creature,
- targets,
- actionContext = {},
- method
+ creature, targets, properties, ancestors, method
}){
+ // get the docs
+ const ancestorScope = getAncestorScope(ancestors);
+ const propertyForest = nodeArrayToTree(properties);
+ if (propertyForest.length !== 1){
+ throw new Meteor.Error(`The action has ${propertyForest.length} top level properties, expected 1`);
+ }
+
// Create the log
let log = CreatureLogSchema.clean({
creatureId: creature._id,
creatureName: creature.name,
});
- let decendantForest = nodesToTree({
- collection: CreatureProperties,
- ancestorId: action._id,
- });
- let startingForest = [{
- node: action,
- children: decendantForest,
- }];
- applyProperties({
- forest: startingForest,
- actionContext,
+ // Apply the top level property, it is responsible for applying its children
+ // recursively
+ const scope = {
+ ...creature.variables,
+ ...ancestorScope,
+ }
+ applyProperty(propertyForest[0], {
creature,
targets,
+ scope,
log,
});
+
+ // Insert the log
insertCreatureLogWork({log, creature, method});
}
-export default doAction;
+// Assumes ancestors are in tree order already
+function getAncestorScope(ancestors){
+ let scope = {};
+ ancestors.forEach(prop => {
+ scope[`#${prop.type}`] = prop;
+ });
+ return scope;
+}
diff --git a/app/imports/api/engine/actions/doAction.test.js b/app/imports/api/engine/actions/doAction.test.js
new file mode 100644
index 00000000..19560704
--- /dev/null
+++ b/app/imports/api/engine/actions/doAction.test.js
@@ -0,0 +1,11 @@
+import '/imports/api/simpleSchemaConfig.js';
+//import testTypes from './testTypes/index.js';
+import { doActionWork } from './doAction.js';
+import createAction from './tests/createAction.testFn.js';
+
+describe('Do Action', function(){
+ it('Does an empty action', function(){
+ doActionWork(createAction({properties: [{type: 'action'}]}));
+ });
+ //testTypes.forEach(test => it(test.text, test.fn));
+});
diff --git a/app/imports/api/engine/actions/methods/commitAction.js b/app/imports/api/engine/actions/methods/commitAction.js
new file mode 100644
index 00000000..472ca1d8
--- /dev/null
+++ b/app/imports/api/engine/actions/methods/commitAction.js
@@ -0,0 +1,54 @@
+import SimpleSchema from 'simpl-schema';
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import Creatures from '/imports/api/creature/creatures/Creatures.js';
+import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
+import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
+import doAction from '../doAction.js';
+
+const commitAction = new ValidatedMethod({
+ name: 'creatureProperties.doAction',
+ validate: new SimpleSchema({
+ actionId: SimpleSchema.RegEx.Id,
+ targetIds: {
+ type: Array,
+ defaultValue: [],
+ maxCount: 20,
+ optional: true,
+ },
+ 'targetIds.$': {
+ type: String,
+ regEx: SimpleSchema.RegEx.Id,
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 10,
+ timeInterval: 5000,
+ },
+ run({actionId, targetIds = []}) {
+ let action = CreatureProperties.findOne(actionId);
+ // Check permissions
+ let creature = getRootCreatureAncestor(action);
+
+ assertEditPermission(creature, this.userId);
+ let targets = [];
+ targetIds.forEach(targetId => {
+ let target = Creatures.findOne(targetId);
+ assertEditPermission(target, this.userId);
+ targets.push(target);
+ });
+ doAction({action, creature, targets, method: this});
+
+ // recompute creatures
+ computeCreature(creature._id);
+
+ targets.forEach(target => {
+ computeCreature(target._id);
+ });
+ },
+});
+
+export default commitAction;
diff --git a/app/imports/api/engine/actions/tests/createAction.testFn.js b/app/imports/api/engine/actions/tests/createAction.testFn.js
new file mode 100644
index 00000000..84ffa907
--- /dev/null
+++ b/app/imports/api/engine/actions/tests/createAction.testFn.js
@@ -0,0 +1,26 @@
+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/applyAction.testFn.js b/app/imports/api/engine/actions/tests/testTypes/applyAction.testFn.js
new file mode 100644
index 00000000..e69de29b
diff --git a/app/imports/api/engine/actions/tests/testTypes/index.testFn.js b/app/imports/api/engine/actions/tests/testTypes/index.testFn.js
new file mode 100644
index 00000000..e8a4b486
--- /dev/null
+++ b/app/imports/api/engine/actions/tests/testTypes/index.testFn.js
@@ -0,0 +1,6 @@
+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.js
new file mode 100644
index 00000000..a806ddd6
--- /dev/null
+++ b/app/imports/api/engine/computation/CreatureComputation.js
@@ -0,0 +1,37 @@
+import { EJSON } from 'meteor/ejson';
+import createGraph from 'ngraph.graph';
+
+export default class CreatureComputation {
+ constructor(properties){
+ // Set up fields
+ this.originalPropsById = {};
+ this.propsById = {};
+ this.propsWithTag = {};
+ this.scope = {};
+ this.props = properties;
+ this.dependencyGraph = createGraph();
+
+ // Store properties for easy access later
+ properties.forEach(prop => {
+ // Store a copy of the unmodified prop
+ // EJSON clone is ~4x faster than lodash cloneDeep for EJSONable objects
+ this.originalPropsById[prop._id] = EJSON.clone(prop);
+ // Store by id
+ this.propsById[prop._id] = prop;
+
+ // Store sets of ids in each tag
+ if (prop.tags){
+ prop.tags.forEach(tag => {
+ if (this.propsWithTag[tag]){
+ this.propsWithTag[tag].push(prop._id);
+ } else {
+ this.propsWithTag[tag] = [prop._id];
+ }
+ });
+ }
+
+ // 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
new file mode 100644
index 00000000..b794a61e
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/computeInactiveStatus.js
@@ -0,0 +1,42 @@
+import walkDown from '/imports/api/engine/computation/utility/walkdown.js';
+
+export default function computeInactiveStatus(node){
+ const prop = node.node;
+ if (!isActive(prop)){
+ // Mark prop inactive due to self
+ prop.inactive = true;
+ prop.deactivatedBySelf = true;
+ }
+ if(!childrenActive(prop)){
+ // Mark children as inactive due to ancestor
+ walkDown(node.children, child => {
+ child.node.inactive = true;
+ child.node.deactivatedByAncestor = true;
+ });
+ }
+}
+
+function isActive(prop){
+ if (prop.disabled) return false;
+ switch (prop.type){
+ // Unprepared spells are inactive
+ case 'spell': return !!prop.prepared || !!prop.alwaysPrepared;
+ default: return true;
+ }
+}
+
+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
+ case 'action': return false;
+ case 'spell': return false;
+ // The children of notes are always inactive
+ case 'note': return false;
+ // Other children are active
+ default: return true;
+ }
+}
diff --git a/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js b/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js
new file mode 100644
index 00000000..e3adabc8
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/computeSlotQuantityFilled.js
@@ -0,0 +1,21 @@
+/**
+ * Only computes `totalFilled`, need to compute `quantityExpected.value`
+ * before `spacesLeft` can be computed
+ */
+export default function computeSlotQuantityFilled(node, dependencyGraph){
+ let slot = node.node;
+ if (slot.type !== 'propertySlot') return;
+ slot.totalFilled = 0;
+ node.children.forEach(child => {
+ let childProp = child.node;
+ dependencyGraph.addLink(slot._id, childProp._id, 'slotFill');
+ if (
+ childProp.type === 'slotFiller' &&
+ Number.isFinite(childProp.slotQuantityFilled)
+ ){
+ slot.totalFilled += childProp.slotQuantityFilled;
+ } else {
+ slot.totalFilled++;
+ }
+ });
+}
diff --git a/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js
new file mode 100644
index 00000000..13549e73
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/computeToggleDependencies.js
@@ -0,0 +1,17 @@
+import walkDown from '/imports/api/engine/computation/utility/walkdown.js';
+
+export default function computeToggleDependencies(node, dependencyGraph){
+ const prop = node.node;
+ // Only for toggles that aren't inactive and aren't set to enabled or disabled
+ if (
+ prop.inactive ||
+ prop.type !== 'toggle' ||
+ prop.disabled ||
+ prop.enabled
+ ) return;
+ walkDown(node.children, child => {
+ child.node._computationDetails.toggleAncestors.push(prop);
+ // The child nodes depend on the toggle condition compuation
+ dependencyGraph.addLink(child.node._id, prop._id, 'toggle');
+ });
+}
diff --git a/app/imports/api/engine/computation/buildComputation/linkCalculationDependencies.js b/app/imports/api/engine/computation/buildComputation/linkCalculationDependencies.js
new file mode 100644
index 00000000..67e4452e
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/linkCalculationDependencies.js
@@ -0,0 +1,51 @@
+import findAncestorByType from '/imports/api/engine/computation/utility/findAncestorByType.js';
+import { traverse } from '/imports/parser/resolve.js';
+
+export default function linkCalculationDependencies(dependencyGraph, prop, {propsById}){
+ prop._computationDetails.calculations.forEach(calcObj => {
+ // Store resolved ancestors
+ const memo = {
+ // ancestors: {} //this gets added if there are resolved ancestors
+ };
+ // Add this calculation to the dependency graph
+ const calcNodeId = `${prop._id}.${calcObj._key}`;
+ dependencyGraph.addNode(calcNodeId, calcObj);
+
+ // Traverse the parsed calculation looking for variable names
+ traverse(calcObj.parseNode, node => {
+ // Skip nodes that aren't symbols or accessors
+ if (node.parseType !== 'symbol' && node.parseType !== 'accessor') return;
+ // Link ancestor references as direct property dependencies
+ if (node.name[0] === '#'){
+ let ancestorProp = getAncestorProp(
+ node.name.slice(1), memo, prop, propsById
+ );
+ if (!ancestorProp) return;
+ // Link the ancestor prop as a direct dependency
+ dependencyGraph.addLink(
+ calcNodeId, ancestorProp._id, 'ancestorReference'
+ );
+ } else {
+ // Link variable name references as variable dependencies
+ dependencyGraph.addLink(
+ calcNodeId, node.name, 'variableReference'
+ );
+ }
+ });
+ // Store the resolved ancestors in this calculation's local scope
+ if (memo.ancestors) {
+ calcObj._localScope = { ...calcObj._localScope, ...memo.ancestors};
+ }
+ });
+}
+
+function getAncestorProp(type, memo, prop, propsById){
+ if (memo.ancestors && memo.ancestors['#' + type]){
+ return memo.ancestors['#' + type];
+ } else {
+ var ancestorProp = findAncestorByType( prop, type, propsById );
+ if (!memo.ancestors) memo.ancestors = {};
+ memo.ancestors['#' + type] = ancestorProp;
+ return ancestorProp;
+ }
+}
diff --git a/app/imports/api/engine/computation/buildComputation/linkInventory.js b/app/imports/api/engine/computation/buildComputation/linkInventory.js
new file mode 100644
index 00000000..ae092bd5
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/linkInventory.js
@@ -0,0 +1,62 @@
+/**
+ * Performs a depth first traversal of the character tree, summing the container
+ * and inventory contents on the way up the tree
+ */
+export default function linkInventory(forest, dependencyGraph){
+ // The stack of properties to still navigate
+ const stack = [...forest];
+ // The current containers we are inside of
+ const containerStack = [];
+
+ while(stack.length){
+ const top = stack[stack.length - 1];
+ const prop = top.node;
+ if (prop._computationDetails.inventoryChildrenVisited){
+ if (prop.type === 'container') containerStack.pop();
+ stack.pop();
+ handleProp(prop, containerStack, dependencyGraph);
+ } else {
+ // Add all containers to the stack when we first visit them
+ if (prop.type === 'container'){
+ containerStack.push(top.node);
+ }
+ // Push children onto the stack and mark this as children are visited
+ stack.push(...top.children);
+ prop._computationDetails.inventoryChildrenVisited = true;
+ }
+ }
+}
+
+function handleProp(prop, containerStack, dependencyGraph){
+ // Skip props that aren't part of the inventory
+ if (prop.type !== 'item' && prop.type !== 'container') return;
+ // Determine if this property is carried, items are carried by default
+ let carried = prop.type === 'container' ? prop.carried : true;
+
+ // Item-specific links
+ if (prop.type === 'item'){
+ if (prop.attuned){
+ dependencyGraph.addLink('itemsAttuned', prop._id, 'attunedItem');
+ }
+ if (prop.equipped){
+ dependencyGraph.addLink('weightEquipment', prop._id, 'equippedItem');
+ dependencyGraph.addLink('valueEquipment', prop._id, 'equippedItem');
+ }
+ }
+
+ // Get the parent container
+ const container = containerStack[containerStack.length - 1];
+
+ if (container){
+ // The container depends on this prop for its contents data
+ dependencyGraph.addLink(container._id, prop._id, 'containerContents');
+ } else {
+ // There is no parent container, the character totals depend on this prop
+ dependencyGraph.addLink('weightTotal', prop._id, 'inventoryStats');
+ dependencyGraph.addLink('valueTotal', prop._id, 'inventoryStats');
+ if (carried){
+ dependencyGraph.addLink('weightCarried', prop._id, 'inventoryStats');
+ dependencyGraph.addLink('valueCarried', prop._id, 'inventoryStats');
+ }
+ }
+}
diff --git a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js
new file mode 100644
index 00000000..0acaf4cc
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js
@@ -0,0 +1,262 @@
+import { get, intersection, difference } from 'lodash';
+
+const linkDependenciesByType = {
+ action: linkAction,
+ adjustment: linkAdjustment,
+ attribute: linkAttribute,
+ branch: linkBranch,
+ buff: linkBuff,
+ class: linkVariableName,
+ classLevel: linkClassLevel,
+ constant: linkVariableName,
+ damage: linkDamage,
+ damageMultiplier: linkDamageMultiplier,
+ effect: linkEffects,
+ proficiency: linkProficiencies,
+ roll: linkRoll,
+ slot: linkSlot,
+ skill: linkSkill,
+ spell: linkAction,
+ spellList: linkSpellList,
+ savingThrow: linkSavingThrow,
+ toggle: linkToggle,
+}
+
+export default function linkTypeDependencies(dependencyGraph, prop, computation){
+ linkDependenciesByType[prop.type]?.(dependencyGraph, prop, computation);
+}
+
+function dependOnCalc({dependencyGraph, prop, key}){
+ let calc = get(prop, key);
+ if (!calc) return;
+ if (calc.type !== '_calculation'){
+ console.log(calc);
+ throw `Expected calculation got ${calc.type}`
+ }
+ dependencyGraph.addLink(prop._id, `${prop._id}.${key}`, 'calculation');
+}
+
+function linkAction(dependencyGraph, prop, {propsById}){
+ // The action depends on its attack roll and uses calculations
+ dependOnCalc({dependencyGraph, prop, key: 'attackRoll'});
+ dependOnCalc({dependencyGraph, prop, key: 'uses'});
+
+ // Link the resources the action uses
+ if (!prop.resources) return;
+ // Link items consumed
+ prop.resources.itemsConsumed.forEach((itemConsumed, index) => {
+ if (!itemConsumed.itemId) return;
+ const item = propsById[itemConsumed.itemId];
+ if (!item || item.inactive){
+ // Unlink if the item doesn't exist or is inactive
+ itemConsumed.itemId = undefined;
+ return;
+ }
+ // none of these dependencies are computed, we can use them immediately
+ itemConsumed.available = item.quantity;
+ itemConsumed.itemName = item.name;
+ itemConsumed.itemIcon = item.icon;
+ itemConsumed.itemColor = item.color;
+ dependencyGraph.addLink(prop._id, item._id, 'inventory');
+ // Link the property to its resource quantity calculation
+
+ dependOnCalc({
+ dependencyGraph,
+ prop,
+ key: `${prop._id}.resources.itemsConsumed.${index}.quantity`,
+ });
+ });
+ // Link attributes consumed
+ prop.resources.attributesConsumed.forEach((attConsumed, index) => {
+ if (!attConsumed.variableName) return;
+ dependencyGraph.addLink(prop._id, attConsumed.variableName, 'resource');
+ // Link the property to its resource quantity calculation
+ dependOnCalc({
+ dependencyGraph,
+ prop,
+ key: `${prop._id}.resources.attributesConsumed.${index}.quantity`,
+ });
+ });
+}
+
+function linkAdjustment(dependencyGraph, prop){
+ // Adjustment depends on its amount
+ dependOnCalc({dependencyGraph, prop, key: 'amount'});
+}
+
+function linkAttribute(dependencyGraph, prop){
+ linkVariableName(dependencyGraph, prop);
+ // Depends on spellSlotLevel
+ dependOnCalc({dependencyGraph, prop, key: 'spellSlotLevel'});
+
+ // Depends on base value
+ dependOnCalc({dependencyGraph, prop, key: 'baseValue'});
+
+ // hit dice depend on constitution
+ if (prop.attributeType === 'hitDice'){
+ dependencyGraph.addLink(prop._id, 'constitution', 'hitDiceConMod');
+ }
+}
+
+function linkBranch(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'condition'});
+}
+
+function linkBuff(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'duration'});
+}
+
+function linkClassLevel(dependencyGraph, prop){
+ // The variableName of the prop depends on the prop
+ if (prop.variableName && prop.level){
+ dependencyGraph.addLink(prop.variableName, prop._id, 'classLevel');
+ // The level variable depends on the class variableName variable
+ let existingLevelLink = dependencyGraph.getLink('level', prop.variableName);
+ if (!existingLevelLink){
+ dependencyGraph.addLink('level', prop.variableName, 'level');
+ }
+ }
+}
+
+function linkDamage(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'amount'});
+}
+
+function linkEffects(dependencyGraph, prop, computation){
+ // The effect depends on its amount calculation
+ dependOnCalc({dependencyGraph, prop, key: 'amount'});
+ // The stats depend on the effect
+ 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');
+ }
+ });
+ } else {
+ prop.stats.forEach(statName => {
+ if (!statName) return;
+ dependencyGraph.addLink(statName, prop._id, 'effect');
+ });
+ }
+}
+
+// Returns an array of IDs of the properties the effect targets
+function getEffectTagTargets(effect, computation){
+ const targets = getTargetListFromTags(effect.targetTags, computation);
+ const notIds = [];
+ if (effect.extraTags){
+ effect.extraTags.forEach(ex => {
+ if (ex.operation === 'OR'){
+ targets.push(...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])
+ });
+ }
+ });
+ }
+ return difference(targets, notIds);
+}
+
+function getTargetListFromTags(tags, computation){
+ const targetTagIdLists = [];
+ if (!tags) return [];
+ tags.forEach(tag => {
+ const idList = computation.propsWithTag[tag];
+ if (idList) targetTagIdLists.push(idList);
+ });
+ const targets = intersection(...targetTagIdLists);
+ return targets;
+}
+
+function getDefaultCalculationField(prop){
+ switch (prop.type){
+ case 'action': return 'attackRoll';
+ case 'adjustment': return 'amount';
+ case 'attribute': return 'baseValue';
+ case 'branch': return 'condition';
+ case 'buff': return 'duration';
+ case 'class': return null;
+ case 'classLevel': return null;
+ case 'constant': return null;
+ case 'container': return null;
+ case 'damageMultiplier': return null;
+ case 'damage': return 'amount';
+ case 'effect': return 'amount';
+ case 'feature': return null;
+ case 'folder': return null;
+ case 'item': return null;
+ case 'note': return null;
+ case 'proficiency': return null;
+ case 'reference': return null;
+ case 'roll': return 'roll';
+ case 'savingThrow': return 'dc';
+ case 'skill': return 'baseValue';
+ case 'slotFiller': return null;
+ case 'slot': return 'quantityExpected';
+ case 'spellList': return 'attackRollBonus';
+ case 'spell': return null;
+ case 'toggle': return 'condition';
+ }
+}
+
+function linkRoll(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'roll'});
+}
+
+function linkVariableName(dependencyGraph, prop){
+ // The variableName of the prop depends on the prop
+ if (prop.variableName){
+ dependencyGraph.addLink(prop.variableName, prop._id, 'definition');
+ }
+}
+
+function linkDamageMultiplier(dependencyGraph, prop){
+ prop.damageTypes.forEach(damageType => {
+ // Remove all non-letter characters from the damage name
+ const damageName = damageType.replace(/[^a-z]/gi, '')
+ dependencyGraph.addLink(`${damageName}Multiplier`, prop._id, prop.type);
+ });
+}
+
+function linkProficiencies(dependencyGraph, prop){
+ // The stats depend on the proficiency
+ prop.stats.forEach(statName => {
+ if (!statName) return;
+ dependencyGraph.addLink(statName, prop._id, prop.type);
+ });
+}
+
+function linkSavingThrow(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'dc'});
+}
+
+function linkSkill(dependencyGraph, prop){
+ linkVariableName(dependencyGraph, prop);
+ // The prop depends on the variable references as the ability
+ if (prop.ability){
+ dependencyGraph.addLink(prop._id, prop.ability, 'skillAbilityScore');
+ }
+ // Skills depend on the creature's proficiencyBonus
+ dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
+}
+
+function linkSlot(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'quantityExpected'});
+ dependOnCalc({dependencyGraph, prop, key: 'slotCondition'});
+}
+
+function linkSpellList(dependencyGraph, prop){
+ dependOnCalc({dependencyGraph, prop, key: 'maxPrepared'});
+ dependOnCalc({dependencyGraph, prop, key: 'attackRollBonus'});
+ dependOnCalc({dependencyGraph, prop, key: 'dc'});
+}
+
+function linkToggle(dependencyGraph, prop){
+ linkVariableName(dependencyGraph, prop);
+ dependOnCalc({dependencyGraph, prop, key: 'condition'});
+}
diff --git a/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js b/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js
new file mode 100644
index 00000000..a2e19db1
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/parseCalculationFields.js
@@ -0,0 +1,105 @@
+import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
+import { prettifyParseError, parse } from '/imports/parser/parser.js';
+import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js';
+import { get, unset } from 'lodash';
+import errorNode from '/imports/parser/parseTree/error.js';
+import cyrb53 from '/imports/api/engine/computation/utility/cyrb53.js';
+
+export default function parseCalculationFields(prop, schemas){
+ discoverInlineCalculationFields(prop, schemas);
+ parseAllCalculationFields(prop, schemas);
+}
+
+function discoverInlineCalculationFields(prop, schemas){
+ // For each key in the schema
+ schemas[prop.type].inlineCalculationFields().forEach( calcKey => {
+ // That ends in .inlineCalculations
+ applyFnToKey(prop, calcKey, (prop, key) => {
+ const inlineCalcObj = get(prop, key);
+ if (!inlineCalcObj) return;
+ // Store a reference to all the inline calculations
+ prop._computationDetails.inlineCalculations.push(inlineCalcObj);
+ // Extract the calculations and store them on the property
+ let string = inlineCalcObj.text;
+ // If there is no text, delete the whole field
+ if (!string){
+ unset(prop, calcKey);
+ return;
+ }
+
+ // Set the value to the uncomputed string for use in calculations
+ inlineCalcObj.value = string;
+
+ // Has the text, if it matches the existing hash, stop
+ const inlineCalcHash = cyrb53(inlineCalcObj.text);
+ if (inlineCalcHash === inlineCalcObj.hash){
+ return;
+ }
+ inlineCalcObj.hash = inlineCalcHash;
+ inlineCalcObj.inlineCalculations = [];
+
+ // It will be re set including the embedded calculation at the end of
+ // the computation
+ let matches = string.matchAll(INLINE_CALCULATION_REGEX);
+ for (let match of matches){
+ let calculation = match[1];
+ inlineCalcObj.inlineCalculations.push({
+ calculation,
+ });
+ }
+ });
+ });
+}
+
+function parseAllCalculationFields(prop, schemas){
+ // For each computed key in the schema
+ schemas[prop.type].computedFields().forEach( calcKey => {
+ // Determine the level the calculation should compute down to
+ let parseLevel = schemas[prop.type].getDefinition(calcKey).parseLevel || 'reduce';
+
+ // Special case of effects, when targeting by tags compile
+ if (prop.type === 'effect' && prop.targetByTags) parseLevel = 'compile';
+
+ // For all fields matching they keys
+ // supports `keys.$.with.$.arrays`
+ applyFnToKey(prop, calcKey, (prop, key) => {
+ const calcObj = get(prop, key);
+ if (!calcObj) return;
+ // Delete the whole calculation object if the calculation string isn't set
+ if (!calcObj.calculation){
+ unset(prop, calcKey);
+ return;
+ }
+ // Store a reference to all the calculations
+ prop._computationDetails.calculations.push(calcObj);
+ // Store the level to compute down to later
+ calcObj._parseLevel = parseLevel;
+ // Store the key
+ calcObj._key = key;
+ // Set a type
+ calcObj.type = '_calculation';
+ // Parse the calculation
+ parseCalculation(calcObj);
+ });
+ });
+}
+
+function parseCalculation(calcObj){
+ const calcHash = cyrb53(calcObj.calculation);
+ // If the cached parse calculation is equal to the calculation, skip
+ if (calcHash === calcObj.hash){
+ return;
+ }
+ calcObj.hash = calcHash;
+ try {
+ calcObj.parseNode = parse(calcObj.calculation);
+ delete calcObj.parseError;
+ } catch (e) {
+ let error = {
+ type: 'evaluation',
+ message: prettifyParseError(e),
+ };
+ calcObj.parseError = error;
+ calcObj.parseNode = errorNode.create({error});
+ }
+}
diff --git a/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js b/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js
new file mode 100644
index 00000000..9c783df0
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/removeSchemaFields.js
@@ -0,0 +1,10 @@
+import applyFnToKey from '../utility/applyFnToKey.js';
+import { unset } from 'lodash';
+
+export default function removeSchemaFields(schemas, prop){
+ schemas.forEach(schema => {
+ schema.removeBeforeComputeFields().forEach(
+ key => applyFnToKey(prop, key, unset)
+ );
+ });
+}
diff --git a/app/imports/api/engine/computation/buildComputation/tests/computeInactiveStatus.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/computeInactiveStatus.testFn.js
new file mode 100644
index 00000000..c7565156
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/computeInactiveStatus.testFn.js
@@ -0,0 +1,116 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ let computation = buildComputationFromProps(testProperties);
+ const bySelf = (propId, note) => assertDeactivatedBySelf(computation, propId, note);
+ const byAncestor = (propId, note) => assertDeactivatedByAncestor(computation, propId, note);
+ const active = (propId, note) => assertActive(computation, propId, note);
+
+ // Items
+ active('itemUnequippedId', 'Unequipped items should be active');
+ byAncestor('itemUnequippedChildId', 'Children of unequipped items should be inactive');
+ active('itemEquippedId', 'Equipped items should be active');
+ active('itemEquippedChildId', 'Children of equipped items should be active');
+
+ // Spells
+ active('spellPreparedId', 'Prepared spells should be active');
+ byAncestor('spellPreparedChildId', 'Children of prepared spells should be deactivatedByAncestor');
+ active('spellAlwaysPreparedId', 'Always prepared spells should be active');
+ byAncestor('spellAlwaysPreparedChildId', 'Children of always prepared spells should be deactivatedByAncestor');
+ bySelf('spellUnpreparedId', 'Unprepared spells should be deactivatedBySelf');
+ byAncestor('spellUnpreparedChildId', 'Children of unprepared spells should be deactivatedByAncestor');
+
+ // Notes
+ active('NoteId', 'Notes should be active');
+ byAncestor('NoteChildId', 'children of notes should always be inactive');
+}
+
+function assertDeactivatedBySelf(computation, propId, note){
+ const prop = computation.propsById[propId];
+ assert.isTrue(prop.deactivatedBySelf, note);
+ assert.isTrue(prop.inactive, note + '. The property should be inactive');
+}
+
+function assertDeactivatedByAncestor(computation, propId, note){
+ const prop = computation.propsById[propId];
+ assert.isTrue(prop.deactivatedByAncestor, note);
+ assert.isTrue(prop.inactive, 'The property should be inactive');
+}
+
+function assertActive(computation, propId, note){
+ const prop = computation.propsById[propId];
+ assert.isNotTrue(prop.inactive, note);
+ assert.isNotTrue(prop.deactivatedBySelf, note);
+ assert.isNotTrue(prop.deactivatedBySelf, note);
+}
+
+var testProperties = [
+ // Items
+ clean({
+ _id: 'itemUnequippedId',
+ type: 'item',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'itemUnequippedChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'itemUnequippedId'}],
+ }),
+ clean({
+ _id: 'itemEquippedId',
+ type: 'item',
+ equipped: true,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'itemEquippedChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'itemEquippedId'}],
+ }),
+ // Spells
+ clean({
+ _id: 'spellPreparedId',
+ type: 'spell',
+ ancestors: [{id: 'charId'}],
+ prepared: true,
+ }),
+ clean({
+ _id: 'spellPreparedChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'spellPreparedId'}],
+ }),
+ clean({
+ _id: 'spellAlwaysPreparedId',
+ type: 'spell',
+ ancestors: [{id: 'charId'}],
+ alwaysPrepared: true,
+ }),
+ clean({
+ _id: 'spellAlwaysPreparedChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'spellAlwaysPreparedId'}],
+ }),
+ clean({
+ _id: 'spellUnpreparedId',
+ type: 'spell',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'spellUnpreparedChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'spellUnpreparedId'}],
+ }),
+ // Notes
+ clean({
+ _id: 'NoteId',
+ type: 'note',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'NoteChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'NoteId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js
new file mode 100644
index 00000000..8a8af817
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/computeSlotQuantityFilled.testFn.js
@@ -0,0 +1,36 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ const totalFilled = computation.propsById['slotId'].totalFilled;
+ assert.equal(totalFilled, 4);
+}
+
+var testProperties = [
+ // Slots
+ clean({
+ _id: 'slotId',
+ type: 'propertySlot',
+ ancestors: [{id: 'charId'}],
+ }),
+ // Children
+ clean({
+ _id: 'slotFillerId',
+ type: 'slotFiller',
+ slotQuantityFilled: 3,
+ slotFillerType: 'item',
+ ancestors: [{id: 'charId'}, {id: 'slotId'}],
+ }),
+ clean({
+ _id: 'slotChildId',
+ type: 'item',
+ ancestors: [{id: 'charId'}, {id: 'slotId'}],
+ }),
+ clean({
+ _id: 'slotGrandchildId',
+ type: 'effect',
+ ancestors: [{id: 'charId'}, {id: 'slotId'}, {id: 'slotChildId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildComputation/tests/computeToggleDependencies.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/computeToggleDependencies.testFn.js
new file mode 100644
index 00000000..268ea2ef
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/computeToggleDependencies.testFn.js
@@ -0,0 +1,74 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ const hasLink = computation.dependencyGraph.hasLink;
+ assert.include(
+ computation.propsById['conditionToggleChildId']._computationDetails.toggleAncestors,
+ computation.propsById['conditionToggleId'],
+ 'Children of toggles should store a reference to their toggle ancestor'
+ )
+ assert.isTrue(
+ !!hasLink('conditionToggleChildId', 'conditionToggleId'),
+ 'Children of the condition toggle should depend on it'
+ );
+ assert.isTrue(
+ !!hasLink('conditionToggleGrandChildId', 'conditionToggleId'),
+ 'Descendants of the condition toggle should depend on it'
+ );
+ assert.isFalse(
+ !!hasLink('disabledToggleId', 'disabledToggleChildId'),
+ 'The dependency should not be reversed'
+ );
+ assert.isFalse(
+ !!hasLink('disabledToggleChildId', 'disabledToggleId'),
+ 'Children of disabled toggle should not depend on it'
+ );
+ assert.isFalse(
+ !!hasLink('enabledToggleChildId', 'enabledToggleId'),
+ 'Children of enabled toggle should not depend on it'
+ );
+}
+
+var testProperties = [
+ clean({
+ _id: 'enabledToggleId',
+ type: 'toggle',
+ enabled: true,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'disabledToggleId',
+ type: 'toggle',
+ disabled: true,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'conditionToggleId',
+ type: 'toggle',
+ ancestors: [{id: 'charId'}],
+ }),
+ // Children
+ clean({
+ _id: 'enabledToggleChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'enabledToggleId'}],
+ }),
+ clean({
+ _id: 'disabledToggleChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'disabledToggleId'}],
+ }),
+ clean({
+ _id: 'conditionToggleChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'conditionToggleId'}],
+ }),
+ clean({
+ _id: 'conditionToggleGrandChildId',
+ type: 'folder',
+ ancestors: [{id: 'charId'}, {id: 'conditionToggleId'}, {id: 'conditionToggleChildId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildComputation/tests/linkCalculationDependencies.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/linkCalculationDependencies.testFn.js
new file mode 100644
index 00000000..29fd101a
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/linkCalculationDependencies.testFn.js
@@ -0,0 +1,62 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ const hasLink = computation.dependencyGraph.hasLink;
+ const prop = (id) => computation.propsById[id];
+ assert.isTrue(
+ !!hasLink('childId.description.inlineCalculations[0]', 'spellListId'),
+ 'Ancestor references of parent in inline calculations should create dependency'
+ );
+ assert.isTrue(
+ !!hasLink('grandchildId.dc', 'spellListId'),
+ 'References to higher ancestor should create dependency'
+ );
+ assert.isTrue(
+ !!hasLink('grandchildId.dc', 'strength'),
+ 'Variable references create dependencies'
+ );
+ assert.isTrue(
+ !!hasLink('grandchildId.dc', 'wisdom'),
+ 'Variable references create dependencies even if the attributes don\'t exist'
+ );
+ assert.equal(
+ prop('strengthId').baseValue.parseError.message, 'Unexpected end of input',
+ 'Parse errors should be stored on the calculation doc'
+ );
+}
+
+var testProperties = [
+ clean({
+ _id: 'spellListId',
+ type: 'spellList',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'childId',
+ type: 'spell',
+ description: {
+ text: 'DC {#spellList.dc} save or suck'
+ },
+ ancestors: [{id: 'charId'}, {id: 'spellListId'}],
+ }),
+ clean({
+ _id: 'grandchildId',
+ type: 'savingThrow',
+ dc: {
+ calculation: '#spellList.dc + strength + wisdom.modifier'
+ },
+ ancestors: [{id: 'charId'}, {id: 'spellListId'}, {id: 'childId'}],
+ }),
+ clean({
+ _id: 'strengthId',
+ type: 'attribute',
+ variableName: 'strength',
+ baseValue: {
+ calculation: '15 + ',
+ },
+ ancestors: [{id: 'charId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildComputation/tests/linkInventory.testFn.js b/app/imports/api/engine/computation/buildComputation/tests/linkInventory.testFn.js
new file mode 100644
index 00000000..cb22544a
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/linkInventory.testFn.js
@@ -0,0 +1,89 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ const hasLink = computation.dependencyGraph.hasLink;
+
+ assert.isTrue(
+ !!hasLink('weightEquipment', 'equippedAttunedItemId'),
+ 'weight of equipment depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('valueEquipment', 'equippedAttunedItemId'),
+ 'value of equipment depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('weightTotal', 'equippedAttunedItemId'),
+ 'weightTotal depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('valueTotal', 'equippedAttunedItemId'),
+ 'valueTotal depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('weightCarried', 'equippedAttunedItemId'),
+ 'weightCarried depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('valueCarried', 'equippedAttunedItemId'),
+ 'valueCarried depends on equipped items'
+ );
+ assert.isTrue(
+ !!hasLink('weightCarried', 'containerId'),
+ 'weightCarried depends on top level containers'
+ );
+ assert.isTrue(
+ !!hasLink('valueCarried', 'containerId'),
+ 'valueCarried depends on top level containers'
+ );
+ assert.isFalse(
+ !!hasLink('weightCarried', 'childContainerId'),
+ 'weightCarried does not depend on nested containers'
+ );
+ assert.isFalse(
+ !!hasLink('valueCarried', 'childContainerId'),
+ 'valueCarried does not depend on nested containers'
+ );
+ assert.isTrue(
+ !!hasLink('containerId', 'childContainerId'),
+ 'containers depend on their child containers'
+ );
+ assert.isTrue(
+ !!hasLink('childContainerId', 'grandchildItemId'),
+ 'containers depend on their child items'
+ );
+}
+
+var testProperties = [
+ clean({
+ _id: 'equippedAttunedItemId',
+ type: 'item',
+ equipped: true,
+ attuned: true,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'containerId',
+ type: 'container',
+ carried: true,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'childContainerId',
+ type: 'container',
+ carried: true,
+ ancestors: [{id: 'charId'}, {id: 'containerId'}],
+ }),
+ clean({
+ _id: 'childItemId',
+ type: 'item',
+ ancestors: [{id: 'charId'}, {id: 'containerId'}],
+ }),
+ clean({
+ _id: 'grandchildItemId',
+ type: 'item',
+ ancestors: [{id: 'charId'}, {id: 'containerId'}, {id: 'childContainerId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildComputation/tests/linkTypeDependencies.testfn.js b/app/imports/api/engine/computation/buildComputation/tests/linkTypeDependencies.testfn.js
new file mode 100644
index 00000000..b8f9d4f1
--- /dev/null
+++ b/app/imports/api/engine/computation/buildComputation/tests/linkTypeDependencies.testfn.js
@@ -0,0 +1,27 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ const getLink = computation.dependencyGraph.hasLink;
+ const getNode = computation.dependencyGraph.getNode;
+
+ assert.equal(
+ getLink('strength', 'strengthId').data, 'definition',
+ 'Links variable names to props that define them'
+ );
+ assert.exists(
+ getNode('strength'),
+ 'Creates variable name nodes when attributes define them'
+ );
+}
+
+var testProperties = [
+ clean({
+ _id: 'strengthId',
+ type: 'attribute',
+ variableName: 'strength',
+ ancestors: [{id: 'charId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/buildCreatureComputation.js b/app/imports/api/engine/computation/buildCreatureComputation.js
new file mode 100644
index 00000000..e78b248c
--- /dev/null
+++ b/app/imports/api/engine/computation/buildCreatureComputation.js
@@ -0,0 +1,94 @@
+import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
+import CreatureProperties,
+ { DenormalisedOnlyCreaturePropertySchema as denormSchema }
+ from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import computedOnlySchemas from '/imports/api/properties/computedOnlyPropertySchemasIndex.js';
+import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex.js';
+import linkInventory from './buildComputation/linkInventory.js';
+import walkDown from './utility/walkdown.js';
+import parseCalculationFields from './buildComputation/parseCalculationFields.js';
+import computeInactiveStatus from './buildComputation/computeInactiveStatus.js';
+import computeToggleDependencies from './buildComputation/computeToggleDependencies.js';
+import linkCalculationDependencies from './buildComputation/linkCalculationDependencies.js';
+import linkTypeDependencies from './buildComputation/linkTypeDependencies.js';
+import computeSlotQuantityFilled from './buildComputation/computeSlotQuantityFilled.js';
+import CreatureComputation from './CreatureComputation.js';
+import removeSchemaFields from './buildComputation/removeSchemaFields.js';
+
+/**
+ * Store index of properties
+ * recompute static tree-based enabled/disabled status
+ * Build a dependency graph
+ * id -> id dependencies for docs that rely on other docs directly
+ * id -> variable deps for docs that rely on a variable's value
+ * variable -> id deps for variables that are impacted by docs
+ */
+
+/**
+ * Forseen issues: Anything that computes during the build step will not obey
+ * computed toggles
+ */
+
+export default function buildCreatureComputation(creatureId){
+ const properties = getProperties(creatureId);
+ const computation = buildComputationFromProps(properties);
+ return computation;
+}
+
+function getProperties(creatureId){
+ return CreatureProperties.find({
+ 'ancestors.id': creatureId,
+ 'removed': {$ne: true},
+ }, {
+ sort: {order: 1}
+ }).fetch();
+}
+
+export function buildComputationFromProps(properties){
+
+ const computation = new CreatureComputation(properties);
+ // 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
+ // can limit the recompute to connected parts of the graph
+ // Each node's data represents a prop or a virtual prop like a variable
+ // Each link's data is a string representing the link type
+ const dependencyGraph = computation.dependencyGraph;
+
+ // Process the properties one by one
+ properties.forEach(prop => {
+
+ let computedSchema = computedOnlySchemas[prop.type];
+ removeSchemaFields([computedSchema, denormSchema], prop);
+
+ // Add a place to store all the computation details
+ prop._computationDetails = {
+ calculations: [],
+ inlineCalculations: [],
+ toggleAncestors: [],
+ };
+
+ // Parse all the calculations
+ parseCalculationFields(prop, computedSchemas);
+
+ });
+
+ // Get all the properties as trees based on their ancestors
+ let forest = nodeArrayToTree(properties);
+ // Walk the property trees computing things that need to be inherited
+ walkDown(forest, node => {
+ computeInactiveStatus(node);
+ computeToggleDependencies(node, dependencyGraph);
+ computeSlotQuantityFilled(node, dependencyGraph);
+ });
+
+ // Link the inventory dependencies
+ linkInventory(forest, dependencyGraph);
+
+ // Link functions that require the above to be complete
+ properties.forEach(prop => {
+ linkTypeDependencies(dependencyGraph, prop, computation);
+ linkCalculationDependencies(dependencyGraph, prop, computation);
+ });
+ return computation;
+}
diff --git a/app/imports/api/engine/computation/buildCreatureComputation.test.js b/app/imports/api/engine/computation/buildCreatureComputation.test.js
new file mode 100644
index 00000000..33b3830b
--- /dev/null
+++ b/app/imports/api/engine/computation/buildCreatureComputation.test.js
@@ -0,0 +1,43 @@
+import '/imports/api/simpleSchemaConfig.js';
+import { buildComputationFromProps } from './buildCreatureComputation.js';
+import { assert } from 'chai';
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import computeInactiveStatus from './buildComputation/tests/computeInactiveStatus.testFn.js';
+import computeSlotQuantityFilled from './buildComputation/tests/computeSlotQuantityFilled.testFn.js';
+import computeToggleDependencies from './buildComputation/tests/computeToggleDependencies.testFn.js';
+import linkCalculationDependencies from './buildComputation/tests/linkCalculationDependencies.testFn.js';
+import linkInventory from './buildComputation/tests/linkInventory.testFn.js';
+import linkTypeDependencies from './buildComputation/tests/linkTypeDependencies.testFn.js';
+
+describe('buildComputation', function(){
+ it('Builds something at all', function(){
+ let computation = buildComputationFromProps(testProperties);
+ assert.exists(computation);
+ });
+ it('Computes inactive status', computeInactiveStatus);
+ it('Computes slot fill quantity', computeSlotQuantityFilled);
+ it('Links toggle dependencies', computeToggleDependencies);
+ it('Links calculation dependencies', linkCalculationDependencies);
+ it('Links inventory stats', linkInventory);
+ it('Links type dependencies', linkTypeDependencies);
+});
+
+var testProperties = [
+ clean({
+ _id: 'attributeId123',
+ type: 'attribute',
+ variableName: 'strength',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '1 + 2 + 3',
+ },
+ description: {
+ text: 'strength is {strength}'
+ }
+ }),
+];
+
+function clean(prop){
+ let schema = CreatureProperties.simpleSchema(prop);
+ return schema.clean(prop);
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType.js b/app/imports/api/engine/computation/computeComputation/computeByType.js
new file mode 100644
index 00000000..cba44f28
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType.js
@@ -0,0 +1,18 @@
+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 slot from './computeByType/computeSlot.js';
+import container from './computeByType/computeContainer.js';
+import _calculation from './computeByType/computeCalculation.js';
+
+export default Object.freeze({
+ _variable,
+ _calculation,
+ action,
+ attribute,
+ container,
+ skill,
+ slot,
+ spell: action,
+});
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js
new file mode 100644
index 00000000..c52dec00
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeAction.js
@@ -0,0 +1,35 @@
+export default function computeAction(computation, node){
+ const prop = node.data;
+ if (prop.uses){
+ prop.usesLeft = prop.uses.value - (prop.usesUsed || 0);
+ if (!prop.usesLeft){
+ prop.insufficientResources = true;
+ }
+ }
+ computeResources(computation, node);
+ if (!prop.resources) return;
+ prop.resources.itemsConsumed.forEach(itemConsumed => {
+ if (!itemConsumed.itemId) return;
+ if (itemConsumed.available < itemConsumed.quantity?.value){
+ prop.insufficientResources = true;
+ }
+ });
+ prop.resources.attributesConsumed.forEach(attConsumed => {
+ if (!attConsumed.variableName) return;
+ if (attConsumed.available < attConsumed.quantity?.value){
+ prop.insufficientResources = true;
+ }
+ });
+}
+
+function computeResources(computation, node){
+ const resources = node.data?.resources;
+ if (!resources) return;
+ resources.attributesConsumed.forEach(attConsumed => {
+ if (!attConsumed.variableName) return;
+ const att = computation.scope[attConsumed.variableName];
+ if (!att._id) return;
+ attConsumed.available = att.value;
+ attConsumed.statName = att.name;
+ });
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeAttribute.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeAttribute.js
new file mode 100644
index 00000000..13c43072
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeAttribute.js
@@ -0,0 +1,9 @@
+// If we compute this attribute without a variable name, it just
+// uses its base value and damage since no effects can target it
+// If this attribute does have a variable name, it is recomputed later
+// by computeVariableAsAttribute
+export default function computeAttribute(computation, node){
+ const prop = node.data;
+ prop.total = prop.baseValue?.value || 0;
+ prop.value = prop.total - (prop.damage || 0);
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
new file mode 100644
index 00000000..054fc7f9
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
@@ -0,0 +1,48 @@
+import evaluateCalculation from '../../utility/evaluateCalculation.js';
+
+export default function computeCalculation(computation, node){
+ const calcObj = node.data;
+ evaluateCalculation(calcObj, computation.scope);
+ aggregateCalculationEffects(node, computation);
+}
+
+export function aggregateCalculationEffects(node, computation){
+ const calcObj = node.data;
+ delete calcObj.effects;
+ computation.dependencyGraph.forEachLinkedNode(
+ node.id,
+ (linkedNode, link) => {
+ // Only effect links
+ if (link.data !== 'effect') return;
+ // That have effect data
+ if (!linkedNode.data) return;
+ // Ignore inactive props
+ if (linkedNode.data.inactive) return;
+
+ // Collate effects
+ calcObj.effects = calcObj.effects || [];
+ calcObj.effects.push({
+ _id: linkedNode.data._id,
+ name: linkedNode.data.name,
+ operation: linkedNode.data.operation,
+ amount: linkedNode.data.amount && {
+ value: linkedNode.data.amount.value,
+ //parseNode: linkedNode.data.amount.parseNode,
+ },
+ // ancestors: linkedNode.data.ancestors,
+ });
+ },
+ true // enumerate only outbound links
+ );
+ if (calcObj.effects && typeof calcObj.value === 'number'){
+ calcObj.baseValue = calcObj.value;
+ calcObj.effects.forEach(effect => {
+ if (
+ effect.operation === 'add' &&
+ effect.amount && typeof effect.amount.value === 'number'
+ ){
+ calcObj.value += effect.amount.value
+ }
+ });
+ }
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeContainer.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeContainer.js
new file mode 100644
index 00000000..61b4b79a
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeContainer.js
@@ -0,0 +1,20 @@
+import aggregate from './computeVariable/aggregate/index.js';
+
+export default function computeContainer(computation, node){
+ if (!node.data) node.data = {};
+ aggregateLinks(computation, node);
+}
+
+function aggregateLinks(computation, node){
+ computation.dependencyGraph.forEachLinkedNode(
+ node.id,
+ (linkedNode, link) => {
+ if (!linkedNode.data) linkedNode.data = {};
+ // Ignore inactive props
+ if (linkedNode.data.inactive) return;
+ // Aggregate inventory links
+ aggregate.inventory({node, linkedNode, link});
+ },
+ true // enumerate only outbound links
+ );
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js
new file mode 100644
index 00000000..2e79d18a
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeSkill.js
@@ -0,0 +1,28 @@
+// If we compute this skill without a variable name, it just
+// uses its base value, proficiency, and damage since no effects can target it
+// If this skill does have a variable name, it is recomputed later
+// by computeVariableAsSkill
+export default function computeSkill(computation, node){
+ const prop = node.data;
+ prop.proficiency = prop.baseProficiency;
+ let profBonus = computation.scope['proficiencyBonus']?.value || 0;
+ // Multiply the proficiency bonus by the actual proficiency
+ if(prop.proficiency === 0.49){
+ // Round down proficiency bonus in the special case
+ profBonus = Math.floor(profBonus * 0.5);
+ } else {
+ profBonus = Math.ceil(profBonus * prop.proficiency);
+ }
+
+ const ability = computation.scope[prop.ability];
+ prop.abilityMod = ability?.modifier || 0;
+
+ const base = prop.baseValue?.value || 0;
+
+ let result = base + prop.abilityMod + profBonus;
+ if (Number.isFinite(result)){
+ result = Math.floor(result);
+ }
+
+ prop.value = result;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js
new file mode 100644
index 00000000..654655ba
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeSlot.js
@@ -0,0 +1,6 @@
+export default function computSlot(computation, node){
+ const prop = node.data;
+ if (prop.quantityExpected){
+ prop.spaceLeft = prop.quantityExpected - prop.totalFilled;
+ }
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js
new file mode 100644
index 00000000..2f5f2964
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js
@@ -0,0 +1,89 @@
+import aggregate from './computeVariable/aggregate/index.js';
+import computeVariableAsAttribute from './computeVariable/computeVariableAsAttribute.js';
+import computeVariableAsSkill from './computeVariable/computeVariableAsSkill.js';
+import computeVariableAsConstant from './computeVariable/computeVariableAsConstant.js';
+import computeVariableAsClass from './computeVariable/computeVariableAsClass.js';
+import computeVariableAsToggle from './computeVariable/computeVariableAsToggle.js';
+import computeImplicitVariable from './computeVariable/computeImplicitVariable.js';
+
+export default function computeVariable(computation, node){
+ const scope = computation.scope;
+ if (!node.data) node.data = {};
+ aggregateLinks(computation, node);
+ combineAggregations(computation, node);
+ if (node.data.definingProp){
+ // Add the defining variable to the scope
+ scope[node.id] = node.data.definingProp
+ } else {
+ // Otherwise add an implicit variable to the scope
+ scope[node.id] = computeImplicitVariable(node);
+ }
+}
+
+function aggregateLinks(computation, node){
+ computation.dependencyGraph.forEachLinkedNode(
+ node.id,
+ (linkedNode, link) => {
+ if (!linkedNode.data) linkedNode.data = {};
+ // Ignore inactive props
+ if (linkedNode.data.inactive) return;
+ // Apply all the aggregations
+ let arg = {node, linkedNode, link, computation};
+ aggregate.classLevel(arg);
+ aggregate.damageMultiplier(arg);
+ aggregate.definition(arg);
+ aggregate.effect(arg);
+ aggregate.inventory(arg);
+ aggregate.proficiency(arg);
+ },
+ true // enumerate only outbound links
+ );
+}
+
+function combineAggregations(computation, node){
+ combineMultiplierAggregator(node);
+ node.data.overridenProps?.forEach(prop => {
+ computeVariableProp(computation, node, prop);
+ });
+ computeVariableProp(computation, node, node.data.definingProp);
+}
+
+function computeVariableProp(computation, node, prop){
+ if (!prop) return;
+ if (prop.type === 'attribute'){
+ computeVariableAsAttribute(computation, node, prop);
+ } else if (prop.type === 'skill'){
+ computeVariableAsSkill(computation, node, prop);
+ } else if (prop.type === 'constant'){
+ computeVariableAsConstant(computation, node, prop);
+ } else if (prop.type === 'class'){
+ computeVariableAsClass(computation, node, prop);
+ } else if (prop.type === 'toggle'){
+ computeVariableAsToggle(computation, node, prop);
+ }
+}
+
+function combineMultiplierAggregator(node){
+ // get a reference to the aggregator
+ const aggregator = node.data.multiplierAggregator;
+ if (!aggregator) return;
+
+ // Combine
+ let value;
+ if (aggregator.immunityCount){
+ value = 0;
+ } else if (
+ aggregator.resistanceCount &&
+ !aggregator.vulnerabilityCount
+ ){
+ value = 0.5;
+ } else if (
+ !aggregator.resistanceCount &&
+ aggregator.vulnerabilityCount
+ ){
+ value = 2;
+ } else {
+ value = 1;
+ }
+ node.data.damageMultiplyValue = value;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateClassLevel.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateClassLevel.js
new file mode 100644
index 00000000..22197dc2
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateClassLevel.js
@@ -0,0 +1,16 @@
+export default function aggregateClassLevel({node, linkedNode, link}){
+ if (link.data === 'classLevel'){
+ if (node.data.inactive) return;
+ if (!node.data.classLevelAggregator) node.data.classLevelAggregator = {
+ levelsFilled: [true], // Level 0 is always filled
+ level: 0,
+ };
+ let linkedProp = linkedNode.data;
+ let aggregator = node.data.classLevelAggregator;
+ if (linkedProp.level > aggregator.level) aggregator.level = linkedProp.level;
+ aggregator.levelsFilled[linkedProp.level] = true;
+ } else if (link.data === 'level'){
+ node.data.baseValue = (node.data.baseValue || 0) +
+ linkedNode.data.classLevelAggregator.level;
+ }
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js
new file mode 100644
index 00000000..15b5309c
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js
@@ -0,0 +1,22 @@
+export default function aggregateDamageMultipliers({node, linkedNode, link}){
+ if (link.data !== 'damageMultiplier') return;
+ const multiplierValue = linkedNode.data.value;
+ if (multiplierValue === undefined) return;
+ // Store an aggregator, its presence indicates damage multipliers target this
+ // variable
+ if (!node.data.multiplierAggregator) node.data.multiplierAggregator = {
+ immunityCount: 0,
+ resistanceCount: 0,
+ vulnerabilityCount: 0,
+ }
+ // Store a short reference to the aggregator
+ const aggregator = node.data.multiplierAggregator;
+ // Sum the counts of each type of multiplier
+ if (multiplierValue === 0){
+ aggregator.immunityCount += 1;
+ } else if (multiplierValue === 0.5){
+ aggregator.resistanceCount += 1;
+ } else if (multiplierValue === 2){
+ aggregator.vulnerabilityCount += 1;
+ }
+}
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
new file mode 100644
index 00000000..66fbe79a
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDefinition.js
@@ -0,0 +1,34 @@
+
+export default function aggregateDefinition({node, linkedNode, link}){
+ // Look at all definition links
+ if (link.data !== 'definition') return;
+
+ // Store which property is THE defining property and which are overridden
+ const prop = linkedNode.data;
+ // get current defining prop
+ const definingProp = node.data.definingProp;
+ // Find the last defining prop
+ if (!definingProp || prop.order > definingProp.order){
+ // override the current defining prop
+ overrideProp(definingProp, node);
+ // set this prop as the new defining prop
+ node.data.definingProp = prop;
+ } else {
+ overrideProp(prop, node);
+ }
+
+ // Aggregate the base value due to the defining properties
+ const propBaseValue = prop.baseValue?.value;
+
+ if (propBaseValue === undefined) return;
+ if (node.data.baseValue === undefined || propBaseValue > node.data.baseValue){
+ node.data.baseValue = propBaseValue;
+ }
+}
+
+function overrideProp(prop, node){
+ if (!prop) return;
+ prop.overridden = true;
+ if (!node.data.overriddenProps) node.data.overriddenProps = [];
+ node.data.overriddenProps.push(prop);
+}
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
new file mode 100644
index 00000000..8cf47d36
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateEffect.js
@@ -0,0 +1,91 @@
+export default function aggregateEffect({node, linkedNode, link}){
+ if (link.data !== 'effect') return;
+ // store the effect aggregator, its presence indicates that the variable is
+ // targeted by effects
+ if (!node.data.effectAggregator) node.data.effectAggregator = {
+ base: undefined,
+ add: 0,
+ mul: 1,
+ min: Number.NEGATIVE_INFINITY,
+ max: Number.POSITIVE_INFINITY,
+ advantage: 0,
+ disadvantage: 0,
+ passiveAdd: undefined,
+ fail: 0,
+ set: undefined,
+ conditional: [],
+ rollBonus: [],
+ };
+
+ // Store a summary of the effect itself
+ node.data.effects = node.data.effects || [];
+ 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},
+ // ancestors: linkedNode.data.ancestors,
+ });
+
+ // get a shorter reference to the aggregator document
+ const aggregator = node.data.effectAggregator;
+ // 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;
+ // Aggregate the effect based on its operation
+ switch(linkedNode.data.operation){
+ case 'base':
+ // Take the largest base value
+ if (Number.isFinite(result)){
+ if(Number.isFinite(aggregator.base)){
+ aggregator.base = Math.max(aggregator.base, result);
+ } else {
+ aggregator.base = result;
+ }
+ }
+ break;
+ case 'add':
+ // Add all adds together
+ aggregator.add += result || 0;
+ break;
+ case 'mul':
+ // Multiply the muls together
+ aggregator.mul *= result || 1;
+ break;
+ case 'min':
+ // Take the largest min value
+ aggregator.min = result > aggregator.min ? result : aggregator.min;
+ break;
+ case 'max':
+ // Take the smallest max value
+ aggregator.max = result < aggregator.max ? result : aggregator.max;
+ break;
+ case 'set':
+ // Take the highest set value
+ aggregator.set = aggregator.set === undefined || (result > aggregator.set) ?
+ result :
+ aggregator.set;
+ break;
+ case 'advantage':
+ // Sum number of advantages
+ aggregator.advantage++;
+ break;
+ case 'disadvantage':
+ // Sum number of disadvantages
+ aggregator.disadvantage++;
+ break;
+ case 'passiveAdd':
+ // Add all passive adds together
+ aggregator.passiveAdd = (aggregator.passiveAdd || 0) + result;
+ break;
+ case 'fail':
+ // Sum number of fails
+ aggregator.fail++;
+ break;
+ case 'conditional':
+ // Store array of conditionals
+ aggregator.conditional.push(linkedNode.data.text);
+ break;
+ }
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateInventory.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateInventory.js
new file mode 100644
index 00000000..dc36e1d8
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateInventory.js
@@ -0,0 +1,60 @@
+export default function aggregateInventory({node, linkedNode, link}){
+ let linkedProp = linkedNode.data || {};
+ const prop = node.data;
+
+ switch (link.data){
+ case 'attunedItem':
+ prop.baseValue = (prop.baseValue || 0) + 1;
+ return;
+
+ case 'equippedItem':
+ if (node.id === 'weightEquipment'){
+ prop.baseValue = (prop.baseValue || 0) + weight(linkedProp);
+ } else if (node.id === 'valueEquipment'){
+ prop.baseValue = (prop.baseValue || 0) + value(linkedProp);
+ }
+ return;
+
+ case 'containerContents':
+ // Add this property's weights and values to the container
+ if (!prop.weightless){
+ prop.contentsWeight = (prop.contentsWeight || 0) + weight(linkedProp);
+ if (prop.carried){
+ prop.carriedWeight = (prop.carriedWeight || 0) + carriedWeight(linkedProp);
+ }
+ }
+ prop.contentsValue = (prop.contentsValue || 0) + value(linkedProp);
+ if (prop.carried) {
+ prop.carriedValue = (prop.carriedValue || 0) + carriedValue(linkedProp);
+ }
+ return;
+
+ case 'inventoryStats':
+ if (node.id === 'weightTotal'){
+ prop.baseValue = (prop.baseValue || 0) + weight(linkedProp);
+ } else if (node.id === 'valueTotal'){
+ prop.baseValue = (prop.baseValue || 0) + value(linkedProp);
+ } else if (node.id === 'weightCarried'){
+ prop.baseValue = (prop.baseValue || 0) + carriedWeight(linkedProp);
+ } else if (node.id === 'valueCarried'){
+ prop.baseValue = (prop.baseValue || 0) + carriedValue(linkedProp);
+ }
+ return;
+ }
+}
+
+function weight(prop){
+ return (prop.weight || 0) + (prop.contentsWeight || 0);
+}
+
+function carriedWeight(prop){
+ return (prop.weight || 0) + (prop.carriedWeight || 0);
+}
+
+function value (prop){
+ return (prop.value || 0) + (prop.contentsValue || 0);
+}
+
+function carriedValue (prop){
+ return (prop.value || 0) + (prop.carriedValue || 0);
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateProficiency.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateProficiency.js
new file mode 100644
index 00000000..ea4383e6
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateProficiency.js
@@ -0,0 +1,21 @@
+export default function aggregateProficiency({node, linkedNode, link}){
+ if (
+ link.data !== 'proficiency' &&
+ !(link.data === 'definition' && linkedNode.data.type === 'skill')
+ ) return;
+ let proficiency;
+ if (link.data === 'proficiency'){
+ proficiency = linkedNode.data.value || 0;
+ } else if (link.data === 'definition' && linkedNode.data.type === 'skill'){
+ proficiency = linkedNode.data.baseProficiency || 0;
+ } else {
+ return;
+ }
+ // Store the highest proficiency
+ if (
+ node.data.proficiency === undefined ||
+ proficiency > node.data.proficiency
+ ){
+ node.data.proficiency = proficiency;
+ }
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/index.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/index.js
new file mode 100644
index 00000000..4b455afa
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/index.js
@@ -0,0 +1,15 @@
+import definition from './aggregateDefinition.js';
+import damageMultiplier from './aggregateDamageMultiplier.js';
+import effect from './aggregateEffect.js';
+import proficiency from './aggregateProficiency.js';
+import classLevel from './aggregateClassLevel.js';
+import inventory from './aggregateInventory.js';
+
+export default Object.freeze({
+ classLevel,
+ damageMultiplier,
+ definition,
+ effect,
+ inventory,
+ proficiency,
+});
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js
new file mode 100644
index 00000000..c10c164d
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js
@@ -0,0 +1,46 @@
+import getAggregatorResult from './getAggregatorResult.js';
+
+/*
+ * Variables with effects, proficiencies, or damage multipliers but no defining
+ * properties are added to the scope as implicit variables
+ */
+ export default function computeImplicitVariable(node){
+ const prop = {};
+ const result = getAggregatorResult(node);
+ if (result !== undefined){
+ prop.value = result;
+ }
+ if (node.data.proficiency !== undefined){
+ prop.proficiency = node.data.proficiency;
+ }
+
+ // denormalise class level aggregator
+ let classLevelAgg = node.data.classLevelAggregator;
+ if (classLevelAgg){
+ prop.level = classLevelAgg.level;
+ }
+
+ // denormalise the effect aggregator fields
+ const aggregator = node.data.effectAggregator;
+ if (aggregator){
+ if (aggregator.advantage && !aggregator.disadvantage){
+ prop.advantage = 1;
+ } else if (aggregator.disadvantage && !aggregator.advantage){
+ prop.advantage = -1;
+ } else {
+ prop.advantage = 0;
+ }
+ // Passive bonus
+ prop.passiveBonus = aggregator.passiveAdd;
+ // conditional benefits
+ prop.conditionalBenefits = aggregator.conditional;
+ // Roll bonuses
+ prop.rollBonus = aggregator.rollBonus;
+ // Forced to fail
+ prop.fail = aggregator.fail;
+ // Rollbonus
+ prop.rollBonuses = aggregator.rollBonus;
+ }
+
+ return prop;
+ }
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js
new file mode 100644
index 00000000..21137749
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js
@@ -0,0 +1,29 @@
+import getAggregatorResult from './getAggregatorResult.js';
+
+export default function computeVariableAsAttribute(computation, node, prop){
+ let result = getAggregatorResult(node, prop) || 0;
+
+ prop.total = result;
+ prop.value = prop.total - (prop.damage || 0);
+
+ // Proficiency
+ prop.proficiency = node.data.proficiency;
+
+ // Ability scores get modifiers
+ if (prop.attributeType === 'ability'){
+ prop.modifier = Math.floor((prop.value - 10) / 2);
+ }
+
+ // Hit dice denormalise constitution modifier
+ if (prop.attributeType === 'hitDice') {
+ prop.constitutionMod = computation.scope['constitution']?.modifier || 0;
+ }
+
+ // Stats that have no effects or base value can be hidden
+ prop.hide = !node.data.effectAggregator &&
+ prop.baseValue === undefined ||
+ undefined
+
+ // Store effects
+ prop.effects = node.data.effects;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsClass.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsClass.js
new file mode 100644
index 00000000..187072fc
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsClass.js
@@ -0,0 +1,13 @@
+export default function computeVariableAsAttribute(computation, node, prop){
+ let classLevelAgg = node.data.classLevelAggregator;
+ if (!classLevelAgg) return;
+ prop.level = classLevelAgg.level;
+ for (let index = 1; index < classLevelAgg.level; index++ ){
+ const filled = classLevelAgg.levelsFilled[index];
+ if (!filled){
+ if (!prop.missingLevels) prop.missingLevels = [];
+ prop.missingLevels.push(index);
+ }
+ }
+ prop.missingLevels?.sort((a, b) => a - b);
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsConstant.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsConstant.js
new file mode 100644
index 00000000..898d2c5e
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsConstant.js
@@ -0,0 +1,13 @@
+import { parse } from '/imports/parser/parser.js';
+
+export default function computeVariableAsConstant(computation, node, prop){
+ let string = prop.calculation;
+ if (!string) return;
+ let parseNode;
+ try {
+ parseNode = parse(string);
+ } catch (e) {
+ return;
+ }
+ prop.value = parseNode;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js
new file mode 100644
index 00000000..da0ed54b
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js
@@ -0,0 +1,98 @@
+import aggregate from './aggregate/index.js';
+
+export default function computeVariableAsSkill(computation, node, prop){
+ // Skills are based on some ability Modifier
+ let ability = computation.scope[prop.ability];
+ prop.abilityMod = ability?.modifier || 0;
+
+ // Inherit the ability's skill effects and proficiencies if skill is not a save
+ if (prop.skillType !== 'save' && ability){
+ aggregateAbilityEffects({
+ computation,
+ skillNode: node,
+ abilityNode: computation.dependencyGraph.getNode(prop.ability)
+ });
+ }
+
+ // Proficiency
+ prop.proficiency = node.data.proficiency;
+
+ // Get the character's proficiency bonus to apply
+ let profBonus = computation.scope['proficiencyBonus']?.value || 0;
+
+ // Multiply the proficiency bonus by the actual proficiency
+ if(prop.proficiency === 0.49){
+ // Round down proficiency bonus in the special case
+ profBonus = Math.floor(profBonus * 0.5);
+ } else {
+ profBonus = Math.ceil(profBonus * prop.proficiency);
+ }
+
+ // Combine everything to get the final result
+ const statBase = node.data.baseValue;
+ const aggregator = node.data.effectAggregator;
+
+ // If there is no aggregator, determine if the prop can hide, then exit
+ if (!aggregator){
+ prop.hide = statBase === undefined &&
+ prop.proficiency == 0 ||
+ undefined;
+ prop.value = (statBase || 0) + prop.abilityMod + profBonus;
+ return;
+ }
+ // Combine aggregator
+ const base = (statBase > aggregator.base ? statBase : aggregator.base) || 0;
+ let result = (base + prop.abilityMod + profBonus + aggregator.add) * aggregator.mul;
+ if (result < aggregator.min) result = aggregator.min;
+ if (result > aggregator.max) result = aggregator.max;
+ if (aggregator.set !== undefined) {
+ result = aggregator.set;
+ }
+ if (Number.isFinite(result)){
+ result = Math.floor(result);
+ }
+ prop.value = result;
+ // Advantage/disadvantage
+ if (aggregator.advantage && !aggregator.disadvantage){
+ prop.advantage = 1;
+ } else if (aggregator.disadvantage && !aggregator.advantage){
+ prop.advantage = -1;
+ } else {
+ prop.advantage = 0;
+ }
+ // Passive bonus
+ prop.passiveBonus = aggregator.passiveAdd;
+ // conditional benefits
+ prop.conditionalBenefits = aggregator.conditional;
+ // Roll bonuses
+ prop.rollBonus = aggregator.rollBonus;
+ // Forced to fail
+ prop.fail = aggregator.fail;
+ // Rollbonus
+ prop.rollBonuses = aggregator.rollBonus;
+}
+
+function aggregateAbilityEffects({computation, skillNode, abilityNode}){
+ computation.dependencyGraph.forEachLinkedNode(
+ abilityNode.id,
+ (linkedNode, link) => {
+ if (!linkedNode.data) linkedNode.data = {};
+ // Ignore inactive props
+ if (linkedNode.data.inactive) return;
+ // Check that the link is a valid effect/proficiency to pass on
+ // to a skill from its ability
+ if (link.data === 'effect'){
+ if (![
+ 'advantage', 'disadvantage', 'passiveAdd', 'fail'
+ ].includes(linkedNode.data.operation)){
+ return;
+ }
+ }
+ // Apply the aggregations
+ let arg = {node: skillNode, linkedNode, link};
+ aggregate.effect(arg);
+ aggregate.proficiency(arg);
+ },
+ true // enumerate only outbound links
+ );
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsToggle.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsToggle.js
new file mode 100644
index 00000000..0c3c41a9
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsToggle.js
@@ -0,0 +1,7 @@
+import getAggregatorResult from './getAggregatorResult.js';
+
+export default function computeVariableAsToggle(computation, node, prop){
+ let result = getAggregatorResult(node, prop) || 0;
+
+ prop.value = !!result || !!prop.enabled || !!prop.condition?.value;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js
new file mode 100644
index 00000000..95905904
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js
@@ -0,0 +1,44 @@
+import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js';
+
+export default function getAggregatorResult(node){
+ // Work out the base value as the greater of the deining stat value or
+ // the damage multiplier value
+ // This baseValue comes from aggregating definitions
+ let statBase = node.data.baseValue;
+
+ const damageMultiplyValue = node.data.damageMultiplyValue;
+ if (statBase === undefined || damageMultiplyValue > statBase){
+ statBase = damageMultiplyValue;
+ }
+ // get a reference to the aggregator
+ const aggregator = node.data.effectAggregator;
+
+ // Without effects just return the defining base value
+ if (!aggregator) return statBase;
+
+ let base;
+ if (!Number.isFinite(aggregator.base)){
+ base = statBase || 0;
+ } else if (!Number.isFinite(statBase)){
+ base = aggregator.base || 0;
+ } else {
+ base = Math.max(aggregator.base, statBase);
+ }
+ let result = (base + aggregator.add) * aggregator.mul;
+ if (result < aggregator.min) {
+ result = aggregator.min;
+ }
+ if (result > aggregator.max) {
+ result = aggregator.max;
+ }
+ if (aggregator.set !== undefined) {
+ result = aggregator.set;
+ }
+ if (!node.definingProp?.decimal && Number.isFinite(result)){
+ result = Math.floor(result);
+ } else if (Number.isFinite(result)){
+ result = stripFloatingPointOddities(result);
+ }
+
+ return result;
+}
diff --git a/app/imports/api/engine/computation/computeComputation/computeToggles.js b/app/imports/api/engine/computation/computeComputation/computeToggles.js
new file mode 100644
index 00000000..3a49fe53
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/computeToggles.js
@@ -0,0 +1,13 @@
+export default function evaluateToggles(computation, node){
+ let prop = node.data;
+ if (!prop) return;
+ let toggles = prop._computationDetails?.toggleAncestors;
+ if (!toggles) return;
+ toggles.forEach(toggle => {
+ if (prop.inactive || !toggle.condition) return;
+ if (!toggle.condition.value){
+ prop.inactive = true;
+ prop.deactivatedByToggle = true;
+ }
+ });
+}
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeAction.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeAction.testFn.js
new file mode 100644
index 00000000..096db3d4
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeAction.testFn.js
@@ -0,0 +1,114 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+
+ const prop = computation.propsById['actionId'];
+ assert.equal(prop.summary.value, 'test summary 3 without referencing anything 7');
+ assert.equal(prop.description.value, 'test description 12 with reference 0.25 prop');
+ assert.equal(prop.uses.value, 7);
+ assert.equal(prop.usesLeft, 2);
+
+ const rolled = computation.propsById['rolledDescriptionId'];
+ assert.equal(rolled.summary.value, 'test roll gets compiled d4 + 4 properly');
+
+ const itemConsumed = prop.resources.itemsConsumed[0];
+ assert.equal(itemConsumed.quantity.value, 3);
+ assert.equal(itemConsumed.available, 27);
+ assert.equal(itemConsumed.itemName, 'Arrow');
+ assert.equal(itemConsumed.itemIcon, 'itemIcon');
+ assert.equal(itemConsumed.itemColor, 'itemColor');
+
+ const attConsumed = prop.resources.attributesConsumed[0];
+ assert.equal(attConsumed.quantity.value, 4);
+ assert.equal(attConsumed.available, 9);
+ assert.equal(attConsumed.statName, 'Resource Var');
+}
+
+var testProperties = [
+ clean({
+ _id: 'actionId',
+ type: 'action',
+ ancestors: [{id: 'charId'}],
+ summary: {
+ text: 'test summary {1 + 2} without referencing anything {3 + 4}',
+ },
+ description: {
+ text: 'test description {inlineRef * 2} with reference {1/4} prop',
+ },
+ resources: {
+ itemsConsumed: [{
+ _id: 'itemConsumedId',
+ itemId: 'arrowId',
+ tag: 'arrow',
+ quantity: {
+ calculation: 'itemConsumedQuantity',
+ },
+ }],
+ attributesConsumed: [{
+ _id: 'attConsumedId',
+ variableName: 'resourceVar',
+ quantity: {
+ calculation: 'resourceConsumedQuantity'
+ }
+ }],
+ },
+ uses: {
+ calculation: 'nonExistantProperty + 7',
+ },
+ usesUsed: 5,
+ }),
+ clean({
+ _id: 'rolledDescriptionId',
+ type: 'action',
+ ancestors: [{id: 'charId'}],
+ summary: {
+ text: 'test roll gets compiled {1d4 + (2 + 2)} properly',
+ },
+ }),
+ clean({
+ _id: 'numItemsConumedId',
+ type: 'attribute',
+ variableName: 'itemConsumedQuantity',
+ baseValue: {
+ calculation: '3',
+ },
+ }),
+ clean({
+ _id: 'numResourceConumedId',
+ type: 'attribute',
+ variableName: 'resourceConsumedQuantity',
+ baseValue: {
+ calculation: '4',
+ },
+ }),
+ clean({
+ _id: 'resourceVarId',
+ name: 'Resource Var',
+ type: 'attribute',
+ variableName: 'resourceVar',
+ baseValue: {
+ calculation: '9',
+ },
+ }),
+ clean({
+ _id: 'inlineRefResourceId',
+ type: 'attribute',
+ variableName: 'inlineRef',
+ baseValue: {
+ calculation: '1 + 5',
+ },
+ }),
+ clean({
+ _id: 'arrowId',
+ type: 'item',
+ name: 'Arrow',
+ quantity: 27,
+ icon: 'itemIcon',
+ color: 'itemColor',
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeAttribute.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeAttribute.testFn.js
new file mode 100644
index 00000000..dfc9fb86
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeAttribute.testFn.js
@@ -0,0 +1,104 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const prop = id => computation.propsById[id];
+ const scope = variableName => computation.scope[variableName];
+ assert.equal(prop('emptyId').value, 0, 'calculates empty props to zero');
+ assert.equal(prop('noVariableNameId').value, 8, 'Calculates props without a variable name');
+ assert.equal(prop('strengthId').value, 12, 'applies base values');
+ assert.equal(prop('strengthId').modifier, 1, 'calculates modifiers for basic properties');
+ assert.equal(scope('strength').modifier, 1, 'Access properties via variables');
+ assert.equal(prop('referencesDexId').value, 4, 'Access variable properties in calculations');
+ assert.equal(prop('hitDiceId').constitutionMod, 5, 'Hit dice get constitution modifier');
+ assert.equal(prop('overriddenDexId').overridden, true, 'override properties with the same variable name');
+ assert.equal(
+ prop('parseErrorId').baseValue.value, null,
+ 'Parse errors should null the value'
+ );
+}
+
+var testProperties = [
+ clean({
+ _id: 'emptyId',
+ type: 'attribute',
+ attributeType: 'ability',
+ }),
+ clean({
+ _id: 'noVariableNameId',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '8'
+ },
+ }),
+ clean({
+ _id: 'strengthId',
+ variableName: 'strength',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '12'
+ },
+ }),
+ clean({
+ _id: 'overriddenDexId',
+ variableName: 'dexterity',
+ type: 'attribute',
+ attributeType: 'ability',
+ order: 1,
+ baseValue: {
+ calculation: '15'
+ },
+ }),
+ clean({
+ _id: 'dexterityId',
+ variableName: 'dexterity',
+ type: 'attribute',
+ attributeType: 'ability',
+ order: 2,
+ baseValue: {
+ calculation: '15'
+ },
+ }),
+ clean({
+ _id: 'constitutionId',
+ variableName: 'constitution',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '21'
+ },
+ }),
+ clean({
+ _id: 'referencesDexId',
+ variableName: 'refDex',
+ type: 'attribute',
+ baseValue: {
+ calculation: 'dexterity.modifier + 2'
+ },
+ }),
+ clean({
+ _id: 'hitDiceId',
+ variableName: 'hd',
+ type: 'attribute',
+ attributeType: 'hitDice',
+ hitDiceSize: 'd8',
+ baseValue: {
+ calculation: '4'
+ },
+ }),
+ clean({
+ _id: 'parseErrorId',
+ variableName: 'parseError',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '12 +'
+ },
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeClasses.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeClasses.testFn.js
new file mode 100644
index 00000000..2c6bdc8f
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeClasses.testFn.js
@@ -0,0 +1,60 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const scope = id => computation.scope[id];
+ const prop = id => computation.propsById[id];
+ assert.equal(scope('level').value, 5);
+ assert.equal(scope('wizard').level, 4);
+ assert.equal(prop('wizzardId').level, 4);
+ assert.deepEqual(prop('wizzardId').missingLevels, [3]);
+}
+
+var testProperties = [
+ clean({
+ _id: 'wizzardId',
+ type: 'class',
+ variableName: 'wizard',
+ classType: 'startingClass',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'rangerId',
+ type: 'class',
+ variableName: 'ranger',
+ classType: 'multiClass',
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'wiz1Id',
+ type: 'classLevel',
+ variableName: 'wizard',
+ level: 1,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'wiz2Id',
+ type: 'classLevel',
+ variableName: 'wizard',
+ level: 2,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'wiz4Id',
+ type: 'classLevel',
+ variableName: 'wizard',
+ level: 4,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'rang1Id',
+ type: 'classLevel',
+ variableName: 'ranger',
+ level: 1,
+ ancestors: [{id: 'charId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeConstants.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeConstants.testFn.js
new file mode 100644
index 00000000..94fd414e
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeConstants.testFn.js
@@ -0,0 +1,28 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const prop = id => computation.propsById[id];
+ assert.equal(prop('attId').value, 6);
+}
+
+var testProperties = [
+ clean({
+ _id: 'constId',
+ type: 'constant',
+ variableName: 'arrayConstant',
+ calculation: '[2, 4, 6, 8, 10]',
+ }),
+ clean({
+ _id: 'attId',
+ type: 'attribute',
+ baseValue: {
+ calculation: 'arrayConstant[3]',
+ },
+ ancestors: [{id: 'charId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js
new file mode 100644
index 00000000..ef1ec555
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js
@@ -0,0 +1,40 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const scope = id => computation.scope[id];
+ assert.equal(scope('blugeoningMultiplier').value, 1);
+ assert.equal(scope('customDamageMultiplier').value, 0.5);
+ assert.equal(scope('slashingMultiplier').value, 0);
+}
+
+var testProperties = [
+ clean({
+ _id: 'resistanceId',
+ type: 'damageMultiplier',
+ damageTypes: ['blugeoning', 'customDamage'],
+ value: 0.5,
+ }),
+ clean({
+ _id: 'vulnerabilityId',
+ type: 'damageMultiplier',
+ damageTypes: ['blugeoning'],
+ value: 2,
+ }),
+ clean({
+ _id: 'slashResistId',
+ type: 'damageMultiplier',
+ damageTypes: ['slashing'],
+ value: 0.5,
+ }),
+ clean({
+ _id: 'slashInvulnId',
+ type: 'damageMultiplier',
+ damageTypes: ['slashing'],
+ value: 0,
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeEffects.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeEffects.testFn.js
new file mode 100644
index 00000000..8650a9e2
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeEffects.testFn.js
@@ -0,0 +1,59 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const prop = id => computation.propsById[id];
+ assert.equal(prop('strengthId').value, 26);
+}
+
+var testProperties = [
+ clean({
+ _id: 'strengthId',
+ variableName: 'strength',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '8'
+ },
+ }),
+ clean({
+ _id: 'strength2Id',
+ variableName: 'strength',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '10'
+ },
+ }),
+ clean({
+ _id: 'strengthBaseId',
+ type: 'effect',
+ operation: 'base',
+ amount: {
+ calculation: '10 + 2'
+ },
+ stats: ['strength'],
+ }),
+ clean({
+ _id: 'strengthAddId',
+ type: 'effect',
+ operation: 'add',
+ amount: {
+ calculation: '1'
+ },
+ stats: ['strength'],
+ }),
+ clean({
+ _id: 'strengthMulId',
+ type: 'effect',
+ operation: 'mul',
+ amount: {
+ calculation: '2'
+ },
+ stats: ['strength'],
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeInventory.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeInventory.testFn.js
new file mode 100644
index 00000000..d2cc2f1d
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeInventory.testFn.js
@@ -0,0 +1,69 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const prop = id => computation.propsById[id];
+ const scope = id => computation.scope[id].value;
+
+ assert.equal(scope('weightEquipment'), 2);
+ assert.equal(scope('valueEquipment'), 3);
+
+ assert.equal(scope('itemsAttuned'), 1);
+
+ assert.equal(prop('childContainerId').carriedWeight, 23);
+ assert.equal(prop('childContainerId').contentsWeight, 23);
+
+ assert.equal(scope('weightCarried'), 58);
+
+ assert.equal(scope('weightCarried'), 58);
+ assert.equal(scope('valueCarried'), 71);
+
+ assert.equal(scope('weightTotal'), 58);
+ assert.equal(scope('valueTotal'), 71);
+}
+
+var testProperties = [
+ clean({
+ _id: 'equippedAttunedItemId',
+ type: 'item',
+ equipped: true,
+ attuned: true,
+ weight: 2,
+ value: 3,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'containerId',
+ type: 'container',
+ carried: true,
+ weight: 5,
+ value: 7,
+ ancestors: [{id: 'charId'}],
+ }),
+ clean({
+ _id: 'childContainerId',
+ type: 'container',
+ carried: true,
+ weight: 11,
+ value: 13,
+ ancestors: [{id: 'charId'}, {id: 'containerId'}],
+ }),
+ clean({
+ _id: 'childItemId',
+ type: 'item',
+ weight: 17,
+ value: 19,
+ ancestors: [{id: 'charId'}, {id: 'containerId'}],
+ }),
+ clean({
+ _id: 'grandchildItemId',
+ type: 'item',
+ weight: 23,
+ value: 29,
+ ancestors: [{id: 'charId'}, {id: 'containerId'}, {id: 'childContainerId'}],
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeSkills.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeSkills.testFn.js
new file mode 100644
index 00000000..2557296a
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/computeSkills.testFn.js
@@ -0,0 +1,84 @@
+import { buildComputationFromProps } from '/imports/api/engine/computation/buildCreatureComputation.js';
+import { assert } from 'chai';
+import computeCreatureComputation from '../../computeCreatureComputation.js';
+import clean from '../../utility/cleanProp.testFn.js';
+
+export default function(){
+ const computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ const prop = id => computation.propsById[id];
+
+ assert.equal(prop('atheleticsId').proficiency, 2, 'Inherits proficiency from ability');
+ assert.equal(prop('atheleticsId').value, 7, 'calculates value correctly');
+ assert.equal(prop('atheleticsId').advantage, 1, 'Inherits advantage from ability');
+ assert.equal(prop('strengthSaveId').advantage, undefined, 'Saves don\'t inherit effects');
+ assert.equal(prop('strengthSaveId').value, 4, 'Saves calculate correctly');
+
+ assert.equal(prop('acrobaticsId').value, 1);
+ assert.equal(prop('toolsId').value, 7);
+}
+
+var testProperties = [
+ clean({
+ _id: 'strengthId',
+ variableName: 'strength',
+ type: 'attribute',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '12'
+ },
+ }),
+ clean({
+ _id: 'profBonusId',
+ variableName: 'proficiencyBonus',
+ type: 'attribute',
+ attributeType: 'modifier',
+ baseValue: {
+ calculation: '3'
+ },
+ }),
+ clean({
+ _id: 'atheleticsId',
+ variableName: 'athletics',
+ type: 'skill',
+ skillType: 'skill',
+ ability: 'strength',
+ }),
+ clean({
+ _id: 'acrobaticsId',
+ variableName: 'acrobatics',
+ type: 'skill',
+ skillType: 'skill',
+ baseProficiency: 0.49,
+ }),
+ clean({
+ _id: 'toolsId',
+ variableName: 'tools',
+ type: 'skill',
+ skillType: 'tool',
+ baseProficiency: 0.5,
+ baseValue: {
+ calculation: '5',
+ }
+ }),
+ clean({
+ _id: 'strengthAdvantageId',
+ type: 'effect',
+ operation: 'advantage',
+ stats: ['strength'],
+ }),
+ clean({
+ _id: 'strengthProficiencyId',
+ type: 'proficiency',
+ value: 2,
+ stats: ['strength'],
+ }),
+ clean({
+ _id: 'strengthSaveId',
+ variableName: 'strengthSave',
+ type: 'skill',
+ skillType: 'save',
+ ability: 'strength',
+ baseProficiency: 1,
+ }),
+];
diff --git a/app/imports/api/engine/computation/computeComputation/tests/index.js b/app/imports/api/engine/computation/computeComputation/tests/index.js
new file mode 100644
index 00000000..b9c8baf7
--- /dev/null
+++ b/app/imports/api/engine/computation/computeComputation/tests/index.js
@@ -0,0 +1,34 @@
+import computeAction from './computeAction.testFn.js';
+import computeAttribute from './computeAttribute.testFn.js';
+import computeClasses from './computeClasses.testFn.js';
+import computeConstants from './computeConstants.testFn.js';
+import computeInventory from './computeInventory.testFn.js';
+import computeDamageMultipliers from './computeDamageMultipliers.testFn.js';
+import computeEffects from './computeEffects.testFn.js';
+import computeSkills from './computeSkills.testFn.js';
+
+export default [{
+ text: 'Computes actions',
+ fn: computeAction,
+},{
+ text: 'Computes attributes',
+ fn: computeAttribute,
+},{
+ text: 'Computes classes',
+ fn: computeClasses,
+},{
+ text: 'Computes constants',
+ fn: computeConstants,
+},{
+ text: 'Computes inventory',
+ fn: computeInventory,
+},{
+ text: 'Computes damage multipliers',
+ fn: computeDamageMultipliers,
+},{
+ text: 'Computes effects',
+ fn: computeEffects,
+},{
+ text: 'Computes skills',
+ fn: computeSkills,
+}];
diff --git a/app/imports/api/engine/computation/computeCreatureComputation.js b/app/imports/api/engine/computation/computeCreatureComputation.js
new file mode 100644
index 00000000..5eba016d
--- /dev/null
+++ b/app/imports/api/engine/computation/computeCreatureComputation.js
@@ -0,0 +1,58 @@
+import computeToggles from '/imports/api/engine/computation/computeComputation/computeToggles.js';
+import computeByType from '/imports/api/engine/computation/computeComputation/computeByType.js';
+import embedInlineCalculations from './utility/embedInlineCalculations.js';
+
+export default function computeCreatureComputation(computation){
+ const stack = [];
+ // Computation scope of {variableName: prop}
+ const graph = computation.dependencyGraph;
+ // Add all nodes to the stack
+ graph.forEachNode(node => {
+ node._visited = false;
+ node._visitedChildren = false;
+ stack.push(node)
+ });
+ // Depth first traversal of nodes
+ while (stack.length){
+ let top = stack[stack.length - 1];
+ if (top._visited){
+ // The object has already been computed, skip
+ stack.pop();
+ } else if (top._visitedChildren){
+ // Mark the object as visited and remove from stack
+ top._visited = true;
+ stack.pop();
+ // Compute the top object of the stack
+ compute(computation, top);
+ } else {
+ top._visitedChildren = true;
+ // Push dependencies to graph to be computed first
+ pushDependenciesToStack(top.id, graph, stack);
+ }
+ }
+
+ // Finish the props after the dependency graph has been traversed
+ computation.props.forEach(finalizeProp);
+}
+
+function compute(computation, node){
+ // Determine the prop's active status by its toggles
+ computeToggles(computation, node);
+ // Compute the property by type
+ computeByType[node.data?.type || '_variable']?.(computation, node);
+}
+
+function pushDependenciesToStack(nodeId, graph, stack){
+ graph.forEachLinkedNode(nodeId, linkedNode => {
+ stack.push(linkedNode);
+ }, true);
+}
+
+function finalizeProp(prop){
+ // Embed the inline calculations
+ prop._computationDetails?.inlineCalculations?.forEach(inlineCalcObj => {
+ embedInlineCalculations(inlineCalcObj);
+ });
+ // Clean up the computation details
+ delete prop._computationDetails;
+}
diff --git a/app/imports/api/engine/computation/computeCreatureComputation.test.js b/app/imports/api/engine/computation/computeCreatureComputation.test.js
new file mode 100644
index 00000000..f64e6e7f
--- /dev/null
+++ b/app/imports/api/engine/computation/computeCreatureComputation.test.js
@@ -0,0 +1,34 @@
+import computeCreatureComputation from './computeCreatureComputation.js';
+import { buildComputationFromProps } from './buildCreatureComputation.js';
+import { assert } from 'chai';
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import computeTests from './computeComputation/tests/index.js';
+
+describe('Compute compuation', function(){
+ it('Computes something at all', function(){
+ let computation = buildComputationFromProps(testProperties);
+ computeCreatureComputation(computation);
+ assert.exists(computation);
+ });
+ computeTests.forEach(test => it(test.text, test.fn));
+});
+
+var testProperties = [
+ clean({
+ _id: 'attributeId123',
+ type: 'attribute',
+ variableName: 'strength',
+ attributeType: 'ability',
+ baseValue: {
+ calculation: '1 + 2 + 3',
+ },
+ description: {
+ text: 'strength is {strength}'
+ }
+ }),
+];
+
+function clean(prop){
+ let schema = CreatureProperties.simpleSchema(prop);
+ return schema.clean(prop);
+}
diff --git a/app/imports/api/engine/computation/utility/applyFnToKey.js b/app/imports/api/engine/computation/utility/applyFnToKey.js
new file mode 100644
index 00000000..0d4005af
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/applyFnToKey.js
@@ -0,0 +1,51 @@
+import { get } from 'lodash';
+
+export default function applyFnToKey(doc, key, fn){
+ if (key.includes('.$')){
+ applyToArrayKey(doc, key, fn);
+ } else {
+ applyToSingleKey(doc, key, fn);
+ }
+}
+
+function applyToSingleKey(doc, key, fn){
+ // call the function with the current value and document for context
+ fn(doc, key);
+}
+
+/**
+ * Applies the given function to all instances in a document key
+ * key.$.with.$.subdocs will apply to all key[i...n].with[j...m].subdocs
+ * Warning: Order might be confusing, it will traverse the deepest array in order
+ * but the shallower arrays in reverse order
+ */
+function applyToArrayKey(doc, key, fn){
+ const keySplit = key.split('.$');
+ // Stack based depth first traversal of arrays
+ const array = get(doc, keySplit[0]);
+ if (!array) return;
+ const stack = [{
+ array,
+ paths: keySplit.slice(1),
+ currentPath: keySplit[0],
+ indices: [],
+ }];
+ while(stack.length){
+ const state = stack.pop();
+ for (let index in state.array){
+ const currentPath = `${state.currentPath}[${index}]${state.paths[0]}`
+ if (state.paths.length == 1){
+ applyToSingleKey(doc, currentPath, fn);
+ } else {
+ const array = get(doc, currentPath);
+ if (!array) return;
+ stack.push({
+ array,
+ paths: state.paths.slice(1),
+ currentPath,
+ indices: [...state.indices, index],
+ });
+ }
+ }
+ }
+}
diff --git a/app/imports/api/engine/computation/utility/applyFnToKey.test.js b/app/imports/api/engine/computation/utility/applyFnToKey.test.js
new file mode 100644
index 00000000..5e1b3a3c
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/applyFnToKey.test.js
@@ -0,0 +1,60 @@
+import applyFnToKey from './applyFnToKey.js';
+import { assert } from 'chai';
+import { get } from 'lodash';
+
+describe('apply function to key', function(){
+ it('uses a basic key correctly', function(){
+ let obj = getStartingObject();
+ applyFnToKey(obj, 'fox.name', (doc, key) => {
+ assert.equal(obj, doc);
+ assert.equal(key, 'fox.name');
+ assert.equal(get(doc, key), 'foxy');
+ });
+ });
+ it('uses a single nested key correctly', function(){
+ let obj = getStartingObject();
+ let foxSounds = [];
+ applyFnToKey(obj, 'fox.sound.$', (doc, key) => {
+ foxSounds.push(get(doc, key));
+ });
+ assert.include(foxSounds, 'wah');
+ assert.include(foxSounds, 'tjoef');
+ assert.include(foxSounds, 'kek');
+ });
+ it('uses a double nested key correctly', function(){
+ let obj = getStartingObject();
+ let birdSounds = [];
+ applyFnToKey(obj, 'birds.$.sound.$', (doc, key) => {
+ birdSounds.push(get(doc, key));
+ });
+ assert.include(birdSounds, 'koer');
+ assert.include(birdSounds, 'hello');
+ assert.include(birdSounds, 'squawk');
+ });
+});
+
+function getStartingObject(){
+ return {
+ fox: {
+ name: 'foxy',
+ sound: [
+ 'tjoef',
+ 'kek',
+ 'wah'
+ ]
+ },
+ birds: [{
+ name: 'pigeon',
+ sound: [
+ 'koer',
+ ]
+ },{
+ name: 'parrot',
+ sound: [
+ 'hello',
+ 'cracker?',
+ 'squawk',
+ ]
+ }]
+ }
+}
diff --git a/app/imports/api/engine/computation/utility/cleanProp.testFn.js b/app/imports/api/engine/computation/utility/cleanProp.testFn.js
new file mode 100644
index 00000000..1c845150
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/cleanProp.testFn.js
@@ -0,0 +1,6 @@
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+
+export default function cleanProp(prop){
+ let schema = CreatureProperties.simpleSchema(prop);
+ return schema.clean(prop);
+}
diff --git a/app/imports/api/engine/computation/utility/collate.js b/app/imports/api/engine/computation/utility/collate.js
new file mode 100644
index 00000000..d6599d65
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/collate.js
@@ -0,0 +1,12 @@
+// Collate the array with the given value or array of values, creating the
+// array if it doesn't exist
+export default function collate(array, toAdd){
+ if (Array.isArray(toAdd) && toAdd.length){
+ if (!array) array = [];
+ array.push(...toAdd);
+ } else if (toAdd) {
+ if (!array) array = [];
+ array.push(toAdd);
+ }
+ return array;
+}
diff --git a/app/imports/api/engine/computation/utility/cyrb53.js b/app/imports/api/engine/computation/utility/cyrb53.js
new file mode 100644
index 00000000..d93cb1c8
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/cyrb53.js
@@ -0,0 +1,14 @@
+// Simple hash function from
+// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
+// Don't use for security
+export default function(str, seed = 0) {
+ let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
+ for (let i = 0, ch; i < str.length; i++) {
+ ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+ h1 = Math.imul(h1 ^ (h1>>>16), 2246822507) ^ Math.imul(h2 ^ (h2>>>13), 3266489909);
+ h2 = Math.imul(h2 ^ (h2>>>16), 2246822507) ^ Math.imul(h1 ^ (h1>>>13), 3266489909);
+ return 4294967296 * (2097151 & h2) + (h1>>>0);
+}
diff --git a/app/imports/api/engine/computation/utility/embedInlineCalculations.js b/app/imports/api/engine/computation/utility/embedInlineCalculations.js
new file mode 100644
index 00000000..096cf921
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/embedInlineCalculations.js
@@ -0,0 +1,12 @@
+import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX.js';
+
+export default function embedInlineCalculations(inlineCalcObj){
+ const string = inlineCalcObj.text;
+ const calculations = inlineCalcObj.inlineCalculations;
+ if (!string || !calculations) return;
+ let index = 0;
+ inlineCalcObj.value = string.replace(INLINE_CALCULATION_REGEX, substring => {
+ let calc = calculations[index++];
+ return (calc && 'value' in calc) ? calc.value : substring;
+ });
+}
diff --git a/app/imports/api/engine/computation/utility/evaluateCalculation.js b/app/imports/api/engine/computation/utility/evaluateCalculation.js
new file mode 100644
index 00000000..580cafde
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/evaluateCalculation.js
@@ -0,0 +1,19 @@
+import resolve, { toString } from '/imports/parser/resolve.js';
+
+export default function evaluateCalculation(calculation, scope, givenContext){
+ const parseNode = calculation.parseNode;
+ const fn = calculation._parseLevel;
+ const calculationScope = {...calculation._localScope, ...scope};
+ const {result: resultNode, context} = resolve(fn, parseNode, calculationScope, givenContext);
+ calculation.errors = context.errors;
+ if (resultNode?.parseType === 'constant'){
+ calculation.value = resultNode.value;
+ } else if (resultNode?.parseType === 'error'){
+ calculation.value = null;
+ } else {
+ calculation.value = toString(resultNode);
+ }
+ // remove the working fields
+ delete calculation._parseLevel;
+ delete calculation._localScope;
+}
diff --git a/app/imports/api/creature/computation/engine/findAncestorByType.js b/app/imports/api/engine/computation/utility/findAncestorByType.js
similarity index 62%
rename from app/imports/api/creature/computation/engine/findAncestorByType.js
rename to app/imports/api/engine/computation/utility/findAncestorByType.js
index f8bafdf4..492cc6d0 100644
--- a/app/imports/api/creature/computation/engine/findAncestorByType.js
+++ b/app/imports/api/engine/computation/utility/findAncestorByType.js
@@ -1,8 +1,8 @@
-export default function findAncestorByType({type, prop, memo}){
+export default function findAncestorByType(prop, type, propsById){
if (!prop || !prop.ancestors) return;
let ancestor;
for (let i = prop.ancestors.length - 1; i >= 0; i--){
- ancestor = memo.propsById[prop.ancestors[i].id];
+ ancestor = propsById[prop.ancestors[i].id];
if (ancestor && ancestor.type === type){
return ancestor;
}
diff --git a/app/imports/ui/utility/stripFloatingPointOddities.js b/app/imports/api/engine/computation/utility/stripFloatingPointOddities.js
similarity index 100%
rename from app/imports/ui/utility/stripFloatingPointOddities.js
rename to app/imports/api/engine/computation/utility/stripFloatingPointOddities.js
diff --git a/app/imports/api/engine/computation/utility/walkdown.js b/app/imports/api/engine/computation/utility/walkdown.js
new file mode 100644
index 00000000..e0c4728a
--- /dev/null
+++ b/app/imports/api/engine/computation/utility/walkdown.js
@@ -0,0 +1,8 @@
+export default function walkDown(tree, callback){
+ let stack = [...tree];
+ while(stack.length){
+ let node = stack.pop();
+ callback(node);
+ stack.push(...node.children);
+ }
+}
diff --git a/app/imports/api/creature/computation/engine/writeAlteredProperties.js b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js
similarity index 90%
rename from app/imports/api/creature/computation/engine/writeAlteredProperties.js
rename to app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js
index 6927ea9e..cd0372cb 100644
--- a/app/imports/api/creature/computation/engine/writeAlteredProperties.js
+++ b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js
@@ -1,12 +1,12 @@
import { Meteor } from 'meteor/meteor'
-import { isEqual, forOwn } from 'lodash';
+import { EJSON } from 'meteor/ejson';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import propertySchemasIndex from '/imports/api/properties/computedOnlyPropertySchemasIndex.js';
-export default function writeAlteredProperties(memo){
+export default function writeAlteredProperties(computation){
let bulkWriteOperations = [];
// Loop through all properties on the memo
- forOwn(memo.propsById, changed => {
+ computation.props.forEach(changed => {
let schema = propertySchemasIndex[changed.type];
if (!schema){
console.warn('No schema for ' + changed.type);
@@ -14,9 +14,8 @@ export default function writeAlteredProperties(memo){
}
let id = changed._id;
let op = undefined;
- let original = memo.originalPropsById[id];
+ let original = computation.originalPropsById[id];
let keys = [
- 'dependencies',
'inactive',
'deactivatedBySelf',
'deactivatedByAncestor',
@@ -36,7 +35,7 @@ function addChangedKeysToOp(op, keys, original, changed) {
// Loop through all keys that can be changed by computation
// and compile an operation that sets all those keys
for (let key of keys){
- if (!isEqual(original[key], changed[key])){
+ if (!EJSON.equals(original[key], changed[key])){
if (!op) op = newOperation(original._id, changed.type);
let value = changed[key];
if (value === undefined){
@@ -92,6 +91,7 @@ function writePropertiesSequentially(bulkWriteOps){
bypassCollection2: true,
});
});
+ //if (bulkWriteOps.length) console.log(`Wrote ${bulkWriteOps.length} props`);
}
// This is more efficient on the database, but significantly less efficient
diff --git a/app/imports/api/engine/computation/writeComputation/writeScope.js b/app/imports/api/engine/computation/writeComputation/writeScope.js
new file mode 100644
index 00000000..3661df4a
--- /dev/null
+++ b/app/imports/api/engine/computation/writeComputation/writeScope.js
@@ -0,0 +1,10 @@
+import Creatures from '/imports/api/creature/creatures/Creatures.js';
+
+export default function writeScope(creatureId, scope){
+ // Remove large properties that aren't likely to be accessed
+ for (const key in scope){
+ delete scope[key].parent;
+ delete scope[key].ancestors;
+ }
+ Creatures.update(creatureId, {$set: {variables: scope}});
+}
diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.js
new file mode 100644
index 00000000..58fe1ed6
--- /dev/null
+++ b/app/imports/api/engine/computeCreature.js
@@ -0,0 +1,18 @@
+import buildCreatureComputation from './computation/buildCreatureComputation.js';
+import computeCreatureComputation from './computation/computeCreatureComputation.js';
+import writeAlteredProperties from './computation/writeComputation/writeAlteredProperties.js';
+import writeScope from './computation/writeComputation/writeScope.js';
+
+export default function computeCreature(creatureId){
+ const computation = buildCreatureComputation(creatureId);
+ computeCreatureComputation(computation);
+ writeAlteredProperties(computation);
+ writeScope(creatureId, computation.scope);
+}
+
+// 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/files/s3FileStorage.js b/app/imports/api/files/s3FileStorage.js
new file mode 100644
index 00000000..43dafb33
--- /dev/null
+++ b/app/imports/api/files/s3FileStorage.js
@@ -0,0 +1,246 @@
+// 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';
+import { FilesCollection } from 'meteor/ostrio:files';
+import stream from 'stream';
+import S3 from 'aws-sdk/clients/s3';
+
+/* See fs-extra and graceful-fs NPM packages */
+/* For better i/o performance */
+import fs from 'fs';
+import { promises as fsp } from 'fs';
+
+/* Example: S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "endpoint": "xxx""}}' meteor */
+if (process.env.S3) {
+ Meteor.settings.s3 = JSON.parse(process.env.S3).s3;
+}
+
+const s3Conf = Meteor.settings.s3 || {};
+Meteor.settings.useS3 = !!(
+ s3Conf && s3Conf.key && s3Conf.secret && s3Conf.bucket && s3Conf.endpoint
+);
+
+const bound = Meteor.bindEnvironment((callback) => {
+ return callback();
+});
+
+let createS3FilesCollection;
+
+/* Check settings existence in `Meteor.settings` */
+/* This is the best practice for app security */
+if (Meteor.isServer && Meteor.settings.useS3) {
+ // Create a new S3 object
+ const s3 = new S3({
+ accessKeyId: s3Conf.key,
+ secretAccessKey: s3Conf.secret,
+ endpoint: s3Conf.endpoint,
+ sslEnabled: true, // optional
+ httpOptions: {
+ timeout: 6000,
+ agent: false
+ }
+ });
+
+ createS3FilesCollection = function({
+ collectionName,
+ storagePath,
+ onBeforeUpload,
+ debug = Meteor.isProduction,
+ allowClientCode = false,
+ }){
+ const collection = new FilesCollection({
+ collectionName,
+ storagePath,
+ onBeforeUpload,
+ onAfterUpload(fileRef){
+ // Start moving files to AWS:S3
+ // after fully received by the Meteor server
+
+ // Run through each of the uploaded file
+ each(fileRef.versions, (vRef, version) => {
+ // We use Random.id() instead of real file's _id
+ // to secure files from reverse engineering on the AWS client
+ const filePath = 'files/' + (Random.id()) + '-' + version + '.' + fileRef.extension;
+
+ // Create the AWS:S3 object.
+ // Feel free to change the storage class from, see the documentation,
+ // `STANDARD_IA` is the best deal for low access files.
+ // Key is the file name we are creating on AWS:S3, so it will be like files/XXXXXXXXXXXXXXXXX-original.XXXX
+ // Body is the file stream we are sending to AWS
+ s3.putObject({
+ // ServerSideEncryption: 'AES256', // Optional
+ StorageClass: 'STANDARD',
+ Bucket: s3Conf.bucket,
+ Key: filePath,
+ Body: fs.createReadStream(vRef.path),
+ ContentType: vRef.type,
+ }, (error) => {
+ bound(() => {
+ if (error) {
+ console.error(error);
+ } else {
+ // Update FilesCollection with link to the file at AWS
+ const upd = { $set: {} };
+ upd['$set']['versions.' + version + '.meta.pipePath'] = filePath;
+
+ this.collection.update({
+ _id: fileRef._id
+ }, upd, (updError) => {
+ if (updError) {
+ console.error(updError);
+ } else {
+ // Unlink original files from FS after successful upload to AWS:S3
+ this.unlink(this.collection.findOne(fileRef._id), version);
+ }
+ });
+ }
+ });
+ });
+ });
+ },
+ interceptDownload(http, fileRef, version) {
+ // Intercept access to the file
+ // And redirect request to AWS:S3
+ let path;
+
+ if (fileRef && fileRef.versions && fileRef.versions[version] && fileRef.versions[version].meta && fileRef.versions[version].meta.pipePath) {
+ path = fileRef.versions[version].meta.pipePath;
+ }
+
+ if (path) {
+ // If file is successfully moved to AWS:S3
+ // We will pipe request to AWS:S3
+ // So, original link will stay always secure
+
+ // To force ?play and ?download parameters
+ // and to keep original file name, content-type,
+ // content-disposition, chunked "streaming" and cache-control
+ // we're using low-level .serve() method
+ const opts = {
+ Bucket: s3Conf.bucket,
+ Key: path
+ };
+
+ if (http.request.headers.range) {
+ const vRef = fileRef.versions[version];
+ let range = clone(http.request.headers.range);
+ const array = range.split(/bytes=([0-9]*)-([0-9]*)/);
+ const start = parseInt(array[1]);
+ let end = parseInt(array[2]);
+ if (isNaN(end)) {
+ // Request data from AWS:S3 by small chunks
+ end = (start + this.chunkSize) - 1;
+ if (end >= vRef.size) {
+ end = vRef.size - 1;
+ }
+ }
+ opts.Range = `bytes=${start}-${end}`;
+ http.request.headers.range = `bytes=${start}-${end}`;
+ }
+
+ const fileColl = this;
+ s3.getObject(opts, function (error) {
+ if (error) {
+ console.error(error);
+ if (!http.response.finished) {
+ http.response.end();
+ }
+ } else {
+ if (http.request.headers.range && this.httpResponse.headers['content-range']) {
+ // Set proper range header in according to what is returned from AWS:S3
+ http.request.headers.range = this.httpResponse.headers['content-range'].split('/')[0].replace('bytes ', 'bytes=');
+ }
+
+ const dataStream = new stream.PassThrough();
+ fileColl.serve(http, fileRef, fileRef.versions[version], version, dataStream);
+ dataStream.end(this.data.Body);
+ }
+ });
+
+ return true;
+ }
+ // While file is not yet uploaded to AWS:S3
+ // It will be served file from FS
+ return false;
+ },
+ debug,
+ allowClientCode,
+ });
+ // Intercept FilesCollection's remove method to remove file from AWS:S3
+ const _origRemove = collection.remove;
+ collection.remove = function (search) {
+ const cursor = this.collection.find(search);
+ cursor.forEach((fileRef) => {
+ each(fileRef.versions, (vRef) => {
+ if (vRef && vRef.meta && vRef.meta.pipePath) {
+ // Remove the object from AWS:S3 first, then we will call the original FilesCollection remove
+ s3.deleteObject({
+ Bucket: s3Conf.bucket,
+ Key: vRef.meta.pipePath,
+ }, (error) => {
+ bound(() => {
+ if (error) {
+ console.error(error);
+ }
+ });
+ });
+ }
+ });
+ });
+
+ //remove original file from database
+ _origRemove.call(this, search);
+ };
+
+ collection.readJSONFile = async function(file){
+ // If there is the pipepath, use s3 to get the file
+ if (file?.versions?.original?.meta?.pipePath){
+ const path = file.versions.original.meta.pipePath;
+ const data = await s3.getObject({
+ Bucket: s3Conf.bucket,
+ Key: path
+ }).promise();
+ return JSON.parse(data.Body.toString('utf-8'));
+ } else {
+ // Otherwise use the normal filesystem
+ const fileString = await fsp.readFile(file.path, 'utf8');
+ return JSON.parse(fileString);
+ }
+ };
+
+ return collection;
+ }
+} else {
+ if (Meteor.isServer){
+ console.log('No S3 details specified, files will be stored in the local filesystem');
+ }
+ createS3FilesCollection = function({
+ collectionName,
+ storagePath,
+ onBeforeUpload,
+ debug = Meteor.isProduction,
+ allowClientCode = false,
+ }){
+ const collection = new FilesCollection({
+ collectionName,
+ storagePath,
+ onBeforeUpload,
+ debug,
+ allowClientCode,
+ });
+
+ if (Meteor.isServer){
+ // Use the normal file system to read files
+ collection.readJSONFile = async function(file){
+ const fileString = await fsp.readFile(file.path, 'utf8');
+ return JSON.parse(fileString);
+ };
+ }
+
+ return collection;
+ }
+}
+
+export { createS3FilesCollection };
diff --git a/app/imports/api/library/LibraryNodes.js b/app/imports/api/library/LibraryNodes.js
index 8b8ec90c..5d04a0e6 100644
--- a/app/imports/api/library/LibraryNodes.js
+++ b/app/imports/api/library/LibraryNodes.js
@@ -32,6 +32,15 @@ let LibraryNodeSchema = new SimpleSchema({
type: String,
max: STORAGE_LIMITS.tagLength,
},
+ libraryTags: {
+ type: Array,
+ defaultValue: [],
+ maxCount: STORAGE_LIMITS.tagCount,
+ },
+ 'libraryTags.$': {
+ type: String,
+ max: STORAGE_LIMITS.tagLength,
+ },
icon: {
type: storedIconsSchema,
optional: true,
diff --git a/app/imports/api/parenting/organizeMethods.js b/app/imports/api/parenting/organizeMethods.js
index 624a2525..f104154c 100644
--- a/app/imports/api/parenting/organizeMethods.js
+++ b/app/imports/api/parenting/organizeMethods.js
@@ -8,9 +8,8 @@ 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 { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
-import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';
-import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
+import computeCreature from '/imports/api/engine/computeCreature.js';
+
const organizeDoc = new ValidatedMethod({
name: 'organize.organizeDoc',
validate: new SimpleSchema({
@@ -60,14 +59,8 @@ const organizeDoc = new ValidatedMethod({
let creaturesToRecompute = union(docCreatures, parentCreatures);
// Recompute the creatures
creaturesToRecompute.forEach(id => {
- // The active status of some properties might change due to a change in
- // ancestry
- recomputeInactiveProperties(id);
- if (doc.type === 'container' || doc.type === 'item'){
- recomputeInventory(id);
- }
// Some Dependencies depend on ancestry, so a full recompute is needed
- recomputeCreatureById(id);
+ computeCreature(id);
});
}
},
@@ -93,7 +86,7 @@ const reorderDoc = new ValidatedMethod({
safeUpdateDocOrder({docRef, order});
// Recompute the affected creatures
getCreatureAncestors(doc).forEach(id => {
- recomputeCreatureById(id);
+ computeCreature(id);
});
},
});
diff --git a/app/imports/api/parenting/parenting.js b/app/imports/api/parenting/parenting.js
index 70e7747d..e5a8cc62 100644
--- a/app/imports/api/parenting/parenting.js
+++ b/app/imports/api/parenting/parenting.js
@@ -101,7 +101,6 @@ export function getAncestry({parentRef, inheritedFields = {}}){
}
export function setLineageOfDocs({docArray, oldParent, newAncestry}){
- //const oldParent = oldAncestry[oldAncestry.length - 1];
const newParent = newAncestry[newAncestry.length - 1];
docArray.forEach(doc => {
if(doc.parent.id === oldParent.id){
@@ -109,6 +108,7 @@ export function setLineageOfDocs({docArray, oldParent, newAncestry}){
}
let oldAncestors = doc.ancestors;
let oldParentIndex = oldAncestors.findIndex(a => a.id === oldParent.id);
+ if (oldParentIndex === -1) return;
doc.ancestors = [...newAncestry, ...oldAncestors.slice(oldParentIndex + 1)];
});
}
@@ -117,17 +117,15 @@ export function setLineageOfDocs({docArray, oldParent, newAncestry}){
* Give documents new random ids and transform their references.
* Transform collections of re-IDed docs according to the collection map
*/
-export function renewDocIds({docArray, collectionMap}){
- // map of {oldId: newId}
- let idMap = {};
-
+export function renewDocIds({docArray, collectionMap, idMap = {}}){
+ // idMap is a map of {oldId: newId}
// Get a random generator that's consistent on client and server
let randomSrc = DDP.randomStream('renewDocIds');
// Give new ids and map the changes as {oldId: newId}
docArray.forEach(doc => {
let oldId = doc._id;
- let newId = randomSrc.id();
+ let newId = idMap[oldId] || randomSrc.id();
doc._id = newId;
idMap[oldId] = newId;
});
diff --git a/app/imports/api/properties/Actions.js b/app/imports/api/properties/Actions.js
index 11782ffb..ccda82d1 100644
--- a/app/imports/api/properties/Actions.js
+++ b/app/imports/api/properties/Actions.js
@@ -1,6 +1,5 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
@@ -10,40 +9,60 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
* Any actions that are children of this action will be considered alternatives
* to this action
*/
-let ActionSchema = new SimpleSchema({
- name: {
- type: String,
- optional: true,
+let ActionSchema = createPropertySchema({
+ name: {
+ type: String,
+ optional: true,
max: STORAGE_LIMITS.name,
- },
- summary: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.summary,
- },
- description: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.description,
- },
- // What time-resource is used to take the action in combat
- // long actions take longer than 1 round to cast
- actionType: {
- type: String,
- allowedValues: ['action', 'bonus', 'attack', 'reaction', 'free', 'long'],
- defaultValue: 'action',
- },
- // Who is the action directed at
- target: {
- type: String,
- defaultValue: 'singleTarget',
- allowedValues: [
+ },
+ summary: {
+ type: 'inlineCalculationFieldToCompute',
+ optional: true,
+ },
+ description: {
+ type: 'inlineCalculationFieldToCompute',
+ optional: true,
+ },
+ // What time-resource is used to take the action in combat
+ // long actions take longer than 1 round to cast
+ actionType: {
+ type: String,
+ allowedValues: ['action', 'bonus', 'attack', 'reaction', 'free', 'long'],
+ defaultValue: 'action',
+ },
+ // Who is the action directed at
+ target: {
+ type: String,
+ defaultValue: 'singleTarget',
+ allowedValues: [
'self',
'singleTarget',
- 'multipleTargets',
+ 'multipleTargets',
],
- },
- // Duplicate the ResourceSchema here so we can extend it elegantly.
+ },
+ // Some actions have an attack roll
+ attackRoll: {
+ type: 'fieldToCompute',
+ optional: true,
+ defaultValue: 'strength.modifier + proficiencyBonus',
+ },
+ // Calculation of how many times this action can be used
+ uses: {
+ type: 'fieldToCompute',
+ optional: true,
+ },
+ // Integer of how many times it has already been used
+ usesUsed: {
+ type: SimpleSchema.Integer,
+ optional: true,
+ },
+ // How this action's uses are reset automatically
+ reset: {
+ type: String,
+ allowedValues: ['longRest', 'shortRest'],
+ optional: true,
+ },
+ // Resources
resources: {
type: Object,
defaultValue: {},
@@ -51,7 +70,6 @@ let ActionSchema = new SimpleSchema({
'resources.itemsConsumed': {
type: Array,
defaultValue: [],
- maxCount: STORAGE_LIMITS.resourcesCount,
},
'resources.itemsConsumed.$': {
type: Object,
@@ -66,21 +84,19 @@ let ActionSchema = new SimpleSchema({
'resources.itemsConsumed.$.tag': {
type: String,
optional: true,
- max: STORAGE_LIMITS.tagLength,
},
'resources.itemsConsumed.$.quantity': {
- type: Number,
- defaultValue: 1,
+ type: 'fieldToCompute',
+ optional: true,
},
'resources.itemsConsumed.$.itemId': {
type: String,
+ regEx: SimpleSchema.RegEx.Id,
optional: true,
- max: STORAGE_LIMITS.name,
},
'resources.attributesConsumed': {
type: Array,
defaultValue: [],
- maxCount: STORAGE_LIMITS.resourcesCount,
},
'resources.attributesConsumed.$': {
type: Object,
@@ -98,105 +114,101 @@ let ActionSchema = new SimpleSchema({
max: STORAGE_LIMITS.variableName,
},
'resources.attributesConsumed.$.quantity': {
- type: Number,
- defaultValue: 1,
+ type: 'fieldToCompute',
+ optional: true,
},
- // Calculation of how many times this action can be used
- uses: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
- // Integer of how many times it has already been used
- usesUsed: {
- type: SimpleSchema.Integer,
- optional: true,
- },
- // How this action's uses are reset automatically
- reset: {
- type: String,
- allowedValues: ['longRest', 'shortRest'],
- optional: true,
- },
});
-const ComputedOnlyActionSchema = new SimpleSchema({
- summaryCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
- },
- 'summaryCalculations.$': InlineComputationSchema,
-
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
- },
- 'descriptionCalculations.$': InlineComputationSchema,
-
- usesResult: {
- type: SimpleSchema.Integer,
+const ComputedOnlyActionSchema = createPropertySchema({
+ summary: {
+ type: 'computedOnlyInlineCalculationField',
optional: true,
},
- usesErrors: {
- type: Array,
+ description: {
+ type: 'computedOnlyInlineCalculationField',
optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'usesErrors.$':{
- type: ErrorSchema,
- },
- resources: Object,
- 'resources.itemsConsumed': Array,
- 'resources.itemsConsumed.$': Object,
- 'resources.itemsConsumed.$.available': {
- type: Number,
- optional: true,
- },
- // This appears both in the computed and uncomputed schema because it can be
- // set by both a computation or a form
- 'resources.itemsConsumed.$.itemId': {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- optional: true,
- },
- 'resources.itemsConsumed.$.itemName': {
- type: String,
- max: STORAGE_LIMITS.name,
- optional: true,
- },
- 'resources.itemsConsumed.$.itemIcon': {
- type: storedIconsSchema,
- optional: true,
- max: STORAGE_LIMITS.icon,
- },
- 'resources.itemsConsumed.$.itemColor': {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.color,
- },
- 'resources.attributesConsumed': Array,
- 'resources.attributesConsumed.$': Object,
- 'resources.attributesConsumed.$.available': {
- type: Number,
- optional: true,
- },
- 'resources.attributesConsumed.$.statId': {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- optional: true,
- },
- 'resources.attributesConsumed.$.statName': {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.name,
},
// True if the uses left is zero, or any item or attribute consumed is
// insufficient
insufficientResources: {
type: Boolean,
optional: true,
+ removeBeforeCompute: true,
+ },
+ attackRoll: {
+ type: 'computedOnlyField',
+ optional: true,
+ },
+ uses: {
+ type: 'computedOnlyField',
+ optional: true,
+ },
+ // Uses - usesUsed
+ usesLeft: {
+ type: Number,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ // Resources
+ resources: {
+ type: Object,
+ defaultValue: {},
+ },
+ 'resources.itemsConsumed': {
+ type: Array,
+ defaultValue: [],
+ },
+ 'resources.itemsConsumed.$': {
+ type: Object,
+ },
+ 'resources.itemsConsumed.$.available': {
+ type: Number,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ 'resources.itemsConsumed.$.quantity': {
+ type: 'computedOnlyField',
+ optional: true,
+ },
+ 'resources.itemsConsumed.$.itemName': {
+ type: String,
+ max: STORAGE_LIMITS.name,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ 'resources.itemsConsumed.$.itemIcon': {
+ type: storedIconsSchema,
+ optional: true,
+ max: STORAGE_LIMITS.icon,
+ removeBeforeCompute: true,
+ },
+ 'resources.itemsConsumed.$.itemColor': {
+ type: String,
+ optional: true,
+ regEx: /^#([a-f0-9]{3}){1,2}\b$/i,
+ removeBeforeCompute: true,
+ },
+ 'resources.attributesConsumed': {
+ type: Array,
+ defaultValue: [],
+ },
+ 'resources.attributesConsumed.$': {
+ type: Object,
+ },
+ 'resources.attributesConsumed.$.quantity': {
+ type: 'computedOnlyField',
+ optional: true,
+ },
+ 'resources.attributesConsumed.$.available': {
+ type: Number,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ 'resources.attributesConsumed.$.statName': {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.name,
+ removeBeforeCompute: true,
},
});
diff --git a/app/imports/api/properties/Adjustments.js b/app/imports/api/properties/Adjustments.js
index 9b3c12fb..49c1b248 100644
--- a/app/imports/api/properties/Adjustments.js
+++ b/app/imports/api/properties/Adjustments.js
@@ -1,24 +1,23 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-const AdjustmentSchema = new SimpleSchema({
+const AdjustmentSchema = createPropertySchema({
// The roll that determines how much to change the attribute
// This can be simplified, but should only compute when activated
amount: {
- type: String,
+ type: 'fieldToCompute',
+ parseLevel: 'compile',
optional: true,
- defaultValue: '1',
- max: STORAGE_LIMITS.calculation,
+ defaultValue: 1,
},
// Who this adjustment applies to
target: {
type: String,
- defaultValue: 'every',
+ defaultValue: 'target',
allowedValues: [
- 'self', // the character who took the Adjustment
- 'each', // rolled once for `each` target
- 'every', // rolled once and applied to `every` target
+ 'self',
+ 'target',
],
},
// The stat this rolls applies to
@@ -34,19 +33,12 @@ const AdjustmentSchema = new SimpleSchema({
},
});
-const ComputedOnlyAdjustmentSchema = new SimpleSchema({
- amountResult: {
- type: SimpleSchema.oneOf(String, Number),
+const ComputedOnlyAdjustmentSchema = createPropertySchema({
+ amount: {
+ type: 'computedOnlyField',
+ parseLevel: 'compile',
optional: true,
},
- amountErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'amountErrors.$':{
- type: ErrorSchema,
- },
});
const ComputedAdjustmentSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/Attacks.js b/app/imports/api/properties/Attacks.js
deleted file mode 100644
index e2363e59..00000000
--- a/app/imports/api/properties/Attacks.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
-import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
-
-// Attacks are special instances of actions
-let AttackSchema = new SimpleSchema()
- .extend(ActionSchema)
- .extend({
- // What gets added to the d20 roll
- rollBonus: {
- type: String,
- defaultValue: 'strength.modifier + proficiencyBonus',
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
- // Set better defaults for the action
- actionType: {
- type: String,
- defaultValue: 'attack',
- max: STORAGE_LIMITS.name,
- },
- tags: {
- type: Array,
- defaultValue: ['attack'],
- maxCount: STORAGE_LIMITS.tagCount,
- },
- 'tags.$': {
- type: String,
- max: STORAGE_LIMITS.tagLength,
- },
- });
-
-const ComputedOnlyAttackSchema = new SimpleSchema()
- .extend(ComputedOnlyActionSchema)
- .extend({
- rollBonusResult: {
- type: Number,
- optional: true,
- },
- rollBonusErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'rollBonusErrors.$':{
- type: ErrorSchema,
- },
- });
-
-const ComputedAttackSchema = new SimpleSchema()
- .extend(AttackSchema)
- .extend(ComputedOnlyAttackSchema);
-
-export { AttackSchema, ComputedOnlyAttackSchema, ComputedAttackSchema };
diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.js
index b5c1c605..8bc3a9c8 100644
--- a/app/imports/api/properties/Attributes.js
+++ b/app/imports/api/properties/Attributes.js
@@ -1,24 +1,23 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
/*
* Attributes are numbered stats of a character
*/
-let AttributeSchema = new SimpleSchema({
+let AttributeSchema = createPropertySchema({
name: {
type: String,
- defaultValue: 'New Attribute',
+ optional: true,
max: STORAGE_LIMITS.name,
},
// The technical, lowercase, single-word name used in formulae
variableName: {
type: String,
+ optional: true,
regEx: VARIABLE_NAME_REGEX,
min: 2,
- defaultValue: 'newAttribute',
max: STORAGE_LIMITS.variableName,
},
// How it is displayed and computed is determined by type
@@ -27,7 +26,7 @@ let AttributeSchema = new SimpleSchema({
allowedValues: [
'ability', //Strength, Dex, Con, etc.
'stat', // Speed, Armor Class
- 'modifier', // Proficiency Bonus, Initiative
+ '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
@@ -41,32 +40,28 @@ let AttributeSchema = new SimpleSchema({
// For type hitDice, the size needs to be stored separately
hitDiceSize: {
type: String,
- allowedValues: ['d4', 'd6', 'd8', 'd10', 'd12', 'd20'],
+ allowedValues: ['d1', 'd2', 'd4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'],
optional: true,
},
// For type spellSlot, the level needs to be stored separately
- spellSlotLevelCalculation: {
- type: String,
+ spellSlotLevel: {
+ type: 'fieldToCompute',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
// The starting value, before effects
- baseValueCalculation: {
- type: String,
+ baseValue: {
+ type: 'fieldToCompute',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
// Description of what the attribute is used for
description: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
- // The damage done to the attribute, always positive
+ // The damage done to the attribute, should always compute as positive
damage: {
type: SimpleSchema.Integer,
optional: true,
- min: 0,
},
// Can the value be decimal?
decimal: {
@@ -81,70 +76,62 @@ let AttributeSchema = new SimpleSchema({
},
});
-let ComputedOnlyAttributeSchema = new SimpleSchema({
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+let ComputedOnlyAttributeSchema = createPropertySchema({
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'descriptionCalculations.$': InlineComputationSchema,
- // The result of baseValueCalculation
baseValue: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
+ type: 'computedOnlyField',
optional: true,
},
- baseValueErrors: {
- type: Array,
- optional: true,
- },
- 'baseValueErrors.$': {
- type: ErrorSchema,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- // The result of spellSlotLevelCalculation
- spellSlotLevelValue: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
- optional: true,
- },
- spellSlotLevelErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'spellSlotLevelErrors.$': {
- type: ErrorSchema,
+ spellSlotLevel: {
+ type: 'computedOnlyField',
+ optional: true,
},
// The computed value of the attribute
+ total: {
+ type: SimpleSchema.oneOf(Number, String, Boolean),
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ // The computed value of the attribute minus the damage
value: {
type: SimpleSchema.oneOf(Number, String, Boolean),
defaultValue: 0,
optional: true,
- },
- // The computed value of the attribute minus the damage
- currentValue: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
- defaultValue: 0,
- optional: true,
+ removeBeforeCompute: true,
},
// The computed modifier, provided the attribute type is `ability`
modifier: {
type: SimpleSchema.Integer,
optional: true,
+ removeBeforeCompute: true,
},
- // The computed creature constitution modifier
+ // Attributes with proficiency grant it to all skills based on the attribute
+ proficiency: {
+ type: Number,
+ allowedValues: [0, 0.49, 0.5, 1, 2],
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ // The computed creature constitution modifier for hit dice
constitutionMod: {
type: Number,
optional: true,
+ removeBeforeCompute: true,
},
// Should this attribute hide
hide: {
type: Boolean,
optional: true,
+ removeBeforeCompute: true,
},
// Denormalised tag if stat is overridden by one with the same variable name
overridden: {
type: Boolean,
optional: true,
+ removeBeforeCompute: true,
},
});
diff --git a/app/imports/api/properties/Branches.js b/app/imports/api/properties/Branches.js
new file mode 100644
index 00000000..758d88fa
--- /dev/null
+++ b/app/imports/api/properties/Branches.js
@@ -0,0 +1,52 @@
+import SimpleSchema from 'simpl-schema';
+import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
+
+let BranchSchema = createPropertySchema({
+ branchType: {
+ type: String,
+ allowedValues: [
+ // Uses the condition field to determine whether to apply children
+ 'if',
+ // Attack
+ 'hit',
+ 'miss',
+ // Save
+ 'failedSave',
+ 'successfulSave',
+ // Iterate through targets
+ 'eachTarget',
+ // Pick one child at random
+ 'random',
+ // if it has option children, asks to select one
+ // Otherwise presents its own text with yes/no
+ //'choice',
+ //'option',
+ ],
+ defaultValue: 'if',
+ },
+ text: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.name,
+ },
+ condition: {
+ type: 'fieldToCompute',
+ optional: true,
+ parseLevel: 'compile',
+ },
+});
+
+let ComputedOnlyBranchSchema = createPropertySchema({
+ condition: {
+ type: 'computedOnlyField',
+ optional: true,
+ parseLevel: 'compile',
+ },
+});
+
+const ComputedBranchSchema = new SimpleSchema()
+ .extend(BranchSchema)
+ .extend(ComputedOnlyBranchSchema);
+
+export { BranchSchema, ComputedBranchSchema, ComputedOnlyBranchSchema }
diff --git a/app/imports/api/properties/Buffs.js b/app/imports/api/properties/Buffs.js
index 5ed2913b..cd964a9d 100644
--- a/app/imports/api/properties/Buffs.js
+++ b/app/imports/api/properties/Buffs.js
@@ -1,46 +1,42 @@
import SimpleSchema from 'simpl-schema';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let BuffSchema = new SimpleSchema({
- name: {
+let BuffSchema = createPropertySchema({
+ name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
+ type: 'inlineCalculationFieldToCompute',
+ optional: true,
+ },
+ // How many rounds this buff lasts
+ duration: {
+ type: 'fieldToCompute',
+ optional: true,
+ },
+ target: {
type: String,
+ allowedValues: [
+ 'self',
+ 'target',
+ ],
+ defaultValue: 'target',
+ },
+});
+
+let ComputedOnlyBuffSchema = createPropertySchema({
+ description: {
+ type: 'inlineCalculationFieldToCompute',
optional: true,
max: STORAGE_LIMITS.description,
},
duration: {
- type: String,
+ type: 'computedOnlyField',
optional: true,
- max: STORAGE_LIMITS.name,
},
- applied: {
- type: Boolean,
- defaultValue: false,
- index: 1,
- },
- target: {
- type: String,
- allowedValues: [
- 'self', // the character who took the buff
- 'each', // rolled once for `each` target
- 'every', // rolled once and applied to `every` target
- ],
- defaultValue: 'every',
- },
-});
-
-let ComputedOnlyBuffSchema = new SimpleSchema({
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
- },
- 'descriptionCalculations.$': InlineComputationSchema,
durationSpent: {
type: Number,
optional: true,
@@ -62,7 +58,7 @@ let ComputedOnlyBuffSchema = new SimpleSchema({
type: String,
max: STORAGE_LIMITS.collectionName,
},
-})
+});
const ComputedBuffSchema = new SimpleSchema()
.extend(BuffSchema)
diff --git a/app/imports/api/properties/ClassLevels.js b/app/imports/api/properties/ClassLevels.js
index c83eef24..befbaf83 100644
--- a/app/imports/api/properties/ClassLevels.js
+++ b/app/imports/api/properties/ClassLevels.js
@@ -1,43 +1,42 @@
import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let ClassLevelSchema = new SimpleSchema({
+const ClassLevelSchema = createPropertySchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
- // Only used by slot filling dialog, not computed
- description: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.description,
- },
+ description: {
+ type: 'inlineCalculationFieldToCompute',
+ optional: true,
+ },
// The name of this class level's variable
variableName: {
type: String,
min: 2,
regEx: VARIABLE_NAME_REGEX,
max: STORAGE_LIMITS.variableName,
+ optional: true,
},
level: {
type: SimpleSchema.Integer,
defaultValue: 1,
- },
- nextLevelTags: {
- type: Array,
- defaultValue: [],
- },
- 'nextLevelTags.$': {
- type: String,
- },
- // Same as in SlotFillers.js
- slotFillerCondition: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
+ max: STORAGE_LIMITS.levelMax,
},
});
-export { ClassLevelSchema };
+const ComputedOnlyClassLevelSchema = new SimpleSchema({
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
+ },
+});
+
+const ComputedClassLevelSchema = new SimpleSchema()
+ .extend(ComputedOnlyClassLevelSchema)
+ .extend(ClassLevelSchema);
+
+export { ClassLevelSchema, ComputedOnlyClassLevelSchema, ComputedClassLevelSchema };
diff --git a/app/imports/api/properties/Classes.js b/app/imports/api/properties/Classes.js
new file mode 100644
index 00000000..ee631246
--- /dev/null
+++ b/app/imports/api/properties/Classes.js
@@ -0,0 +1,37 @@
+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({
+ // Only `classLevel`s with the same variable name can fill the class
+ variableName: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.variableName,
+ },
+}).extend(SlotSchema);
+
+const ComputedOnlyClassSchema = createPropertySchema({
+ level: {
+ type: SimpleSchema.Integer,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ missingLevels: {
+ type: Array,
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ 'missingLevels.$': {
+ type: SimpleSchema.Integer,
+ },
+ }).extend(ComputedOnlySlotSchema);
+
+const ComputedClassSchema = new SimpleSchema()
+ .extend(ClassSchema)
+ .extend(ComputedOnlyClassSchema);
+
+export { ClassSchema, ComputedOnlyClassSchema, ComputedClassSchema };
diff --git a/app/imports/api/properties/Constants.js b/app/imports/api/properties/Constants.js
index 8796eaf1..0995ccd9 100644
--- a/app/imports/api/properties/Constants.js
+++ b/app/imports/api/properties/Constants.js
@@ -3,12 +3,10 @@ import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import {
parse,
- CompilationContext,
prettifyParseError,
} from '/imports/parser/parser.js';
-import AccessorNode from '/imports/parser/parseTree/AccessorNode.js';
-import SymbolNode from '/imports/parser/parseTree/SymbolNode.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import resolve, { Context, traverse } from '/imports/parser/resolve.js';
/*
* Constants are primitive values that can be used elsewhere in computations
@@ -50,12 +48,9 @@ let ConstantSchema = new SimpleSchema({
// Any existing errors will result in an early failure
if (context && context.errors.length) return context.errors;
// Ban variables in constants if necessary
- result && result.traverse(node => {
- if (node instanceof SymbolNode || node instanceof AccessorNode){
- context.storeError({
- type: 'error',
- message: 'Variables can\'t be used to define a constant'
- });
+ result && traverse(result, node => {
+ if (node.parseType === 'symbol' || node.parseType === 'accessor'){
+ context.error('Variables can\'t be used to define a constant');
}
});
return context && context.errors || [];
@@ -67,7 +62,7 @@ let ConstantSchema = new SimpleSchema({
});
function parseString(string, fn = 'compile'){
- let context = new CompilationContext();
+ let context = new Context();
if (!string){
return {result: string, context};
}
@@ -78,11 +73,14 @@ function parseString(string, fn = 'compile'){
node = parse(string);
} catch (e) {
let message = prettifyParseError(e);
- context.storeError({type: 'error', message});
+ context.error(message);
return {context};
}
- let result = node[fn]({/*empty scope*/}, context);
+ if (!node) return {context};
+ let {result} = resolve(fn, node, {/*empty scope*/}, context);
return {result, context}
}
-export { ConstantSchema };
+const ComputedOnlyConstantSchema = new SimpleSchema({});
+
+export { ConstantSchema, ComputedOnlyConstantSchema };
diff --git a/app/imports/api/properties/Containers.js b/app/imports/api/properties/Containers.js
index d7911237..402a3e83 100644
--- a/app/imports/api/properties/Containers.js
+++ b/app/imports/api/properties/Containers.js
@@ -1,8 +1,8 @@
import SimpleSchema from 'simpl-schema';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let ContainerSchema = new SimpleSchema({
+let ContainerSchema = createPropertySchema({
name: {
type: String,
optional: true,
@@ -29,28 +29,38 @@ let ContainerSchema = new SimpleSchema({
optional: true,
},
description: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- trim: false,
- max: STORAGE_LIMITS.description,
},
});
-const ComputedOnlyContainerSchema = new SimpleSchema({
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
- },
- 'descriptionCalculations.$': InlineComputationSchema,
+const ComputedOnlyContainerSchema = createPropertySchema({
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
+ },
// Weight of all the contents, zero if `contentsWeightless` is true
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:{
+ type: Number,
+ optional: true,
+ removeBeforeCompute: true,
},
contentsValue:{
type: Number,
optional: true,
+ removeBeforeCompute: true,
+ },
+ carriedValue:{
+ type: Number,
+ optional: true,
+ removeBeforeCompute: true,
},
});
diff --git a/app/imports/api/properties/DamageMultipliers.js b/app/imports/api/properties/DamageMultipliers.js
index 34c3d55f..d40f08be 100644
--- a/app/imports/api/properties/DamageMultipliers.js
+++ b/app/imports/api/properties/DamageMultipliers.js
@@ -1,5 +1,4 @@
import SimpleSchema from 'simpl-schema';
-import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
@@ -20,7 +19,7 @@ let DamageMultiplierSchema = new SimpleSchema({
// The technical, lowercase, single-word name used in formulae
'damageTypes.$': {
type: String,
- allowedValues: DAMAGE_TYPES,
+ max: STORAGE_LIMITS.calculation,
},
// The value of the damage multiplier
value: {
@@ -50,4 +49,6 @@ let DamageMultiplierSchema = new SimpleSchema({
},
});
-export { DamageMultiplierSchema };
+const ComputedOnlyDamageMultiplierSchema = new SimpleSchema({});
+
+export { DamageMultiplierSchema, ComputedOnlyDamageMultiplierSchema };
diff --git a/app/imports/api/properties/Damages.js b/app/imports/api/properties/Damages.js
index 25802861..12e55623 100644
--- a/app/imports/api/properties/Damages.js
+++ b/app/imports/api/properties/Damages.js
@@ -1,46 +1,37 @@
import SimpleSchema from 'simpl-schema';
-import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
-const DamageSchema = new SimpleSchema({
+const DamageSchema = createPropertySchema({
// The roll that determines how much to damage the attribute
// This can be simplified, but only computed when applied
amount: {
- type: String,
+ type: 'fieldToCompute',
optional: true,
defaultValue: '1d8 + strength.modifier',
- max: STORAGE_LIMITS.calculation,
+ parseLevel: 'compile',
},
// Who this damage applies to
target: {
type: String,
- defaultValue: 'every',
+ defaultValue: 'target',
allowedValues: [
- 'self', // the character who took the action
- 'each', // rolled once for `each` target
- 'every', // rolled once and applied to `every` target
+ 'self',
+ 'target',
],
},
damageType: {
type: String,
- allowedValues: DAMAGE_TYPES,
+ max: STORAGE_LIMITS.calculation,
defaultValue: 'slashing',
},
});
-const ComputedOnlyDamageSchema = new SimpleSchema({
- amountResult: {
- type: SimpleSchema.oneOf(String, Number),
+const ComputedOnlyDamageSchema = createPropertySchema({
+ amount: {
+ type: 'computedOnlyField',
optional: true,
- },
- amountErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'amountErrors.$':{
- type: ErrorSchema,
+ parseLevel: 'compile',
},
});
diff --git a/app/imports/api/properties/Effects.js b/app/imports/api/properties/Effects.js
index 2f782533..e1ba0c37 100644
--- a/app/imports/api/properties/Effects.js
+++ b/app/imports/api/properties/Effects.js
@@ -1,71 +1,116 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
/*
* Effects are reason-value attached to skills and abilities
* that modify their final value or presentation in some way
*/
-let EffectSchema = new SimpleSchema({
- name: {
- type: String,
- optional: true,
+let EffectSchema = createPropertySchema({
+ name: {
+ type: String,
+ optional: true,
max: STORAGE_LIMITS.name,
- },
- operation: {
- type: String,
- defaultValue: 'add',
- allowedValues: [
- 'base',
- 'add',
- 'mul',
- 'min',
- 'max',
+ },
+ operation: {
+ type: String,
+ defaultValue: 'add',
+ allowedValues: [
+ 'base',
+ 'add',
+ 'mul',
+ 'min',
+ 'max',
'set',
- 'advantage',
- 'disadvantage',
- 'passiveAdd',
- 'fail',
- 'conditional',
- 'rollBonus',
- ],
- },
- calculation: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
- //which stats the effect is applied to
- stats: {
- type: Array,
- defaultValue: [],
+ 'advantage',
+ 'disadvantage',
+ 'passiveAdd',
+ 'fail',
+ 'conditional',
+ ],
+ },
+ amount: {
+ type: 'fieldToCompute',
+ optional: true,
+ },
+ // Conditional benefits store just uncomputed text
+ text: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.effectCondition,
+ },
+ // Which stats the effect is applied to
+ // Each entry is a variableName targeted by this effect
+ stats: {
+ type: Array,
+ defaultValue: [],
maxCount: STORAGE_LIMITS.statsToTarget,
- },
- 'stats.$': {
- type: String,
+ },
+ 'stats.$': {
+ type: String,
max: STORAGE_LIMITS.variableName,
- },
-});
-
-const ComputedOnlyEffectSchema = new SimpleSchema({
- // The computed result of the effect
- result: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
- optional: true,
- },
- // The errors encountered while computing the result
- errors: {
+ },
+ // True when targeting by tags instead of stats
+ targetByTags: {
+ type: Boolean,
+ optional: true,
+ },
+ // If targeting by tags, the field which will be targeted
+ targetField: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.variableName,
+ },
+ // Which tags the effect is applied to
+ targetTags: {
type: Array,
optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
+ maxCount: STORAGE_LIMITS.tagCount,
},
- 'errors.$':{
- type: ErrorSchema,
+ '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,
+ },
+});
+
+const ComputedOnlyEffectSchema = createPropertySchema({
+ amount: {
+ type: 'computedOnlyField',
+ optional: true,
},
});
const ComputedEffectSchema = new SimpleSchema()
- .extend(ComputedOnlyEffectSchema)
- .extend(EffectSchema);
+ .extend(ComputedOnlyEffectSchema)
+ .extend(EffectSchema);
export { EffectSchema, ComputedEffectSchema, ComputedOnlyEffectSchema };
diff --git a/app/imports/api/properties/Features.js b/app/imports/api/properties/Features.js
index c6b57d2f..0abe8be9 100644
--- a/app/imports/api/properties/Features.js
+++ b/app/imports/api/properties/Features.js
@@ -1,40 +1,32 @@
import SimpleSchema from 'simpl-schema';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let FeatureSchema = new SimpleSchema({
+let FeatureSchema = createPropertySchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
+ optional: true,
},
summary: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.summary,
},
description: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
});
-let ComputedOnlyFeatureSchema = new SimpleSchema({
-
- summaryCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+let ComputedOnlyFeatureSchema = createPropertySchema({
+ summary: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'summaryCalculations.$': InlineComputationSchema,
-
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'descriptionCalculations.$': InlineComputationSchema,
-
});
const ComputedFeatureSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/Folders.js b/app/imports/api/properties/Folders.js
index c8ee9c12..da4e5386 100644
--- a/app/imports/api/properties/Folders.js
+++ b/app/imports/api/properties/Folders.js
@@ -10,4 +10,6 @@ let FolderSchema = new SimpleSchema({
},
});
-export { FolderSchema };
+const ComputedOnlyFolderSchema = new SimpleSchema({});
+
+export { FolderSchema, ComputedOnlyFolderSchema };
diff --git a/app/imports/api/properties/Items.js b/app/imports/api/properties/Items.js
index ba1ac8fa..373a6054 100644
--- a/app/imports/api/properties/Items.js
+++ b/app/imports/api/properties/Items.js
@@ -1,8 +1,8 @@
import SimpleSchema from 'simpl-schema';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-const ItemSchema = new SimpleSchema({
+const ItemSchema = createPropertySchema({
name: {
type: String,
optional: true,
@@ -14,10 +14,9 @@ const ItemSchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.name,
},
- description: {
- type: String,
+ description: {
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
// Number currently held
quantity: {
@@ -58,13 +57,11 @@ const ItemSchema = new SimpleSchema({
},
});
-let ComputedOnlyItemSchema = new SimpleSchema({
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+let ComputedOnlyItemSchema = createPropertySchema({
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'descriptionCalculations.$': InlineComputationSchema,
});
const ComputedItemSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/Notes.js b/app/imports/api/properties/Notes.js
index 3017f930..dd76d906 100644
--- a/app/imports/api/properties/Notes.js
+++ b/app/imports/api/properties/Notes.js
@@ -1,41 +1,32 @@
import SimpleSchema from 'simpl-schema';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let NoteSchema = new SimpleSchema({
+let NoteSchema = createPropertySchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
- summary: {
- type: String,
+ summary: {
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.summary,
},
- description: {
- type: String,
+ description: {
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
});
-let ComputedOnlyNoteSchema = new SimpleSchema({
-
- summaryCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+let ComputedOnlyNoteSchema = createPropertySchema({
+ summary: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'summaryCalculations.$': InlineComputationSchema,
-
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
},
- 'descriptionCalculations.$': InlineComputationSchema,
-
});
const ComputedNoteSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/Proficiencies.js b/app/imports/api/properties/Proficiencies.js
index 1a339a64..8e8246ac 100644
--- a/app/imports/api/properties/Proficiencies.js
+++ b/app/imports/api/properties/Proficiencies.js
@@ -26,4 +26,6 @@ let ProficiencySchema = new SimpleSchema({
},
});
-export { ProficiencySchema };
+const ComputedOnlyProficiencySchema = new SimpleSchema({});
+
+export { ProficiencySchema, ComputedOnlyProficiencySchema };
diff --git a/app/imports/api/properties/References.js b/app/imports/api/properties/References.js
index 4a86007c..31ae8288 100644
--- a/app/imports/api/properties/References.js
+++ b/app/imports/api/properties/References.js
@@ -58,4 +58,6 @@ let ReferenceSchema = new SimpleSchema({
},
});
-export { ReferenceSchema };
+const ComputedOnlyReferenceSchema = new SimpleSchema({});
+
+export { ReferenceSchema, ComputedOnlyReferenceSchema };
diff --git a/app/imports/api/properties/Rolls.js b/app/imports/api/properties/Rolls.js
index bcf9799e..5acc0ce7 100644
--- a/app/imports/api/properties/Rolls.js
+++ b/app/imports/api/properties/Rolls.js
@@ -1,7 +1,7 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
/**
* Rolls are children to actions or other rolls, they are triggered with 0 or
@@ -21,7 +21,7 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
* If the roll fails to meet or exceed the target number, the adjustments and
* child rolls are applied
*/
-let RollSchema = new SimpleSchema({
+let RollSchema = createPropertySchema({
name: {
type: String,
defaultValue: 'New Roll',
@@ -37,25 +37,18 @@ let RollSchema = new SimpleSchema({
},
// The roll, can be simplified, but only computed in context
roll: {
- type: String,
+ type: 'fieldToCompute',
+ parseLevel: 'compile',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
});
-let ComputedOnlyRollSchema = new SimpleSchema({
- rollResult: {
- type: SimpleSchema.Integer,
+let ComputedOnlyRollSchema = createPropertySchema({
+ roll: {
+ type: 'computedOnlyField',
+ parseLevel: 'compile',
optional: true,
},
- rollErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'rollErrors.$':{
- type: ErrorSchema,
- },
});
const ComputedRollSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/SavingThrows.js b/app/imports/api/properties/SavingThrows.js
index bc640bde..f5cfbf70 100644
--- a/app/imports/api/properties/SavingThrows.js
+++ b/app/imports/api/properties/SavingThrows.js
@@ -1,10 +1,10 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
// These are the rolls made when saves are called for
// For the saving throw bonus or proficiency, see ./Skills.js
-let SavingThrowSchema = new SimpleSchema ({
+let SavingThrowSchema = createPropertySchema({
name: {
type: String,
optional: true,
@@ -12,18 +12,16 @@ let SavingThrowSchema = new SimpleSchema ({
},
// The computed DC
dc: {
- type: String,
+ type: 'fieldToCompute',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
// Who this saving throw applies to
target: {
type: String,
- defaultValue: 'every',
+ defaultValue: 'target',
allowedValues: [
- 'self', // the character who took the action
- 'each', // rolled once for `each` target
- 'every', // rolled once and applied to `every` target
+ 'self',
+ 'target',
],
},
// The variable name of save to roll
@@ -34,19 +32,12 @@ let SavingThrowSchema = new SimpleSchema ({
},
});
-const ComputedOnlySavingThrowSchema = new SimpleSchema({
- dcResult: {
- type: Number,
+const ComputedOnlySavingThrowSchema = createPropertySchema({
+ dc: {
+ type: 'computedOnlyField',
+ parseLevel: 'compile',
optional: true,
},
- dcErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'dcErrors.$':{
- type: ErrorSchema,
- },
});
const ComputedSavingThrowSchema = new SimpleSchema()
diff --git a/app/imports/api/properties/Skills.js b/app/imports/api/properties/Skills.js
index bdd407eb..281fe985 100644
--- a/app/imports/api/properties/Skills.js
+++ b/app/imports/api/properties/Skills.js
@@ -1,13 +1,13 @@
import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
/*
* Skills are anything that results in a modifier to be added to a D20
* Skills have an ability score modifier that they use as their basis
*/
-let SkillSchema = new SimpleSchema({
+let SkillSchema = createPropertySchema({
name: {
type: String,
optional: true,
@@ -20,6 +20,7 @@ let SkillSchema = new SimpleSchema({
regEx: VARIABLE_NAME_REGEX,
min: 2,
max: STORAGE_LIMITS.variableName,
+ optional: true,
},
// The variable name of the ability this skill relies on
ability: {
@@ -42,97 +43,93 @@ let SkillSchema = new SimpleSchema({
],
defaultValue: 'skill',
},
- // The starting value, before effects
- baseValueCalculation: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
// 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',
+ optional: true,
+ },
// Description of what the skill is used for
description: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
});
-let ComputedOnlySkillSchema = new SimpleSchema({
+let ComputedOnlySkillSchema = createPropertySchema({
// Computed value of skill to be added to skill rolls
value: {
type: Number,
defaultValue: 0,
+ optional: true,
+ removeBeforeCompute: true,
},
// The result of baseValueCalculation
baseValue: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
+ type: 'computedOnlyField',
optional: true,
},
- baseValueErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'baseValueErrors.$': {
- type: ErrorSchema,
- },
+ description: {
+ type: 'computedOnlyInlineCalculationField',
+ optional: true,
+ },
// Computed value added by the ability
abilityMod: {
type: SimpleSchema.Integer,
optional: true,
+ removeBeforeCompute: true,
},
// Computed advantage/disadvantage
advantage: {
type: SimpleSchema.Integer,
optional: true,
allowedValues: [-1, 0, 1],
+ removeBeforeCompute: true,
},
// Computed bonus to passive checks
passiveBonus: {
type: Number,
optional: true,
+ removeBeforeCompute: true,
},
// Computed proficiency multiplier
proficiency: {
type: Number,
- allowedValues: [0, 0.5, 1, 2],
+ allowedValues: [0, 0.49, 0.5, 1, 2],
defaultValue: 0,
+ removeBeforeCompute: true,
},
// Compiled text of all conditional benefits
conditionalBenefits: {
type: Array,
optional: true,
+ removeBeforeCompute: true,
},
'conditionalBenefits.$': {
type: String,
- },
- // Compiled text of all roll bonuses
- rollBonuses: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.rollBonusCount,
- },
- 'rollBonuses.$': {
- type: String,
},
// Computed number of things forcing this skill to fail
fail: {
type: SimpleSchema.Integer,
optional: true,
+ removeBeforeCompute: true,
},
// Should this attribute hide
hide: {
type: Boolean,
optional: true,
+ removeBeforeCompute: true,
},
// Denormalised tag if stat is overridden by one with the same variable name
overridden: {
type: Boolean,
optional: true,
+ removeBeforeCompute: true,
},
})
diff --git a/app/imports/api/properties/SlotFillers.js b/app/imports/api/properties/SlotFillers.js
index b485a5c6..185e7ba2 100644
--- a/app/imports/api/properties/SlotFillers.js
+++ b/app/imports/api/properties/SlotFillers.js
@@ -30,7 +30,6 @@ let SlotFillerSchema = new SimpleSchema({
slotQuantityFilled: {
type: SimpleSchema.Integer,
defaultValue: 1,
- min: 0,
},
// Filters out of UI if condition isn't met, but isn't otherwise enforced
slotFillerCondition: {
@@ -40,4 +39,6 @@ let SlotFillerSchema = new SimpleSchema({
},
});
-export { SlotFillerSchema };
+const ComputedOnlySlotFillerSchema = new SimpleSchema({});
+
+export { SlotFillerSchema, ComputedOnlySlotFillerSchema };
diff --git a/app/imports/api/properties/Slots.js b/app/imports/api/properties/Slots.js
index 7804ab75..310ba198 100644
--- a/app/imports/api/properties/Slots.js
+++ b/app/imports/api/properties/Slots.js
@@ -1,75 +1,72 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let SlotSchema = new SimpleSchema({
+let SlotSchema = createPropertySchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
- type: String,
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
slotType: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
- slotTags: {
+ slotTags: {
type: Array,
- defaultValue: [],
+ defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
- 'slotTags.$': {
- type: String,
+ 'slotTags.$': {
+ type: String,
max: STORAGE_LIMITS.tagLength,
- },
- extraTags: {
+ },
+ extraTags: {
type: Array,
- defaultValue: [],
+ defaultValue: [],
maxCount: STORAGE_LIMITS.extraTagsCount,
},
- 'extraTags.$': {
- type: Object,
- },
+ 'extraTags.$': {
+ type: Object,
+ },
'extraTags.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue(){
if (!this.isSet) return Random.id();
}
- },
+ },
'extraTags.$.operation': {
- type: String,
+ type: String,
allowedValues: ['OR', 'NOT'],
defaultValue: 'OR',
- },
+ },
'extraTags.$.tags': {
- type: Array,
+ type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
- },
+ },
'extraTags.$.tags.$': {
- type: String,
- max: STORAGE_LIMITS.tagLength,
- },
- quantityExpected: {
type: String,
+ max: STORAGE_LIMITS.tagLength,
+ },
+ quantityExpected: {
+ type: 'fieldToCompute',
optional: true,
defaultValue: '1',
- max: STORAGE_LIMITS.calculation,
},
ignored: {
type: Boolean,
optional: true,
},
slotCondition: {
- type: String,
+ type: 'fieldToCompute',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
hideWhenFull: {
type: Boolean,
@@ -89,48 +86,36 @@ let SlotSchema = new SimpleSchema({
},
});
-const ComputedOnlySlotSchema = new SimpleSchema({
- // Condition calculation results
- slotConditionResult: {
- type: SimpleSchema.oneOf(Number, String, Boolean),
- optional: true,
- },
- slotConditionErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'slotConditionErrors.$':{
- type: ErrorSchema,
- },
-
- // Quantity Expected calculation results
- quantityExpectedResult: {
- type: SimpleSchema.Integer,
+const ComputedOnlySlotSchema = createPropertySchema({
+ // Computed fields
+ description: {
+ type: 'computedOnlyInlineCalculationField',
optional: true,
},
- quantityExpectedErrors: {
- type: Array,
+ quantityExpected: {
+ type: 'computedOnlyField',
optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
},
- 'quantityExpectedErrors.$':{
- type: ErrorSchema,
+ slotCondition: {
+ type: 'computedOnlyField',
+ optional: true,
},
// Denormalised fields
totalFilled: {
type: SimpleSchema.Integer,
defaultValue: 0,
+ removeBeforeCompute: true,
},
spaceLeft: {
type: SimpleSchema.Integer,
optional: true,
+ removeBeforeCompute: true,
},
});
const ComputedSlotSchema = new SimpleSchema()
- .extend(ComputedOnlySlotSchema)
- .extend(SlotSchema);
+ .extend(ComputedOnlySlotSchema)
+ .extend(SlotSchema);
export { SlotSchema, ComputedSlotSchema, ComputedOnlySlotSchema };
diff --git a/app/imports/api/properties/SpellLists.js b/app/imports/api/properties/SpellLists.js
index bcc8a608..2ea0405a 100644
--- a/app/imports/api/properties/SpellLists.js
+++ b/app/imports/api/properties/SpellLists.js
@@ -1,87 +1,50 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
-import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-let SpellListSchema = new SimpleSchema({
+let SpellListSchema = createPropertySchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
- description: {
- type: String,
+ description: {
+ type: 'inlineCalculationFieldToCompute',
optional: true,
- max: STORAGE_LIMITS.description,
},
// Calculation of how many spells in this list can be prepared
maxPrepared: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
+ type: 'fieldToCompute',
+ optional: true,
+ },
// Calculation of The attack roll bonus used by spell attacks in this list
attackRollBonus: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
+ type: 'fieldToCompute',
+ optional: true,
+ },
// Calculation of the save dc used by spells in this list
dc: {
- type: String,
- optional: true,
- max: STORAGE_LIMITS.calculation,
- },
+ type: 'fieldToCompute',
+ optional: true,
+ },
});
const ComputedOnlySpellListSchema = new SimpleSchema({
- descriptionCalculations: {
- type: Array,
- defaultValue: [],
- maxCount: STORAGE_LIMITS.inlineCalculationCount,
- },
- 'descriptionCalculations.$': InlineComputationSchema,
-
- // maxPrepared
- maxPreparedResult: {
- type: Number,
+ description: {
+ type: 'computedOnlyInlineCalculationField',
optional: true,
},
- maxPreparedErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'maxPreparedErrors.$':{
- type: ErrorSchema,
- },
-
- // attackRollBonus
- attackRollBonusResult: {
- type: Number,
+ maxPrepared: {
+ type: 'computedOnlyField',
optional: true,
},
- attackRollBonusErrors: {
- type: Array,
- optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'attackRollBonusErrors.$':{
- type: ErrorSchema,
- },
-
- // dc
- dcResult: {
- type: Number,
+ attackRollBonus: {
+ type: 'computedOnlyField',
optional: true,
},
- dcErrors: {
- type: Array,
+ dc: {
+ type: 'computedOnlyField',
optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'dcErrors.$':{
- type: ErrorSchema,
},
});
diff --git a/app/imports/api/properties/Spells.js b/app/imports/api/properties/Spells.js
index d1d97fef..304e16a3 100644
--- a/app/imports/api/properties/Spells.js
+++ b/app/imports/api/properties/Spells.js
@@ -44,18 +44,18 @@ let SpellSchema = new SimpleSchema({})
type: String,
optional: true,
defaultValue: 'action',
- max: STORAGE_LIMITS.name,
+ max: STORAGE_LIMITS.spellDetail,
},
range: {
type: String,
optional: true,
- max: STORAGE_LIMITS.name,
+ max: STORAGE_LIMITS.spellDetail,
},
duration: {
type: String,
optional: true,
defaultValue: 'Instantaneous',
- max: STORAGE_LIMITS.name,
+ max: STORAGE_LIMITS.spellDetail,
},
verbal: {
type: Boolean,
@@ -72,7 +72,7 @@ let SpellSchema = new SimpleSchema({})
material: {
type: String,
optional: true,
- max: STORAGE_LIMITS.name,
+ max: STORAGE_LIMITS.spellDetail,
},
ritual: {
type: Boolean,
diff --git a/app/imports/api/properties/Toggles.js b/app/imports/api/properties/Toggles.js
index b63a6ff9..7a76d061 100644
--- a/app/imports/api/properties/Toggles.js
+++ b/app/imports/api/properties/Toggles.js
@@ -1,13 +1,22 @@
import SimpleSchema from 'simpl-schema';
-import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
-const ToggleSchema = new SimpleSchema({
+const ToggleSchema = createPropertySchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
+ variableName: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.variableName,
+ },
+ showUI: {
+ type: Boolean,
+ optional: true,
+ },
disabled: {
type: Boolean,
optional: true,
@@ -19,26 +28,15 @@ const ToggleSchema = new SimpleSchema({
// if neither disabled or enabled, the condition will be run to determine
// if the children of the toggle should be active
condition: {
- type: String,
+ type: 'fieldToCompute',
optional: true,
- max: STORAGE_LIMITS.calculation,
},
});
-const ComputedOnlyToggleSchema = new SimpleSchema({
- // The computed result of the effect
- toggleResult: {
- type: Boolean,
- optional: true,
- },
- // The errors encountered while computing the result
- errors: {
- type: Array,
+const ComputedOnlyToggleSchema = createPropertySchema({
+ condition: {
+ type: 'computedOnlyField',
optional: true,
- maxCount: STORAGE_LIMITS.errorCount,
- },
- 'errors.$': {
- type: ErrorSchema,
},
});
diff --git a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js
index 09ce6265..d47d560d 100644
--- a/app/imports/api/properties/computedOnlyPropertySchemasIndex.js
+++ b/app/imports/api/properties/computedOnlyPropertySchemasIndex.js
@@ -1,26 +1,27 @@
import SimpleSchema from 'simpl-schema';
import { ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
import { ComputedOnlyAdjustmentSchema } from '/imports/api/properties/Adjustments.js';
-import { ComputedOnlyAttackSchema } from '/imports/api/properties/Attacks.js';
import { ComputedOnlyAttributeSchema } from '/imports/api/properties/Attributes.js';
import { ComputedOnlyBuffSchema } from '/imports/api/properties/Buffs.js';
-import { ClassLevelSchema } from '/imports/api/properties/ClassLevels.js';
-import { ConstantSchema } from '/imports/api/properties/Constants.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';
+import { ComputedOnlyConstantSchema } from '/imports/api/properties/Constants.js';
import { ComputedOnlyContainerSchema } from '/imports/api/properties/Containers.js';
import { ComputedOnlyDamageSchema } from '/imports/api/properties/Damages.js';
-import { DamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
+import { ComputedOnlyDamageMultiplierSchema } from '/imports/api/properties/DamageMultipliers.js';
import { ComputedOnlyEffectSchema } from '/imports/api/properties/Effects.js';
import { ComputedOnlyFeatureSchema } from '/imports/api/properties/Features.js';
-import { FolderSchema } from '/imports/api/properties/Folders.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 { ProficiencySchema } from '/imports/api/properties/Proficiencies.js';
-import { ReferenceSchema } from '/imports/api/properties/References.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';
import { ComputedOnlySavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { ComputedOnlySkillSchema } from '/imports/api/properties/Skills.js';
import { ComputedOnlySlotSchema } from '/imports/api/properties/Slots.js';
-import { SlotFillerSchema } from '/imports/api/properties/SlotFillers.js';
+import { ComputedOnlySlotFillerSchema } from '/imports/api/properties/SlotFillers.js';
import { ComputedOnlySpellSchema } from '/imports/api/properties/Spells.js';
import { ComputedOnlySpellListSchema } from '/imports/api/properties/SpellLists.js';
import { ComputedOnlyToggleSchema } from '/imports/api/properties/Toggles.js';
@@ -28,26 +29,27 @@ import { ComputedOnlyToggleSchema } from '/imports/api/properties/Toggles.js';
const propertySchemasIndex = {
action: ComputedOnlyActionSchema,
adjustment: ComputedOnlyAdjustmentSchema,
- attack: ComputedOnlyAttackSchema,
attribute: ComputedOnlyAttributeSchema,
buff: ComputedOnlyBuffSchema,
- classLevel: ClassLevelSchema,
- constant: ConstantSchema,
+ branch: ComputedOnlyBranchSchema,
+ class: ComputedOnlyClassSchema,
+ classLevel: ComputedOnlyClassLevelSchema,
+ constant: ComputedOnlyConstantSchema,
container: ComputedOnlyContainerSchema,
damage: ComputedOnlyDamageSchema,
- damageMultiplier: DamageMultiplierSchema,
+ damageMultiplier: ComputedOnlyDamageMultiplierSchema,
effect: ComputedOnlyEffectSchema,
feature: ComputedOnlyFeatureSchema,
- folder: FolderSchema,
+ folder: ComputedOnlyFolderSchema,
item: ComputedOnlyItemSchema,
note: ComputedOnlyNoteSchema,
- proficiency: ProficiencySchema,
+ proficiency: ComputedOnlyProficiencySchema,
propertySlot: ComputedOnlySlotSchema,
- reference: ReferenceSchema,
+ reference: ComputedOnlyReferenceSchema,
roll: ComputedOnlyRollSchema,
savingThrow: ComputedOnlySavingThrowSchema,
skill: ComputedOnlySkillSchema,
- slotFiller: SlotFillerSchema,
+ slotFiller: ComputedOnlySlotFillerSchema,
spellList: ComputedOnlySpellListSchema,
spell: ComputedOnlySpellSchema,
toggle: ComputedOnlyToggleSchema,
diff --git a/app/imports/api/properties/computedPropertySchemasIndex.js b/app/imports/api/properties/computedPropertySchemasIndex.js
index 02366c05..1fa8c9d8 100644
--- a/app/imports/api/properties/computedPropertySchemasIndex.js
+++ b/app/imports/api/properties/computedPropertySchemasIndex.js
@@ -1,10 +1,11 @@
import SimpleSchema from 'simpl-schema';
import { ComputedActionSchema } from '/imports/api/properties/Actions.js';
import { ComputedAdjustmentSchema } from '/imports/api/properties/Adjustments.js';
-import { ComputedAttackSchema } from '/imports/api/properties/Attacks.js';
import { ComputedAttributeSchema } from '/imports/api/properties/Attributes.js';
import { ComputedBuffSchema } from '/imports/api/properties/Buffs.js';
-import { ClassLevelSchema } from '/imports/api/properties/ClassLevels.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';
import { ConstantSchema } from '/imports/api/properties/Constants.js';
import { ComputedContainerSchema } from '/imports/api/properties/Containers.js';
import { ComputedDamageSchema } from '/imports/api/properties/Damages.js';
@@ -28,10 +29,11 @@ import { ComputedToggleSchema } from '/imports/api/properties/Toggles.js';
const propertySchemasIndex = {
action: ComputedActionSchema,
adjustment: ComputedAdjustmentSchema,
- attack: ComputedAttackSchema,
attribute: ComputedAttributeSchema,
buff: ComputedBuffSchema,
- classLevel: ClassLevelSchema,
+ branch: ComputedBranchSchema,
+ class: ComputedClassSchema,
+ classLevel: ComputedClassLevelSchema,
constant: ConstantSchema,
damage: ComputedDamageSchema,
damageMultiplier: DamageMultiplierSchema,
diff --git a/app/imports/api/properties/propertySchemasIndex.js b/app/imports/api/properties/propertySchemasIndex.js
index 3ad0c4d9..0cba80a0 100644
--- a/app/imports/api/properties/propertySchemasIndex.js
+++ b/app/imports/api/properties/propertySchemasIndex.js
@@ -1,9 +1,10 @@
import SimpleSchema from 'simpl-schema';
import { ActionSchema } from '/imports/api/properties/Actions.js';
import { AdjustmentSchema } from '/imports/api/properties/Adjustments.js';
-import { AttackSchema } from '/imports/api/properties/Attacks.js';
import { AttributeSchema } from '/imports/api/properties/Attributes.js';
import { BuffSchema } from '/imports/api/properties/Buffs.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';
import { ConstantSchema } from '/imports/api/properties/Constants.js';
import { DamageSchema } from '/imports/api/properties/Damages.js';
@@ -28,9 +29,10 @@ import { ItemSchema } from '/imports/api/properties/Items.js';
const propertySchemasIndex = {
action: ActionSchema,
adjustment: AdjustmentSchema,
- attack: AttackSchema,
attribute: AttributeSchema,
buff: BuffSchema,
+ branch: BranchSchema,
+ class: ClassSchema,
classLevel: ClassLevelSchema,
constant: ConstantSchema,
damage: DamageSchema,
diff --git a/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js b/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js
deleted file mode 100644
index 8c85d13f..00000000
--- a/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-import { Random } from 'meteor/random';
-
-const AttributeConsumedSchema = new SimpleSchema({
- _id: {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- autoValue(){
- if (!this.isSet) return Random.id();
- }
- },
- variableName: {
- type: String,
- optional: true,
- },
- quantity: {
- type: Number,
- defaultValue: 1,
- },
-});
-
-export default AttributeConsumedSchema;
diff --git a/app/imports/api/properties/subSchemas/InlineComputationSchema.js b/app/imports/api/properties/subSchemas/InlineComputationSchema.js
index ea17e4e5..e220a3aa 100644
--- a/app/imports/api/properties/subSchemas/InlineComputationSchema.js
+++ b/app/imports/api/properties/subSchemas/InlineComputationSchema.js
@@ -8,8 +8,8 @@ const InlineComputationSchema = new SimpleSchema({
type: String,
max: STORAGE_LIMITS.calculation,
},
- result: {
- type: String,
+ value: {
+ type: SimpleSchema.oneOf(String, Number),
optional: true,
max: STORAGE_LIMITS.calculation,
},
diff --git a/app/imports/api/properties/subSchemas/ItemConsumedSchema.js b/app/imports/api/properties/subSchemas/ItemConsumedSchema.js
deleted file mode 100644
index 8545b6d1..00000000
--- a/app/imports/api/properties/subSchemas/ItemConsumedSchema.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-import { Random } from 'meteor/random';
-
-const ItemConsumedSchema = new SimpleSchema({
- _id: {
- type: String,
- regEx: SimpleSchema.RegEx.Id,
- autoValue(){
- if (!this.isSet) return Random.id();
- }
- },
- tag: {
- type: String,
- optional: true,
- },
- quantity: {
- type: Number,
- defaultValue: 1,
- },
- itemId: {
- type: String,
- optional: true,
- },
-});
-
-export default ItemConsumedSchema;
diff --git a/app/imports/api/properties/subSchemas/ResourcesSchema.js b/app/imports/api/properties/subSchemas/ResourcesSchema.js
deleted file mode 100644
index bc7ae9da..00000000
--- a/app/imports/api/properties/subSchemas/ResourcesSchema.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-import ItemConsumedSchema from '/imports/api/properties/subSchemas/ItemConsumedSchema.js';
-import AttributeConsumedSchema from '/imports/api/properties/subSchemas/AttributeConsumedSchema.js';
-
-const ResourcesSchema = new SimpleSchema({
- itemsConsumed: {
- type: Array,
- defaultValue: [],
- },
- 'itemsConsumed.$': {
- type: ItemConsumedSchema,
- },
- attributesConsumed: {
- type: Array,
- defaultValue: [],
- },
- 'attributesConsumed.$': {
- type: AttributeConsumedSchema,
- },
-});
-
-export default ResourcesSchema;
diff --git a/app/imports/api/properties/subSchemas/computedField.js b/app/imports/api/properties/subSchemas/computedField.js
new file mode 100644
index 00000000..0f0ef37b
--- /dev/null
+++ b/app/imports/api/properties/subSchemas/computedField.js
@@ -0,0 +1,112 @@
+import SimpleSchema from 'simpl-schema';
+import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
+import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+
+// Get schemas that apply fields directly so they can be gracefully extended
+// because {type: Schema} fields can't be extended
+function fieldToCompute(field){
+ const schemaObj = {
+ [`${field}.calculation`]: {
+ type: String,
+ max: STORAGE_LIMITS.calculation,
+ optional: true,
+ },
+ }
+ // If the field is an array, we need to include those fields as well
+ includeParentFields(field, schemaObj);
+ return new SimpleSchema(schemaObj);
+}
+
+function computedOnlyField(field){
+ const schemaObj = {
+ [`${field}.value`]: {
+ type: SimpleSchema.oneOf(String, Number),
+ optional: true,
+ removeBeforeCompute: true,
+ },
+ // A list of effects targeting this calculation
+ [`${field}.effects`]: {
+ type: Array,
+ optional: true,
+ },
+ [`${field}.effects.$`]: {
+ type: Object,
+ blackbox: true,
+ },
+ // A cache of the parse result of the calculation
+ [`${field}.parseNode`]: {
+ type: Object,
+ optional: true,
+ blackbox: true,
+ },
+ // Set if there was an error parsing the calculation
+ [`${field}.parseError`]: {
+ type: ErrorSchema,
+ optional: true,
+ },
+ // a hash of the calculation to see if the cached values need to be updated
+ [`${field}.hash`]: {
+ type: Number,
+ optional: true,
+ },
+ [`${field}.errors`]: {
+ type: Array,
+ optional: true,
+ maxCount: STORAGE_LIMITS.errorCount,
+ removeBeforeCompute: true,
+ },
+ [`${field}.errors.$`]:{
+ type: ErrorSchema,
+ },
+ }
+ includeParentFields(field, schemaObj);
+ return new SimpleSchema(schemaObj);
+}
+
+// We must include parent array and object fields for the schema to be valid
+function includeParentFields(field, schemaObj){
+ const splitField = field.split('.');
+ if (splitField.length === 1){
+ schemaObj[field] = {
+ type: Object,
+ optional: true,
+ computedField: true,
+ };
+ return;
+ }
+ let key = '';
+ splitField.push('');
+ splitField.forEach((value, index) => {
+ if (key){
+ if (value === '$'){
+ schemaObj[key] = {
+ type: Array,
+ optional: true
+ };
+ } else {
+ schemaObj[key] = {
+ type: Object,
+ optional: true,
+ };
+ // the last object is the computed field
+ if (index === splitField.length - 1){
+ schemaObj[key].computedField = true;
+ }
+ }
+ key += '.';
+ }
+ key += value;
+ });
+}
+
+// This should rarely be used, since the other two will merge correctly when
+// uncomputed and computedOnly schemas are merged
+function computedField(field){
+ return computedField(field).extend(computedOnlyField(field));
+}
+
+export {
+ fieldToCompute,
+ computedOnlyField,
+ computedField,
+};
diff --git a/app/imports/api/properties/subSchemas/createPropertySchema.js b/app/imports/api/properties/subSchemas/createPropertySchema.js
new file mode 100644
index 00000000..a866295c
--- /dev/null
+++ b/app/imports/api/properties/subSchemas/createPropertySchema.js
@@ -0,0 +1,90 @@
+import {
+ inlineCalculationFieldToCompute,
+ computedOnlyInlineCalculationField,
+} from '/imports/api/properties/subSchemas/inlineCalculationField.js';
+import {
+ fieldToCompute,
+ computedOnlyField,
+} from '/imports/api/properties/subSchemas/computedField.js';
+import SimpleSchema from 'simpl-schema';
+
+// Search through the schema for keys whose type is 'fieldToCompute' etc.
+// replace the type with Object and attach extend the schema with
+// the required fields to make the computation work
+export default function createPropertySchema(definition){
+ const computationFields = {
+ inlineCalculationFieldToCompute: [],
+ computedOnlyInlineCalculationField: [],
+ fieldToCompute: [],
+ computedOnlyField: [],
+ };
+ const computedKeys = Object.keys(computationFields);
+
+ for (let key in definition){
+ const def = definition[key];
+ if (computedKeys.includes(def.type)){
+ computationFields[def.type].push(key);
+ applyDefaultCalculationValue(definition, key);
+ def.type = Object;
+ if (!def.optional){
+ console.warn(
+ `computed field: '${key}' of '${def.type}' is expected to be optional`
+ );
+ }
+ if (def.removeBeforeCompute){
+ console.warn(
+ `computed field: '${key}' of '${def.type}' should not be removed before computation`
+ )
+ }
+ }
+ }
+
+ // Create a schema with the edited definition
+ const schema = new SimpleSchema(definition);
+
+ // Extend the schema with all the computation fields
+ computationFields.inlineCalculationFieldToCompute.forEach(key => {
+ schema.extend(inlineCalculationFieldToCompute(key))
+ });
+ computationFields.computedOnlyInlineCalculationField.forEach(key => {
+ schema.extend(computedOnlyInlineCalculationField(key))
+ });
+ computationFields.fieldToCompute.forEach(key => {
+ schema.extend(fieldToCompute(key))
+ });
+ computationFields.computedOnlyField.forEach(key => {
+ schema.extend(computedOnlyField(key))
+ });
+ return schema
+}
+
+function applyDefaultCalculationValue(definition, key){
+ const def = definition[key];
+ if (
+ def.type === 'computedOnlyField' ||
+ def.type === 'computedOnlyInlineCalculationField'
+ ){
+ // don't apply defaults to computed only fields
+ // because it would add the calculation field which should only appear
+ // on the fields to compute
+ return;
+ }
+ let defaultValue = def.defaultValue;
+ if (!defaultValue) return;
+ let calcField;
+ if (def.type === 'fieldToCompute'){
+ calcField = key + '.calculation'
+ } else {
+ calcField = key + '.text'
+ }
+ if (definition[calcField]){
+ definition[calcField].defaultValue = defaultValue;
+ } else {
+ definition[calcField] = {
+ type: String,
+ defaultValue,
+ optional: true,
+ };
+ }
+ delete def.defaultValue;
+}
diff --git a/app/imports/api/properties/subSchemas/inlineCalculationField.js b/app/imports/api/properties/subSchemas/inlineCalculationField.js
new file mode 100644
index 00000000..7be26ed7
--- /dev/null
+++ b/app/imports/api/properties/subSchemas/inlineCalculationField.js
@@ -0,0 +1,103 @@
+import SimpleSchema from 'simpl-schema';
+import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
+import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
+
+// Get schemas that apply fields directly so they can be gracefully extended
+// because {type: Schema} fields can't be extended
+function inlineCalculationFieldToCompute(field){
+ return new SimpleSchema({
+ // The object should already be set, but set again just in case
+ [field]: {
+ type: Object,
+ optional: true,
+ inlineCalculationField: true,
+ },
+ [`${field}.text`]: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.inlineCalculationField,
+ },
+ });
+}
+
+function computedOnlyInlineCalculationField(field){
+ return new SimpleSchema({
+ // The object should already be set, but set again just in case
+ [field]: {
+ type: Object,
+ optional: true,
+ inlineCalculationField: true,
+ },
+ // a hash of the text to see if the current cached values need to be updated
+ [`${field}.hash`]: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.inlineCalculationField,
+ },
+ [`${field}.value`]: {
+ type: String,
+ optional: true,
+ max: STORAGE_LIMITS.inlineCalculationField,
+ removeBeforeCompute: true,
+ },
+ [`${field}.inlineCalculations`]: {
+ type: Array,
+ defaultValue: [],
+ maxCount: STORAGE_LIMITS.inlineCalculationCount,
+ },
+ [`${field}.inlineCalculations.$`]: {
+ type: Object,
+ parseLevel: 'compile',
+ computedField: true,
+ },
+ // The part between bracers {}
+ [`${field}.inlineCalculations.$.calculation`]: {
+ type: String,
+ max: STORAGE_LIMITS.calculation,
+ },
+ // The result of the calc
+ [`${field}.inlineCalculations.$.value`]: {
+ type: SimpleSchema.oneOf(String, Number),
+ optional: true,
+ max: STORAGE_LIMITS.calculation,
+ removeBeforeCompute: true,
+ },
+ // A cache of the parse result of the calculation
+ [`${field}.inlineCalculations.$.parseNode`]: {
+ type: Object,
+ optional: true,
+ blackbox: true,
+ },
+ // Set if there was an error parsing the calculation
+ [`${field}.inlineCalculations.$.parseError`]: {
+ type: ErrorSchema,
+ optional: true,
+ },
+ // a hash of the calculation to see if the cached values need to be updated
+ [`${field}.inlineCalculations.$.hash`]: {
+ type: Number,
+ optional: true,
+ },
+ [`${field}.inlineCalculations.$.errors`]: {
+ type: Array,
+ optional: true,
+ maxCount: STORAGE_LIMITS.errorCount,
+ removeBeforeCompute: true,
+ },
+ [`${field}.inlineCalculations.$.errors.$`]: {
+ type: ErrorSchema,
+ },
+ });
+}
+
+function computedInlineCalculationField(field){
+ return inlineCalculationFieldToCompute(field).extend(
+ computedOnlyInlineCalculationField(field)
+ )
+}
+
+export {
+ inlineCalculationFieldToCompute,
+ computedOnlyInlineCalculationField,
+ computedInlineCalculationField,
+};
diff --git a/app/imports/api/sharing/sharingPermissions.js b/app/imports/api/sharing/sharingPermissions.js
index 16831209..6f48ed4b 100644
--- a/app/imports/api/sharing/sharingPermissions.js
+++ b/app/imports/api/sharing/sharingPermissions.js
@@ -4,7 +4,7 @@ import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
function assertIdValid(userId){
if (!userId || typeof userId !== 'string'){
throw new Meteor.Error('Permission denied',
- 'No user ID given for edit permission check');
+ 'No user ID. Are you logged in?');
}
}
diff --git a/app/imports/api/simpleSchemaConfig.js b/app/imports/api/simpleSchemaConfig.js
new file mode 100644
index 00000000..aff36261
--- /dev/null
+++ b/app/imports/api/simpleSchemaConfig.js
@@ -0,0 +1,30 @@
+import SimpleSchema from 'simpl-schema';
+
+SimpleSchema.extendOptions([
+ 'parseLevel',
+ 'removeBeforeCompute',
+ 'inlineCalculationField',
+ 'computedField',
+]);
+
+// Store a quick way of referencing keys that have specific tags === true
+function storeTaggedKeys(tag, fnName){
+ SimpleSchema.prototype[fnName] = function(){
+ if (!this['_' + fnName]){
+ this['_' + fnName] = [];
+ for (const key in this._schema){
+ if (this._schema[key][tag]){
+ this['_' + fnName].push(key);
+ }
+ }
+ }
+ return this['_' + fnName];
+ }
+}
+
+// Keys that should be deleted at the start of a computation
+storeTaggedKeys('removeBeforeCompute', 'removeBeforeComputeFields');
+// Keys that represent inline calculation objects
+storeTaggedKeys('inlineCalculationField', 'inlineCalculationFields');
+// Keys that represent computed field objects
+storeTaggedKeys('computedField', 'computedFields');
diff --git a/app/imports/api/tabletop/Tabletops.js b/app/imports/api/tabletop/Tabletops.js
index cf991b55..5c81986c 100644
--- a/app/imports/api/tabletop/Tabletops.js
+++ b/app/imports/api/tabletop/Tabletops.js
@@ -1,8 +1,4 @@
import SimpleSchema from 'simpl-schema';
-import { ValidatedMethod } from 'meteor/mdg:validated-method';
-import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
-import Creatures from '/imports/api/creature/creatures/Creatures.js';
let Tabletops = new Mongo.Collection('tabletops');
@@ -52,150 +48,8 @@ let TabletopSchema = new SimpleSchema({
Tabletops.attachSchema(TabletopSchema);
-function assertUserIsTabletopOwner(tabletopId, userId){
- let tabletop = Tabletops.findOne(tabletopId);
- if (!tabletop){
- throw new Meteor.Error('Tabletop does not exist',
- 'No tabletop could be found for the given tabletop id');
- }
- if (tabletop.gameMaster !== userId){
- throw new Meteor.Error('Not the owner',
- 'The user is not the owner of the given tabletop');
- }
-}
-
-export function assertUserInTabletop(tabletopId, userId){
- let tabletop = Tabletops.findOne(tabletopId);
- if (!tabletop){
- throw new Meteor.Error('Tabletop does not exist',
- 'No tabletop could be found for the given tabletop id');
- }
- if (tabletop.gameMaster !== userId && !tabletop.players.includes(userId)){
- throw new Meteor.Error('Not in tabletop',
- 'The user is not the gamemaster or a player in the given tabletop');
- }
-}
-
-function assertUserIsAdmin(userId){
- if (!Meteor.users.isAdmin(userId)){
- throw new Meteor.Error('Admin only',
- 'This is restricted to admins for now');
- }
-}
-
-const insertTabletop = new ValidatedMethod({
-
- name: 'tabletops.insert',
-
- validate: null,
-
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 5,
- timeInterval: 5000,
- },
-
- run() {
- if (!this.userId) {
- throw new Meteor.Error('tabletops.insert.denied',
- 'You need to be logged in to insert a tabletop');
- }
- assertUserHasPaidBenefits(this.userId);
- assertUserIsAdmin(this.userId);
-
- return Tabletops.insert({
- gameMaster: this.userId,
- });
- },
-
-});
-
-const removeTabletop = new ValidatedMethod({
-
- name: 'tabletops.remove',
-
- validate: new SimpleSchema({
- tabletopId: {
- type: String,
- regEx: SimpleSchema.RegEx.id,
- },
- }).validator(),
-
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 5,
- timeInterval: 5000,
- },
-
- run({tabletopId}) {
- if (!this.userId) {
- throw new Meteor.Error('tabletops.remove.denied',
- 'You need to be logged in to remove a tabletop');
- }
- assertUserHasPaidBenefits(this.userId);
- assertUserIsTabletopOwner(tabletopId, this.userId);
- assertUserIsAdmin(this.userId);
-
- let removed = Tabletops.remove({
- _id: tabletopId,
- });
- Creatures.update({
- tabletop: tabletopId,
- }, {
- $unset: {tabletop: 1},
- });
- return removed;
- },
-
-});
-
-const addCreaturesToTabletop = new ValidatedMethod({
-
- name: 'tabletops.addCreatures',
-
- validate: new SimpleSchema({
- 'creatureIds': {
- type: Array,
- },
- 'creatureIds.$': {
- type: String,
- regEx: SimpleSchema.RegEx.id,
- },
- tabletopId: {
- type: String,
- regEx: SimpleSchema.RegEx.id,
- },
- }).validator(),
-
- mixins: [RateLimiterMixin],
- rateLimit: {
- numRequests: 10,
- timeInterval: 5000,
- },
-
- run({tabletopId, creatureIds}) {
- if (!this.userId) {
- throw new Meteor.Error('tabletops.addCreatures.denied',
- 'You need to be logged in to remove a tabletop');
- }
- assertUserHasPaidBenefits(this.userId);
- assertUserInTabletop(tabletopId, this.userId);
- assertUserIsAdmin(this.userId);
-
- Creatures.update({
- _id: {$in: creatureIds},
- $or: [
- {writers: this.userId},
- {owner: this.userId},
- ],
- }, {
- $set: {tabletop: tabletopId},
- }, {
- multi: true,
- });
- },
-
-});
+import '/imports/api/tabletop/methods/removeTabletop.js';
+import '/imports/api/tabletop/methods/insertTabletop.js';
+import '/imports/api/tabletop/methods/addCreaturesToTabletop.js';
export default Tabletops;
-export { insertTabletop, removeTabletop, addCreaturesToTabletop };
diff --git a/app/imports/api/tabletop/methods/addCreaturesToTabletop.js b/app/imports/api/tabletop/methods/addCreaturesToTabletop.js
new file mode 100644
index 00000000..b17b953b
--- /dev/null
+++ b/app/imports/api/tabletop/methods/addCreaturesToTabletop.js
@@ -0,0 +1,57 @@
+import SimpleSchema from 'simpl-schema';
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import { assertUserInTabletop } from './shared/tabletopPermissions.js';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
+import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
+import Creatures from '/imports/api/creature/creatures/Creatures.js';
+
+const addCreaturesToTabletop = new ValidatedMethod({
+
+ name: 'tabletops.addCreatures',
+
+ validate: new SimpleSchema({
+ 'creatureIds': {
+ type: Array,
+ },
+ 'creatureIds.$': {
+ type: String,
+ regEx: SimpleSchema.RegEx.id,
+ },
+ tabletopId: {
+ type: String,
+ regEx: SimpleSchema.RegEx.id,
+ },
+ }).validator(),
+
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 10,
+ timeInterval: 5000,
+ },
+
+ run({tabletopId, creatureIds}) {
+ if (!this.userId) {
+ throw new Meteor.Error('tabletops.addCreatures.denied',
+ 'You need to be logged in to remove a tabletop');
+ }
+ assertUserHasPaidBenefits(this.userId);
+ assertUserInTabletop(tabletopId, this.userId);
+ assertAdmin(this.userId);
+
+ Creatures.update({
+ _id: {$in: creatureIds},
+ $or: [
+ {writers: this.userId},
+ {owner: this.userId},
+ ],
+ }, {
+ $set: {tabletop: tabletopId},
+ }, {
+ multi: true,
+ });
+ },
+
+});
+
+export default addCreaturesToTabletop;
diff --git a/app/imports/api/tabletop/methods/insertTabletop.js b/app/imports/api/tabletop/methods/insertTabletop.js
new file mode 100644
index 00000000..0857031a
--- /dev/null
+++ b/app/imports/api/tabletop/methods/insertTabletop.js
@@ -0,0 +1,34 @@
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import Tabletops from '../Tabletops.js';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
+import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
+
+const insertTabletop = new ValidatedMethod({
+
+ name: 'tabletops.insert',
+
+ validate: null,
+
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 5,
+ timeInterval: 5000,
+ },
+
+ run() {
+ if (!this.userId) {
+ throw new Meteor.Error('tabletops.insert.denied',
+ 'You need to be logged in to insert a tabletop');
+ }
+ assertUserHasPaidBenefits(this.userId);
+ assertAdmin(this.userId);
+
+ return Tabletops.insert({
+ gameMaster: this.userId,
+ });
+ },
+
+});
+
+export default insertTabletop;
diff --git a/app/imports/api/tabletop/methods/removeTabletop.js b/app/imports/api/tabletop/methods/removeTabletop.js
new file mode 100644
index 00000000..633cc6d0
--- /dev/null
+++ b/app/imports/api/tabletop/methods/removeTabletop.js
@@ -0,0 +1,49 @@
+import SimpleSchema from 'simpl-schema';
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import Tabletops from '../Tabletops.js';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
+import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
+import { assertUserIsTabletopOwner } from './shared/tabletopPermissions.js';
+import Creatures from '/imports/api/creature/creatures/Creatures.js';
+
+const removeTabletop = new ValidatedMethod({
+
+ name: 'tabletops.remove',
+
+ validate: new SimpleSchema({
+ tabletopId: {
+ type: String,
+ regEx: SimpleSchema.RegEx.id,
+ },
+ }).validator(),
+
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 5,
+ timeInterval: 5000,
+ },
+
+ run({tabletopId}) {
+ if (!this.userId) {
+ throw new Meteor.Error('tabletops.remove.denied',
+ 'You need to be logged in to remove a tabletop');
+ }
+ assertUserHasPaidBenefits(this.userId);
+ assertUserIsTabletopOwner(tabletopId, this.userId);
+ assertAdmin(this.userId);
+
+ let removed = Tabletops.remove({
+ _id: tabletopId,
+ });
+ Creatures.update({
+ tabletop: tabletopId,
+ }, {
+ $unset: {tabletop: 1},
+ });
+ return removed;
+ },
+
+});
+
+export default removeTabletop;
diff --git a/app/imports/api/tabletop/methods/shared/tabletopPermissions.js b/app/imports/api/tabletop/methods/shared/tabletopPermissions.js
new file mode 100644
index 00000000..2f14543d
--- /dev/null
+++ b/app/imports/api/tabletop/methods/shared/tabletopPermissions.js
@@ -0,0 +1,25 @@
+import Tabletops from '../../Tabletops.js';
+
+export function assertUserInTabletop(tabletopId, userId){
+ let tabletop = Tabletops.findOne(tabletopId);
+ if (!tabletop){
+ throw new Meteor.Error('Tabletop does not exist',
+ 'No tabletop could be found for the given tabletop id');
+ }
+ if (tabletop.gameMaster !== userId && !tabletop.players.includes(userId)){
+ throw new Meteor.Error('Not in tabletop',
+ 'The user is not the gamemaster or a player in the given tabletop');
+ }
+}
+
+export function assertUserIsTabletopOwner(tabletopId, userId){
+ let tabletop = Tabletops.findOne(tabletopId);
+ if (!tabletop){
+ throw new Meteor.Error('Tabletop does not exist',
+ 'No tabletop could be found for the given tabletop id');
+ }
+ if (tabletop.gameMaster !== userId){
+ throw new Meteor.Error('Not the owner',
+ 'The user is not the owner of the given tabletop');
+ }
+}
diff --git a/app/imports/api/users/Invites.js b/app/imports/api/users/Invites.js
index 87a6301c..af98adf2 100644
--- a/app/imports/api/users/Invites.js
+++ b/app/imports/api/users/Invites.js
@@ -16,7 +16,6 @@ let InviteSchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
optional: true,
index: 1,
- unique: 1,
},
inviteToken: {
type: String,
diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js
index cd963707..be3fd3a8 100644
--- a/app/imports/api/users/Users.js
+++ b/app/imports/api/users/Users.js
@@ -1,7 +1,10 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
-import '/imports/api/users/deleteMyAccount.js';
+import '/imports/api/users/methods/deleteMyAccount.js';
+import '/imports/api/users/methods/addEmail.js';
+import '/imports/api/users/methods/removeEmail.js';
+
import { some } from 'lodash';
const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || [];
@@ -168,12 +171,6 @@ Meteor.users.sendVerificationEmail = new ValidatedMethod({
}
});
-Meteor.users.isAdmin = function(userId){
- userId = this.userId || userId;
- let user = Meteor.users.findOne(userId);
- return user && user.roles.includes('admin');
-}
-
Meteor.users.canPickUsername = new ValidatedMethod({
name: 'users.canPickUsername',
validate: userSchema.pick('username').validator(),
diff --git a/app/imports/api/users/methods/addEmail.js b/app/imports/api/users/methods/addEmail.js
new file mode 100644
index 00000000..af8e81f6
--- /dev/null
+++ b/app/imports/api/users/methods/addEmail.js
@@ -0,0 +1,34 @@
+import SimpleSchema from 'simpl-schema';
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+
+const addEmail = new ValidatedMethod({
+ name: 'users.addEmail',
+ validate: new SimpleSchema({
+ email: {
+ type: String,
+ regEx: SimpleSchema.RegEx.Email,
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 1,
+ timeInterval: 5000,
+ },
+ 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){
+ throw new Meteor.Error('Emails full',
+ 'You may only have up to 2 email addresses per account');
+ }
+ if (Meteor.isServer){
+ Accounts.addEmail(userId, email);
+ Accounts.sendVerificationEmail(userId, email);
+ }
+ }
+});
+
+export default addEmail;
diff --git a/app/imports/api/users/deleteMyAccount.js b/app/imports/api/users/methods/deleteMyAccount.js
similarity index 96%
rename from app/imports/api/users/deleteMyAccount.js
rename to app/imports/api/users/methods/deleteMyAccount.js
index d887b8c0..f6947bfc 100644
--- a/app/imports/api/users/deleteMyAccount.js
+++ b/app/imports/api/users/methods/deleteMyAccount.js
@@ -15,7 +15,7 @@ Meteor.users.deleteMyAccount = new ValidatedMethod({
run(){
let userId = Meteor.userId();
if (!userId) throw new Meteor.Error('No user',
- 'You must be logged into to delete your account');
+ 'You must be logged in to delete your account');
// Delete all creatures
let creatures = Creatures.find({owner: userId}, {fields: {_id: 1}}).fetch();
diff --git a/app/imports/api/users/linkWithPatreon.js b/app/imports/api/users/methods/linkWithPatreon.js
similarity index 100%
rename from app/imports/api/users/linkWithPatreon.js
rename to app/imports/api/users/methods/linkWithPatreon.js
diff --git a/app/imports/api/users/methods/removeEmail.js b/app/imports/api/users/methods/removeEmail.js
new file mode 100644
index 00000000..86ffb822
--- /dev/null
+++ b/app/imports/api/users/methods/removeEmail.js
@@ -0,0 +1,37 @@
+import SimpleSchema from 'simpl-schema';
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+
+const removeEmail = new ValidatedMethod({
+ name: 'users.removeEmail',
+ validate: new SimpleSchema({
+ email: {
+ type: String,
+ regEx: SimpleSchema.RegEx.Email,
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 1,
+ timeInterval: 5000,
+ },
+ 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){
+ throw new Meteor.Error('No email to remove',
+ 'No email addresses are associated with this account');
+ }
+ 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');
+ }
+ if (Meteor.isServer){
+ Accounts.removeEmail(userId, email);
+ }
+ }
+});
+
+export default removeEmail;
diff --git a/app/imports/constants/MAINTENANCE_MODE.js b/app/imports/constants/MAINTENANCE_MODE.js
new file mode 100644
index 00000000..f81e01e4
--- /dev/null
+++ b/app/imports/constants/MAINTENANCE_MODE.js
@@ -0,0 +1,19 @@
+import { Migrations } from 'meteor/percolate:migrations';
+import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js';
+
+if (Meteor.isServer){
+ Meteor.startup(()=>{
+ const dbVersion = Migrations.getVersion();
+ if (
+ !Meteor.settings.public.maintenanceMode &&
+ SCHEMA_VERSION !== dbVersion
+ ){
+ Meteor.settings.public.maintenanceMode = {
+ reason: 'App data needs to be migrated to the latest version'
+ };
+ }
+ });
+}
+
+const MAINTENANCE_MODE = Meteor.settings.public.maintenanceMode;
+export default MAINTENANCE_MODE;
diff --git a/app/imports/constants/PROPERTIES.js b/app/imports/constants/PROPERTIES.js
index d1ee0bb7..4c35c038 100644
--- a/app/imports/constants/PROPERTIES.js
+++ b/app/imports/constants/PROPERTIES.js
@@ -5,12 +5,6 @@ const PROPERTIES = Object.freeze({
helpText: 'Actions are things your character can do. When an action is taken, all the properties under it are activated.',
suggestedParents: ['classLevel', 'feature', 'item'],
},
- attack: {
- icon: '$vuetify.icons.attack',
- name: 'Attack',
- helpText: 'Attacks are a special form of action that includes an attack roll. Attacks can critical hit, which doubles the number of damage dice in properties under the attack.',
- suggestedParents: ['classLevel', 'feature', 'item'],
- },
attribute: {
icon: '$vuetify.icons.attribute',
name: 'Attribute',
@@ -22,14 +16,26 @@ const PROPERTIES = Object.freeze({
icon: '$vuetify.icons.attribute_damage',
name: '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'],
+ suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'],
},
buff: {
icon: '$vuetify.icons.buff',
name: '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'],
+ },
+ branch: {
+ icon: 'mdi-file-tree',
+ name: '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',
+ helpText: 'Your character should ideally have one starting class. Classes hold class levels',
+ suggestedParents: [],
+ },
classLevel: {
icon: '$vuetify.icons.class_level',
name: 'Class level',
@@ -53,7 +59,7 @@ const PROPERTIES = Object.freeze({
icon: '$vuetify.icons.damage',
name: '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'],
+ suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'],
},
damageMultiplier: {
icon: '$vuetify.icons.damage_multiplier',
@@ -102,7 +108,7 @@ const PROPERTIES = Object.freeze({
icon: '$vuetify.icons.roll',
name: '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'],
+ suggestedParents: ['action', 'attack', 'savingThrow', 'spell', 'branch'],
},
reference: {
icon: 'mdi-vector-link',
diff --git a/app/imports/constants/SCHEMA_VERSION.js b/app/imports/constants/SCHEMA_VERSION.js
new file mode 100644
index 00000000..4ac57384
--- /dev/null
+++ b/app/imports/constants/SCHEMA_VERSION.js
@@ -0,0 +1,3 @@
+const SCHEMA_VERSION = 1;
+
+export default SCHEMA_VERSION;
diff --git a/app/imports/constants/STORAGE_LIMITS.js b/app/imports/constants/STORAGE_LIMITS.js
index 23f07fbe..f2bd1e1f 100644
--- a/app/imports/constants/STORAGE_LIMITS.js
+++ b/app/imports/constants/STORAGE_LIMITS.js
@@ -1,16 +1,21 @@
const STORAGE_LIMITS = Object.freeze({
// String lengths
- calculation: 256,
+ calculation: 1024,
collectionName: 64,
- color: 10000,
description: 49473, //the length of the Bee Movie script
+ inlineCalculationField: 49473,
errorMessage: 256,
icon: 10000,
name: 128,
summary: 10000,
tagLength: 128,
- url: 256,
+ url: 1024,
variableName: 64,
+ spellDetail: 512,
+ effectText: 512,
+
+ // Number limits
+ levelMax: 128,
//Array counts
ancestorCount: 100,
@@ -18,13 +23,12 @@ const STORAGE_LIMITS = Object.freeze({
diceRollValuesCount: 100,
errorCount: 32,
extraTagsCount: 5,
- inlineCalculationCount: 32,
+ inlineCalculationCount: 64,
logContentCount: 32,
readersCount: 50,
resourcesCount: 32,
rollCount: 64,
- rollBonusCount: 32,
- statsToTarget: 32,
+ statsToTarget: 64,
tagCount: 64,
writersCount: 20,
});
diff --git a/app/imports/migrations/methods/getVersion.js b/app/imports/migrations/methods/getVersion.js
new file mode 100644
index 00000000..8b67d5fe
--- /dev/null
+++ b/app/imports/migrations/methods/getVersion.js
@@ -0,0 +1,30 @@
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
+import { Migrations } from 'meteor/percolate:migrations';
+
+const dbVersionToGitVersion = {
+ 0: '2.0-beta.32 and lower',
+ 1: '2.0-beta.33',
+}
+
+const getVersion = new ValidatedMethod({
+ name: 'admin.getVersion',
+ validate: null,
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 5,
+ timeInterval: 5000,
+ },
+ run() {
+ if (Meteor.isClient) return;
+ assertAdmin(this.userId);
+ const dbVersion = Migrations.getVersion();
+ return {
+ dbVersion,
+ gitVersion: dbVersionToGitVersion[dbVersion],
+ }
+ },
+});
+
+export default getVersion;
diff --git a/app/imports/migrations/methods/index.js b/app/imports/migrations/methods/index.js
new file mode 100644
index 00000000..6d5dc8c5
--- /dev/null
+++ b/app/imports/migrations/methods/index.js
@@ -0,0 +1,2 @@
+import './migrateTo.js';
+import './getVersion.js';
diff --git a/app/imports/migrations/methods/migrateTo.js b/app/imports/migrations/methods/migrateTo.js
new file mode 100644
index 00000000..c5248c9d
--- /dev/null
+++ b/app/imports/migrations/methods/migrateTo.js
@@ -0,0 +1,29 @@
+import { ValidatedMethod } from 'meteor/mdg:validated-method';
+import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
+import SimpleSchema from 'simpl-schema';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
+import { Migrations } from 'meteor/percolate:migrations';
+
+const migrateTo = new ValidatedMethod({
+ name: 'admin.migrateTo',
+ validate: new SimpleSchema({
+ version: {
+ type: SimpleSchema.oneOf(
+ SimpleSchema.Integer,
+ String
+ ),
+ },
+ }).validator(),
+ mixins: [RateLimiterMixin],
+ rateLimit: {
+ numRequests: 5,
+ timeInterval: 5000,
+ },
+ run({version}) {
+ if (Meteor.isClient) return;
+ assertAdmin(this.userId);
+ Migrations.migrateTo(version);
+ },
+});
+
+export default migrateTo;
diff --git a/app/imports/migrations/server/dbv1/dbv1.js b/app/imports/migrations/server/dbv1/dbv1.js
new file mode 100644
index 00000000..530f0774
--- /dev/null
+++ b/app/imports/migrations/server/dbv1/dbv1.js
@@ -0,0 +1,260 @@
+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';
+
+// Git version 2.0-beta.33
+// Database version 1
+Migrations.add({
+ version: 1,
+ name: 'Unifies calculated field schema',
+ up(){
+ migrate();
+ },
+ down(){
+ migrate({reversed: true});
+ },
+});
+
+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}){
+ const bulk = collection.rawCollection().initializeUnorderedBulkOp();
+ collection.find({}).forEach(prop => {
+ const newProp = migrateProperty({collection, reversed, prop});
+ bulk.find({ _id: prop._id }).replaceOne(newProp);
+ });
+ bulk.execute();
+}
+
+export function migrateProperty({collection, reversed, prop}){
+ const transforms = [
+ ...(transformsByPropType[prop.type] || []),
+ {from: 'dependencies'}
+ ];
+ let migratedProp = transformFields(prop, transforms, reversed);
+ const schema = collection.simpleSchema({type: migratedProp.type});
+ // Only clean if the schema version matches our destination version
+ if(!reversed && SCHEMA_VERSION === 1){
+ try {
+ migratedProp = schema.clean(migratedProp);
+ schema.validate(migratedProp);
+ } catch(e){
+ if (e.details[0]?.type === 'maxString'){
+
+ console.log({
+ prop: prop,
+ details: e.details,
+ });
+ } else {
+ console.warn({prop, error: e});
+ }
+ }
+ }
+ return migratedProp;
+}
+
+const actionTransforms = [
+ ...getComputedPropertyTransforms('uses'),
+ ...getComputedPropertyTransforms('resources.attributesConsumed.$.quantity'),
+ ...getComputedPropertyTransforms('resources.itemsConsumed.$.quantity'),
+ ...getInlineComputationTransforms('summary'),
+ ...getInlineComputationTransforms('description'),
+];
+
+const transformsByPropType = {
+ 'action': actionTransforms,
+ 'adjustment': [
+ ...getComputedPropertyTransforms('amount'),
+ {from: 'target', to: 'target', up: simplifyTarget},
+ ],
+ 'attack': [
+ ...actionTransforms,
+ ...getComputedPropertyTransforms('rollBonus', 'attackRoll'),
+ //change type to action
+ {from: 'type', to: 'type', up: () => 'action'},
+ ],
+ 'attribute': [
+ // from: baseValue must be first or else it will delete the field we need
+ {from: 'baseValue', to: 'baseValue.value', up: nanToNull},
+ {from: 'baseValueCalculation', to: 'baseValue.calculation', up: calculationUp, down: calculationDown},
+ {from: 'baseValueErrors', to: 'baseValue.errors', up: trimErrors},
+ ...getComputedPropertyTransforms('spellSlotLevel'),
+ ...getInlineComputationTransforms('description'),
+ {from: 'value', to: 'total', up: nanToNull},
+ {from: 'currentValue', to: 'value', up: nanToNull},
+ {from: 'proficiency', to: 'proficiency', up: stripZero},
+ ],
+ 'buff': [
+ ...getComputedPropertyTransforms('duration'),
+ ...getInlineComputationTransforms('description'),
+ {from: 'value', to: 'total', up: nanToNull},
+ {from: 'target', to: 'target', up: simplifyTarget},
+ {from: 'applied'},
+ ],
+ 'classLevel': [
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'container': [
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'damage': [
+ ...getComputedPropertyTransforms('amount'),
+ {from: 'target', to: 'target', up: simplifyTarget},
+ ],
+ 'effect': [
+ {from: 'calculation', to: 'amount.calculation'},
+ {from: 'result', to: 'amount.value', up: nanToNull},
+ {from: 'errors', to: 'amount.errors', up: trimErrors},
+ {from: 'name', to: 'name', up(val, src, doc){
+ if (src.operation === 'conditional'){
+ doc.text = val;
+ return;
+ } else {
+ return val;
+ }
+ }},
+ ],
+ 'feature': [
+ ...getInlineComputationTransforms('summary'),
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'item': [
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'note': [
+ ...getInlineComputationTransforms('summary'),
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'roll': [
+ ...getComputedPropertyTransforms('roll'),
+ ],
+ 'savingThrow': [
+ ...getComputedPropertyTransforms('dc'),
+ {from: 'target', to: 'target', up: simplifyTarget},
+ ],
+ 'skill': [
+ ...getComputedPropertyTransforms('baseValue'),
+ ...getInlineComputationTransforms('description'),
+ {from: 'value', to: 'value', up: nanToNull},
+ {from: 'passiveBonus', to: 'passiveBonus', up: nanToNull},
+ {from: 'proficiency', to: 'proficiency', up: stripZero},
+ ],
+ 'spell': [
+ ...actionTransforms,
+ ],
+ 'proficiency': [
+ {from: 'value', to: 'value', up: stripZero},
+ ],
+ 'propertySlot': [
+ ...getComputedPropertyTransforms('quantityExpected'),
+ ...getComputedPropertyTransforms('slotCondition'),
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'spellList': [
+ ...getComputedPropertyTransforms('maxPrepared'),
+ ...getComputedPropertyTransforms('dc'),
+ ...getComputedPropertyTransforms('attackRollBonus'),
+ ...getInlineComputationTransforms('description'),
+ ],
+ 'toggle': [
+ {from: 'condition', to: 'condition.calculation'},
+ {from: 'toggleResult', to: 'condition.value', up: nanToNull},
+ {from: 'errors', to: 'condition.errors', up: trimErrors},
+ ],
+};
+
+function getComputedPropertyTransforms(key, toKey){
+ if (!toKey) toKey = key;
+ return [
+ {from: key, to: `${key}.calculation`, up: calculationUp, down: calculationDown},
+ {from: `${key}Result`, to: `${toKey}.value`, up: nanToNull},
+ {from: `${key}Errors`, to: `${toKey}.errors`, up: trimErrors},
+ ];
+}
+
+function getInlineComputationTransforms(key){
+ return [
+ {from: key, to: `${key}.text`},
+ {from: `${key}Calculations`, to: `${key}.inlineCalculations`, up: calculationUp, down: calculationDown},
+ {from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`},
+ ];
+}
+
+function calculationUp(val){
+ if (typeof val !== 'string') return val;
+ return val.replace('.value', '.total').replace('.currentValue', '.value');
+}
+
+function calculationDown(val){
+ if (typeof val !== 'string') return val;
+ return val.replace('.value', '.currentValue').replace('.total', '.value');
+}
+
+function nanToNull(val){
+ if (Number.isNaN(val)){
+ return null;
+ } else {
+ return val;
+ }
+}
+
+function stripZero(val){
+ if (val === 0){
+ return undefined;
+ } else {
+ return val;
+ }
+}
+
+function simplifyTarget(val){
+ if (val === 'self'){
+ return val;
+ } else {
+ return 'target';
+ }
+}
+
+function trimErrors(arr){
+ if(!arr) return arr;
+ arr.forEach(e => {
+ if (e.message.length > STORAGE_LIMITS.errorMessage){
+ e.message = e.message.slice(0, STORAGE_LIMITS.errorMessage);
+ }
+ });
+ return arr;
+}
diff --git a/app/imports/migrations/server/dbv1/dbv1.test.js b/app/imports/migrations/server/dbv1/dbv1.test.js
new file mode 100644
index 00000000..13954186
--- /dev/null
+++ b/app/imports/migrations/server/dbv1/dbv1.test.js
@@ -0,0 +1,137 @@
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+import { migrateProperty } from './dbv1.js';
+import { assert } from 'chai';
+
+const exampleAction = {
+ '_id':'hY5MKZ4ivaoTRpNWy',
+ 'actionType':'bonus',
+ 'target':'singleTarget',
+ 'tags':[],
+ 'resources':{
+ 'itemsConsumed':[],
+ 'attributesConsumed':[{
+ '_id':'FaK6jXEj3pSe7mNuu',
+ 'quantity': '1',
+ 'variableName':'HunterTech',
+ 'statName':'Hunter\'s Technique',
+ 'available':5
+ }],
+ },
+ 'type':'action',
+ 'name':'Hexblade\\\'s Curse',
+ 'parent':{
+ 'id':'JqtDmqa5Zd3xpts5G',
+ 'collection':'creatureProperties'
+ },
+ 'ancestors':[
+ {
+ 'collection':'creatures',
+ 'id':'X9rzFhsgFhodYfHmG'
+ },
+ ],
+ 'order':315,
+ 'summary':'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}',
+ 'uses':'1',
+ 'usesResult':1,
+ 'reset':'shortRest',
+ 'usesUsed':0,
+ 'description':'Starting at 1st level, you gain the ability to place a baleful curse on someone. As a bonus action, choose one creature you can see within 30 feet of you. The target is cursed for 1 minute. The curse ends early if the target dies, you die, or you are incapacitated. Until the curse ends, you gain the following benefits:\n\n- You gain a bonus to damage rolls against the cursed target. The bonus equals your proficiency bonus.\n- Any attack roll you make against the cursed target is a critical hit on a roll of 19 or 20 on the d20.\n- If the cursed target dies, you regain hit points equal to your warlock level + your Charisma modifier (minimum of 1 hit point). \n{warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."} \nYou can\\\'t use this feature again until you finish a short or long rest.',
+ 'color':'#8e24aa',
+ 'descriptionCalculations':[
+ {
+ 'calculation':'warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
+ 'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
+ }
+ ],
+ 'summaryCalculations':[
+ {
+ 'calculation':'warlock.level >14 ? "" : "the target dies, or"',
+ 'result':'the target dies, or'
+ },
+ {
+ 'calculation':'proficiencyBonus',
+ 'result':'4'
+ },
+ {
+ 'calculation':'warlock.level+charisma.modifier',
+ 'result':'15'
+ },
+ {
+ 'calculation':'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
+ 'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
+ }
+ ]
+};
+
+const exampleAttribute = {
+ _id:'idRWyoj5oxCv73feM',
+ name:'Hit Dice',
+ variableName:'clericHitDice',
+ attributeType:'hitDice',
+ type:'attribute',
+ hitDiceSize:'d8',
+ baseValueCalculation:'cleric.level',
+ parent:{'id':'8jSWKxvgQyKbunFtD','collection':'creatureProperties'},
+ ancestors:[
+ {'collection':'creatures','id':'m9sdCvs6iDf7qRaGv'},
+ {'id':'8jSWKxvgQyKbunFtD','collection':'creatureProperties'}
+ ],
+ order: 84,
+ value: 20,
+ tags:[],
+ baseValue: 20,
+ damage: 3,
+ currentValue: 17,
+ constitutionMod: 2,
+ dependencies: ['8jSWKxvgQyKbunFtD','qPP5yQXPxS7uhuXo3']
+};
+
+const expectedMigratedAttribute = {
+ _id:'idRWyoj5oxCv73feM',
+ name:'Hit Dice',
+ variableName:'clericHitDice',
+ attributeType:'hitDice',
+ type:'attribute',
+ hitDiceSize:'d8',
+ baseValue: {
+ calculation: 'cleric.level',
+ value: 20
+ },
+ parent:{'id':'8jSWKxvgQyKbunFtD','collection':'creatureProperties'},
+ ancestors:[
+ {'collection':'creatures','id':'m9sdCvs6iDf7qRaGv'},
+ {'id':'8jSWKxvgQyKbunFtD','collection':'creatureProperties'}
+ ],
+ order: 84,
+ total: 20,
+ tags:[],
+ damage: 3,
+ value: 17,
+ constitutionMod: 2,
+}
+
+describe('migrateProperty', function () {
+ it('Migrates actions reversibly', function () {
+ const action = {...exampleAction};
+ const newAction = migrateProperty({
+ collection: CreatureProperties,
+ prop: action
+ });
+ const reversedAction = migrateProperty({
+ collection: CreatureProperties,
+ prop: newAction,
+ reversed: true,
+ });
+ assert.deepEqual(action, exampleAction, 'action should not be bashed');
+ assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible');
+ });
+ it ('Migrates attributes as expected', function(){
+ const attribute = {...exampleAttribute};
+ const newAttribute = migrateProperty({
+ collection: CreatureProperties,
+ prop: attribute
+ });
+ assert.deepEqual(newAttribute, expectedMigratedAttribute,
+ 'Attribute should match the expected result');
+ });
+});
diff --git a/app/imports/migrations/server/index.js b/app/imports/migrations/server/index.js
new file mode 100644
index 00000000..54ca97d0
--- /dev/null
+++ b/app/imports/migrations/server/index.js
@@ -0,0 +1 @@
+import './dbv1/dbv1.js';
diff --git a/app/imports/migrations/server/migrateArchive.js b/app/imports/migrations/server/migrateArchive.js
new file mode 100644
index 00000000..42f04c26
--- /dev/null
+++ b/app/imports/migrations/server/migrateArchive.js
@@ -0,0 +1,28 @@
+import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
+
+/* eslint no-fallthrough: "off" -- Using switch fallthrough to run all
+migration steps after the current version of the file. */
+export default function migrateArchive(archive){
+ switch (archive.meta.schemaVersion){
+ // V1 of DiceCloud
+ case 'version1':
+ migrateLegacyArchive(archive);
+ // V2 of DiceCloud, Schema version 1
+ case 1:
+ cleanAt1(archive);
+ }
+}
+
+function migrateLegacyArchive(archive){
+ // TODO:
+ throw 'Not implemented';
+}
+
+function cleanAt1(archive){
+ archive.properties.map(prop => {
+ const schema = CreatureProperties.simpleSchema(prop);
+ const cleanProp = schema.clean(prop);
+ schema.validate(cleanProp);
+ return cleanProp;
+ });
+}
diff --git a/app/imports/migrations/server/transformFields.js b/app/imports/migrations/server/transformFields.js
new file mode 100644
index 00000000..60bd9094
--- /dev/null
+++ b/app/imports/migrations/server/transformFields.js
@@ -0,0 +1,117 @@
+import { get, set, unset, forEachRight, cloneDeep } from 'lodash';
+
+export default function transformFields(src, transformList, reversed = false){
+ if (!transformList) return src;
+ // don't bash the old document during the transforms
+ let doc = cloneDeep(src);
+ for(let originalTransform of transformList){
+ let transform;
+ // Swap to and from when reversing
+ if (reversed){
+ transform = {
+ to: originalTransform.from,
+ from: originalTransform.to,
+ up: originalTransform.down,
+ }
+ } else {
+ transform = {...originalTransform};
+ }
+ transformField(src, doc, transform, reversed);
+ }
+ return doc;
+}
+
+export function transformField(src, doc, transform, reversed){
+ if (transform.from?.includes('$.')){
+ transformArrayField(src, doc, transform, reversed);
+ } else {
+ transformSingleField(src, doc, transform);
+ }
+}
+
+function transformSingleField(src, doc, {from, to, up}){
+ // Get the value in the `from` path and delete it
+ let value = undefined;
+ if (from){
+ value = get(src, from);
+ unset(doc, from);
+ }
+
+ // apply the transform function
+ if (up){
+ value = up(value, src, doc);
+ }
+
+ // Store the value in the `to` path or unset it if undefined
+ if (to){
+ if (value === undefined){
+ unset(doc, to);
+ } else {
+ set(doc, to, value);
+ }
+ }
+}
+
+/**
+ * from: 'from.$.here', to: 'to.$.here'
+ * where from and to are an [array, of, objects] that each need to be modified
+ * documents at 'from.x.here' will map to 'to.x.here'
+ * Attempts to support 'from.$.here.$.nested'
+ * by mapping 'from.x.here.y.nest.z.deep' to 'to.y.nest.z.lessDeep'
+ * from depth must be >= to depth
+ */
+function transformArrayField(src, doc, {from, to, up}, reversed){
+ const fromSplit = from.split('.$');
+ const toSplit = to.split('.$');
+
+ if (toSplit.length > fromSplit.length){
+ throw 'Can\'t transform array fields where "to" is deeper than "from"'
+ }
+
+ // Stack based depth first traversal of arrays
+ const stack = [{
+ array: get(src, fromSplit[0]),
+ paths: fromSplit.slice(1),
+ currentPath: fromSplit[0],
+ indices: [],
+ }];
+ while(stack.length){
+ const state = stack.pop();
+ // Iterate forwads or backwads defpending on our migration direction
+ if (reversed){
+ forEachRight(state.array, iterate(stack, state, src, doc, toSplit, up));
+ } else {
+ state.array?.forEach(iterate(stack, state, src, doc, toSplit, up));
+ }
+ }
+}
+
+function iterate(stack, state, src, doc, toSplit, up){return function(key, index){
+ const currentPath = `${state.currentPath}[${index}]${state.paths[0]}`
+ if (state.paths.length == 1){
+ transformSingleField(src, doc, {
+ from: currentPath,
+ to: buildToPath(toSplit, [...state.indices, index]),
+ up
+ });
+ } else {
+ stack.push({
+ array: get(src, currentPath),
+ paths: state.paths.slice(1),
+ currentPath,
+ indices: [...state.indices, index],
+ });
+ }
+}}
+
+function buildToPath(toSplit, indices){
+ let toPath = '';
+ let offset = indices.length - toSplit.length + 1;
+ toSplit.forEach((path, i) => {
+ toPath += `${path}`;
+ if (i < toSplit.length - 1){
+ toPath += `[${indices[i + offset]}]`
+ }
+ });
+ return toPath;
+}
diff --git a/app/imports/migrations/server/transformFields.test.js b/app/imports/migrations/server/transformFields.test.js
new file mode 100644
index 00000000..d3c1dd6f
--- /dev/null
+++ b/app/imports/migrations/server/transformFields.test.js
@@ -0,0 +1,94 @@
+import transformFields from './transformFields.js';
+import { assert } from 'chai';
+
+const originalDoc = {
+ name: 'doc name',
+ description: 'a document to test transforms on',
+ nest: {
+ deeper: {
+ field: 'some nested field'
+ },
+ },
+ array: [{num: 1}, {num: 3}, {num: 5}],
+ nestArray: [
+ {array: [{item: 2},{item: 4},{item: 6}]},
+ {array: [{item: 8},{item: 10},{item: 12}]},
+ {array: [{item: 14},{item: 16},{item: 18}]},
+ ],
+};
+
+describe('transformFields', function () {
+
+ it('Takes a doc and transforms it according to single field rules', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'name', to: 'title'},
+ ];
+
+ assert.equal(doc.name, 'doc name', '.name is set');
+ assert.doesNotHaveAnyKeys(doc, ['title'], '.title doesn\'t exist');
+
+ doc = transformFields(doc, transformList);
+
+ assert.equal(doc.title, 'doc name', '.name -> .title');
+ assert.doesNotHaveAnyKeys(doc, ['name'], '.name deleted');
+ });
+
+ it('Takes a doc and transforms it with functions', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'name', to: 'name', up: name => name.toUpperCase()},
+ ];
+ assert.equal(doc.name, 'doc name', 'name in lowercase');
+ doc = transformFields(doc, transformList);
+ assert.equal(doc.name, 'DOC NAME', 'name in uppercase');
+ });
+
+ it('Creates objects on the fly', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'name', to:'newObj.name'},
+ ];
+ doc = transformFields(doc, transformList);
+ assert.deepEqual(doc.newObj, {name: 'doc name'});
+ });
+
+ it('Handles empty to and from fields', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {to: 'created', up: () => 'from thin air'},
+ {from: 'description'},
+ ];
+ doc = transformFields(doc, transformList);
+ assert.equal(doc.created, 'from thin air', 'created field success');
+ assert.doesNotHaveAnyKeys(doc, ['description'], '.description deleted');
+ });
+
+ it('Takes a nested field and transforms it into a different nested field', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'nest.deeper', to: 'different.deep'},
+ ];
+ doc = transformFields(doc, transformList);
+ assert.equal(doc.different.deep.field, 'some nested field', 'field moved correctly');
+ assert.doesNotHaveAnyKeys(doc.nest, ['deeper'], 'doc.nest.deeper deleted');
+ });
+
+ it('Transforms arrays', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'array.$.num', to: 'list.$.number'},
+ ];
+ doc = transformFields(doc, transformList);
+ assert.equal(doc.list[1].number, 3, 'array field moved correctly');
+ });
+
+ it('Transforms deep arrays', function () {
+ let doc = {...originalDoc};
+ const transformList = [
+ {from: 'nestArray.$.array.$.item', to: 'nestList.$.list.$.ting'},
+ ];
+ doc = transformFields(doc, transformList);
+ assert.equal(doc.nestList[2].list[1].ting, 16, 'nested array field moved correctly');
+ });
+});
diff --git a/app/imports/api/creature/v1Migration/migrateCharacter.js b/app/imports/migrations/v1Migration/migrateCharacter.js
similarity index 100%
rename from app/imports/api/creature/v1Migration/migrateCharacter.js
rename to app/imports/migrations/v1Migration/migrateCharacter.js
diff --git a/app/imports/parser/TextField.vue b/app/imports/parser/TextField.vue
new file mode 100644
index 00000000..9c9f0919
--- /dev/null
+++ b/app/imports/parser/TextField.vue
@@ -0,0 +1,38 @@
+
+
+
|
+
This tabletop was not found
-Either it does not exist, or you do not have permission to view it
-This tabletop was not found
+Either it does not exist, or you do not have permission to view it
+{{ error.message }}
diff --git a/app/imports/ui/properties/forms/shared/ComputedField.vue b/app/imports/ui/properties/forms/shared/ComputedField.vue
new file mode 100644
index 00000000..d6b237e4
--- /dev/null
+++ b/app/imports/ui/properties/forms/shared/ComputedField.vue
@@ -0,0 +1,45 @@
+
+ {{ model.rollBonus }}
- to hit
- {{ model.uses }}
-
- uses
-
-
-
- {{ reset }}
-
- {{ model.uses.calculation }}
+
+
- {{ model.variableName }}
-
- {{ levelAndSchool }} -
+
-