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 @@ + + + + + diff --git a/app/imports/parser/compute.js b/app/imports/parser/compute.js deleted file mode 100644 index 5386218b..00000000 --- a/app/imports/parser/compute.js +++ /dev/null @@ -1 +0,0 @@ -// Takes a parse tree and computes it down as far as possible into a real number diff --git a/app/imports/parser/functions.js b/app/imports/parser/functions.js index b1ec5a91..a98dded5 100644 --- a/app/imports/parser/functions.js +++ b/app/imports/parser/functions.js @@ -1,5 +1,3 @@ -import ArrayNode from '/imports/parser/parseTree/ArrayNode.js'; - export default { 'abs': { comment: 'Returns the absolute value of a number', @@ -101,12 +99,12 @@ export default { {input: 'tableLookup([100, 300, 900, 1200], 900)', result: '3'}, {input: 'tableLookup([100, 300], 594)', result: '2'}, ], - arguments: [ArrayNode, 'number'], + arguments: ['array', 'number'], resultType: 'number', fn: function tableLookup(arrayNode, number){ for(let i in arrayNode.values){ let node = arrayNode.values[i]; - if (node.value > number) return i; + if (node.value > number) return +i; } return arrayNode.values.length; } diff --git a/app/imports/parser/grammar.js b/app/imports/parser/grammar.js index 8e29fb16..064fc2f2 100644 --- a/app/imports/parser/grammar.js +++ b/app/imports/parser/grammar.js @@ -1,19 +1,8 @@ -// Generated automatically by nearley, version 2.16.0 +// Generated automatically by nearley, version 2.20.1 // http://github.com/Hardmath123/nearley function id(x) { return x[0]; } - import AccessorNode from '/imports/parser/parseTree/AccessorNode.js'; - import ArrayNode from '/imports/parser/parseTree/ArrayNode.js'; - import CallNode from '/imports/parser/parseTree/CallNode.js'; - import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - import IfNode from '/imports/parser/parseTree/IfNode.js'; - import IndexNode from '/imports/parser/parseTree/IndexNode.js'; - import OperatorNode from '/imports/parser/parseTree/OperatorNode.js'; - import ParenthesisNode from '/imports/parser/parseTree/ParenthesisNode.js'; - import RollNode from '/imports/parser/parseTree/RollNode.js'; - import SymbolNode from '/imports/parser/parseTree/SymbolNode.js'; - import UnaryOperatorNode from '/imports/parser/parseTree/UnaryOperatorNode.js'; - import NotOperatorNode from '/imports/parser/parseTree/NotOperatorNode.js'; + import node from './parseTree/_index.js'; import moo from 'moo'; @@ -53,7 +42,7 @@ function id(x) { return x[0]; } function nuller() { return null; } function operator([left, _1, operator, _2, right], fn){ - return new OperatorNode({ + return node.operator.create({ left, right, operator: operator.value, @@ -65,7 +54,7 @@ let ParserRules = [ {"name": "spacedExpression", "symbols": ["_", "expression", "_"], "postprocess": d => d[1]}, {"name": "expression", "symbols": ["ifStatement"], "postprocess": id}, {"name": "ifStatement", "symbols": ["orExpression", "_", (lexer.has("ifOperator") ? {type: "ifOperator"} : ifOperator), "_", "orExpression", "_", (lexer.has("elseOperator") ? {type: "elseOperator"} : elseOperator), "_", "ifStatement"], "postprocess": - d => new IfNode({condition: d[0], consequent: d[4], alternative: d[8]}) + d => node.if.create({condition: d[0], consequent: d[4], alternative: d[8]}) }, {"name": "ifStatement", "symbols": ["orExpression"], "postprocess": id}, {"name": "orExpression", "symbols": ["orExpression", "_", (lexer.has("orOperator") ? {type: "orOperator"} : orOperator), "_", "andExpression"], "postprocess": d => operator(d, 'or')}, @@ -82,18 +71,18 @@ let ParserRules = [ {"name": "remainderExpression", "symbols": ["multiplicativeExpression"], "postprocess": id}, {"name": "multiplicativeExpression", "symbols": ["multiplicativeExpression", "_", (lexer.has("multiplicativeOperator") ? {type: "multiplicativeOperator"} : multiplicativeOperator), "_", "rollExpression"], "postprocess": d => operator(d, 'multiply')}, {"name": "multiplicativeExpression", "symbols": ["rollExpression"], "postprocess": id}, - {"name": "rollExpression", "symbols": ["rollExpression", "_", (lexer.has("diceOperator") ? {type: "diceOperator"} : diceOperator), "_", "exponentExpression"], "postprocess": d => new RollNode({left: d[0], right: d[4]})}, + {"name": "rollExpression", "symbols": ["rollExpression", "_", (lexer.has("diceOperator") ? {type: "diceOperator"} : diceOperator), "_", "exponentExpression"], "postprocess": d => node.roll.create({left: d[0], right: d[4]})}, {"name": "rollExpression", "symbols": ["singleRollExpression"], "postprocess": id}, - {"name": "singleRollExpression", "symbols": [{"literal":"d"}, "_", "singleRollExpression"], "postprocess": d => new RollNode({left: new ConstantNode({value: 1, type: 'number'}), right: d[2]})}, + {"name": "singleRollExpression", "symbols": [{"literal":"d"}, "_", "singleRollExpression"], "postprocess": d => node.roll.create({left: node.constant.create({value: 1}), right: d[2]})}, {"name": "singleRollExpression", "symbols": ["exponentExpression"], "postprocess": id}, {"name": "exponentExpression", "symbols": ["callExpression", "_", (lexer.has("exponentOperator") ? {type: "exponentOperator"} : exponentOperator), "_", "exponentExpression"], "postprocess": d => operator(d, 'exponent')}, {"name": "exponentExpression", "symbols": ["unaryExpression"], "postprocess": id}, - {"name": "unaryExpression", "symbols": [(lexer.has("additiveOperator") ? {type: "additiveOperator"} : additiveOperator), "_", "unaryExpression"], "postprocess": d => new UnaryOperatorNode({operator: d[0].value, right: d[2]})}, + {"name": "unaryExpression", "symbols": [(lexer.has("additiveOperator") ? {type: "additiveOperator"} : additiveOperator), "_", "unaryExpression"], "postprocess": d => node.unaryOperator.create({operator: d[0].value, right: d[2]})}, {"name": "unaryExpression", "symbols": ["notExpression"], "postprocess": id}, - {"name": "notExpression", "symbols": [(lexer.has("notOperator") ? {type: "notOperator"} : notOperator), "_", "notExpression"], "postprocess": d => new NotOperatorNode({right: d[2]})}, + {"name": "notExpression", "symbols": [(lexer.has("notOperator") ? {type: "notOperator"} : notOperator), "_", "notExpression"], "postprocess": d => node.notOperator.create({right: d[2]})}, {"name": "notExpression", "symbols": ["callExpression"], "postprocess": id}, {"name": "callExpression", "symbols": ["name", "_", "arguments"], "postprocess": - d => new CallNode ({functionName: d[0].name, args: d[2]}) + d => node.call.create({functionName: d[0].name, args: d[2]}) }, {"name": "callExpression", "symbols": ["indexExpression"], "postprocess": id}, {"name": "arguments$ebnf$1$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, @@ -105,7 +94,7 @@ let ParserRules = [ {"name": "arguments", "symbols": [{"literal":"("}, "_", "arguments$ebnf$1", "arguments$ebnf$2", "_", {"literal":")"}], "postprocess": d => [d[2], ...d[3]] }, - {"name": "indexExpression", "symbols": ["arrayExpression", {"literal":"["}, "_", "expression", "_", {"literal":"]"}], "postprocess": d => new IndexNode ({array: d[0], index: d[3]})}, + {"name": "indexExpression", "symbols": ["arrayExpression", {"literal":"["}, "_", "expression", "_", {"literal":"]"}], "postprocess": d => node.index.create({array: d[0], index: d[3]})}, {"name": "indexExpression", "symbols": ["arrayExpression"], "postprocess": id}, {"name": "arrayExpression$ebnf$1$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, {"name": "arrayExpression$ebnf$1", "symbols": ["arrayExpression$ebnf$1$subexpression$1"], "postprocess": id}, @@ -114,10 +103,10 @@ let ParserRules = [ {"name": "arrayExpression$ebnf$2$subexpression$1", "symbols": ["_", (lexer.has("separator") ? {type: "separator"} : separator), "_", "expression"], "postprocess": d => d[3]}, {"name": "arrayExpression$ebnf$2", "symbols": ["arrayExpression$ebnf$2", "arrayExpression$ebnf$2$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, {"name": "arrayExpression", "symbols": [{"literal":"["}, "_", "arrayExpression$ebnf$1", "arrayExpression$ebnf$2", "_", {"literal":"]"}], "postprocess": - d => new ArrayNode({values: d[2] ? [d[2], ...d[3]] : []}) + d => node.array.create({values: d[2] ? [d[2], ...d[3]] : []}) }, {"name": "arrayExpression", "symbols": ["parenthesizedExpression"], "postprocess": id}, - {"name": "parenthesizedExpression", "symbols": [{"literal":"("}, "_", "expression", "_", {"literal":")"}], "postprocess": d => new ParenthesisNode({content: d[2]})}, + {"name": "parenthesizedExpression", "symbols": [{"literal":"("}, "_", "expression", "_", {"literal":")"}], "postprocess": d => node.parenthesis.create({content: d[2]})}, {"name": "parenthesizedExpression", "symbols": ["accessorExpression"], "postprocess": id}, {"name": "accessorExpression$subexpression$1", "symbols": [(lexer.has("name") ? {type: "name"} : name)], "postprocess": d => d[0].value}, {"name": "accessorExpression$ebnf$1$subexpression$1", "symbols": [{"literal":"."}, (lexer.has("name") ? {type: "name"} : name)], "postprocess": d => d[1].value}, @@ -125,18 +114,18 @@ let ParserRules = [ {"name": "accessorExpression$ebnf$1$subexpression$2", "symbols": [{"literal":"."}, (lexer.has("name") ? {type: "name"} : name)], "postprocess": d => d[1].value}, {"name": "accessorExpression$ebnf$1", "symbols": ["accessorExpression$ebnf$1", "accessorExpression$ebnf$1$subexpression$2"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, {"name": "accessorExpression", "symbols": ["accessorExpression$subexpression$1", "accessorExpression$ebnf$1"], "postprocess": - d=> new AccessorNode({name: d[0], path: d[1]}) + d=> node.accessor.create({name: d[0], path: d[1]}) }, {"name": "accessorExpression", "symbols": ["valueExpression"], "postprocess": id}, {"name": "valueExpression", "symbols": ["name"], "postprocess": id}, {"name": "valueExpression", "symbols": ["number"], "postprocess": id}, {"name": "valueExpression", "symbols": ["string"], "postprocess": id}, {"name": "valueExpression", "symbols": ["boolean"], "postprocess": id}, - {"name": "number", "symbols": [(lexer.has("number") ? {type: "number"} : number)], "postprocess": d => new ConstantNode({value: +d[0].value, type: 'number'})}, - {"name": "name", "symbols": [(lexer.has("name") ? {type: "name"} : name)], "postprocess": d => new SymbolNode({name: d[0].value})}, - {"name": "string", "symbols": [(lexer.has("string") ? {type: "string"} : string)], "postprocess": d => new ConstantNode({value: d[0].value, type: 'string'})}, - {"name": "boolean", "symbols": [{"literal":"true"}], "postprocess": d => new ConstantNode({value: true, type: 'boolean'})}, - {"name": "boolean", "symbols": [{"literal":"false"}], "postprocess": d => new ConstantNode({value: false, type: 'boolean'})}, + {"name": "number", "symbols": [(lexer.has("number") ? {type: "number"} : number)], "postprocess": d => node.constant.create({value: +d[0].value})}, + {"name": "name", "symbols": [(lexer.has("name") ? {type: "name"} : name)], "postprocess": d => node.symbol.create({name: d[0].value})}, + {"name": "string", "symbols": [(lexer.has("string") ? {type: "string"} : string)], "postprocess": d => node.constant.create({value: d[0].value})}, + {"name": "boolean", "symbols": [{"literal":"true"}], "postprocess": d => node.constant.create({value: true})}, + {"name": "boolean", "symbols": [{"literal":"false"}], "postprocess": d => node.constant.create({value: false})}, {"name": "_", "symbols": []}, {"name": "_", "symbols": [(lexer.has("space") ? {type: "space"} : space)], "postprocess": nuller} ]; diff --git a/app/imports/parser/grammar.ne b/app/imports/parser/grammar.ne index cd278bab..82681659 100644 --- a/app/imports/parser/grammar.ne +++ b/app/imports/parser/grammar.ne @@ -1,17 +1,6 @@ @preprocessor esmodule @{% - import AccessorNode from '/imports/parser/parseTree/AccessorNode.js'; - import ArrayNode from '/imports/parser/parseTree/ArrayNode.js'; - import CallNode from '/imports/parser/parseTree/CallNode.js'; - import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - import IfNode from '/imports/parser/parseTree/IfNode.js'; - import IndexNode from '/imports/parser/parseTree/IndexNode.js'; - import OperatorNode from '/imports/parser/parseTree/OperatorNode.js'; - import ParenthesisNode from '/imports/parser/parseTree/ParenthesisNode.js'; - import RollNode from '/imports/parser/parseTree/RollNode.js'; - import SymbolNode from '/imports/parser/parseTree/SymbolNode.js'; - import UnaryOperatorNode from '/imports/parser/parseTree/UnaryOperatorNode.js'; - import NotOperatorNode from '/imports/parser/parseTree/NotOperatorNode.js'; + import node from './parseTree/_index.js'; import moo from 'moo'; @@ -51,7 +40,7 @@ function nuller() { return null; } function operator([left, _1, operator, _2, right], fn){ - return new OperatorNode({ + return node.operator.create({ left, right, operator: operator.value, @@ -71,7 +60,7 @@ expression -> ifStatement -> orExpression _ %ifOperator _ orExpression _ %elseOperator _ ifStatement {% - d => new IfNode({condition: d[0], consequent: d[4], alternative: d[8]}) + d => node.if.create({condition: d[0], consequent: d[4], alternative: d[8]}) %} | orExpression {% id %} @@ -104,11 +93,11 @@ multiplicativeExpression -> | rollExpression {% id %} rollExpression -> - rollExpression _ %diceOperator _ exponentExpression {% d => new RollNode({left: d[0], right: d[4]}) %} + rollExpression _ %diceOperator _ exponentExpression {% d => node.roll.create({left: d[0], right: d[4]}) %} | singleRollExpression {% id %} singleRollExpression -> - "d" _ singleRollExpression {% d => new RollNode({left: new ConstantNode({value: 1, type: 'number'}), right: d[2]}) %} + "d" _ singleRollExpression {% d => node.roll.create({left: node.constant.create({value: 1}), right: d[2]}) %} | exponentExpression {% id %} exponentExpression -> @@ -116,16 +105,16 @@ exponentExpression -> | unaryExpression {% id %} unaryExpression -> - %additiveOperator _ unaryExpression {% d => new UnaryOperatorNode({operator: d[0].value, right: d[2]})%} + %additiveOperator _ unaryExpression {% d => node.unaryOperator.create({operator: d[0].value, right: d[2]})%} | notExpression {% id %} notExpression -> - %notOperator _ notExpression {% d => new NotOperatorNode({right: d[2]})%} + %notOperator _ notExpression {% d => node.notOperator.create({right: d[2]})%} | callExpression {% id %} callExpression -> name _ arguments {% - d => new CallNode ({functionName: d[0].name, args: d[2]}) + d => node.call.create({functionName: d[0].name, args: d[2]}) %} | indexExpression {% id %} @@ -135,22 +124,22 @@ arguments -> %} indexExpression -> - arrayExpression "[" _ expression _ "]" {% d => new IndexNode ({array: d[0], index: d[3]}) %} + arrayExpression "[" _ expression _ "]" {% d => node.index.create({array: d[0], index: d[3]}) %} | arrayExpression {% id %} arrayExpression -> "[" _ (expression {% d => d[0] %}):? ( _ %separator _ expression {% d => d[3] %} ):* _ "]" {% - d => new ArrayNode({values: d[2] ? [d[2], ...d[3]] : []}) + d => node.array.create({values: d[2] ? [d[2], ...d[3]] : []}) %} | parenthesizedExpression {% id %} parenthesizedExpression -> - "(" _ expression _ ")" {% d => new ParenthesisNode({content: d[2]}) %} + "(" _ expression _ ")" {% d => node.parenthesis.create({content: d[2]}) %} | accessorExpression {% id %} accessorExpression -> (%name {% d => d[0].value %}) ( "." %name {% d => d[1].value %} ):+ {% - d=> new AccessorNode({name: d[0], path: d[1]}) + d=> node.accessor.create({name: d[0], path: d[1]}) %} | valueExpression {% id %} @@ -162,17 +151,17 @@ valueExpression -> # A number or a function of a number number -> - %number {% d => new ConstantNode({value: +d[0].value, type: 'number'}) %} + %number {% d => node.constant.create({value: +d[0].value}) %} name -> - %name {% d => new SymbolNode({name: d[0].value}) %} + %name {% d => node.symbol.create({name: d[0].value}) %} string -> - %string {% d => new ConstantNode({value: d[0].value, type: 'string'}) %} + %string {% d => node.constant.create({value: d[0].value}) %} boolean -> - "true" {% d => new ConstantNode({value: true, type: 'boolean'}) %} -| "false" {% d => new ConstantNode({value: false, type: 'boolean'}) %} + "true" {% d => node.constant.create({value: true}) %} +| "false" {% d => node.constant.create({value: false}) %} _ -> null diff --git a/app/imports/parser/parseTree/AccessorNode.js b/app/imports/parser/parseTree/AccessorNode.js deleted file mode 100644 index ad2d7cad..00000000 --- a/app/imports/parser/parseTree/AccessorNode.js +++ /dev/null @@ -1,54 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class AccessorNode extends ParseNode { - constructor({name, path}) { - super(...arguments); - this.name = name; - this.path = path; - } - compile(scope, context){ - let value = scope && scope[this.name]; - // For objects, get their value - this.path.forEach(name => { - if (value === undefined) return; - value = value[name]; - }); - let type = typeof value; - if (type === 'string' || type === 'number' || type === 'boolean'){ - return new ConstantNode({value, type}); - } else if (type === 'undefined'){ - return new AccessorNode({ - name: this.name, - path: this.path, - }); - } else { - if (context) context.storeError({ - type: 'error', - message: `${this.name} returned an unexpected type` - }); - return new AccessorNode({ - name: this.name, - path: this.path, - }); - } - } - reduce(scope, context){ - let result = this.compile(scope, context); - if (result instanceof AccessorNode){ - if (context) context.storeError({ - type: 'info', - message: `${result.toString()} not found, set to 0` - }); - return new ConstantNode({ - type: 'number', - value: 0, - }); - } else { - return result; - } - } - toString(){ - return `${this.name}.${this.path.join('.')}`; - } -} diff --git a/app/imports/parser/parseTree/ArrayNode.js b/app/imports/parser/parseTree/ArrayNode.js deleted file mode 100644 index 2f31fa69..00000000 --- a/app/imports/parser/parseTree/ArrayNode.js +++ /dev/null @@ -1,39 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class ArrayNode extends ParseNode { - constructor({values}) { - super(...arguments); - this.values = values; - } - static fromConstantArray(array){ - let values = array.map( value => { - let type = typeof value; - if ( - type === 'string' || - type === 'number' || - type === 'boolean' || - type === 'undefined' - ){ - return new ConstantNode({value, type}); - } else { - throw `Unexpected type in constant array: ${type}` - } - }); - return new ArrayNode({values}); - } - resolve(fn, scope, context){ - let values = this.values.map(node => node[fn](scope, context)); - return new ArrayNode({values}); - } - toString(){ - return `[${this.values.map(node => node.toString()).join(', ')}]`; - } - traverse(fn){ - fn(this); - this.values.forEach(value => value.traverse(fn)); - } - replaceChildren(fn){ - this.values = this.values.map(node => node.replaceNodes(fn)); - } -} diff --git a/app/imports/parser/parseTree/CallNode.js b/app/imports/parser/parseTree/CallNode.js deleted file mode 100644 index 59c9e9ff..00000000 --- a/app/imports/parser/parseTree/CallNode.js +++ /dev/null @@ -1,126 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ErrorNode from '/imports/parser/parseTree/ErrorNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; -import functions from '/imports/parser/functions.js'; - -export default class CallNode extends ParseNode { - constructor({functionName, args}) { - super(...arguments); - this.functionName = functionName; - this.args = args; - } - resolve(fn, scope, context){ - let func = functions[this.functionName]; - // Check that the function exists - if (!func) return new ErrorNode({ - node: this, - error: `${this.functionName} is not a supported function`, - context, - }); - - // Resolve the arguments - let resolvedArgs = this.args.map(node => node[fn](scope, context)); - // Check that the arguments match what is expected - let checkFailed = this.checkArugments({ - fn, - context, - resolvedArgs, - argumentsExpected: func.arguments - }); - - if (checkFailed){ - if (fn === 'reduce'){ - return new ErrorNode({ - node: this, - error: `Invalid arguments to ${this.functionName} function`, - }); - } else { - return new CallNode({ - functionName: this.functionName, - args: resolvedArgs, - }); - } - } - - // Map contant nodes to constants before attempting to run the function - let mappedArgs = resolvedArgs.map(node => { - if (node instanceof ConstantNode){ - return node.value; - } else { - return node; - } - }); - - try { - // Run the function - let value = func.fn.apply(null, mappedArgs); - - let type = typeof value; - if (type === 'number' || type === 'string' || type === 'boolean'){ - // Convert constant results into constant nodes - return new ConstantNode({ value, type }); - } else { - return value; - } - } catch (error) { - return new ErrorNode({ - node: this, - error: error.message || error, - context, - }); - } - } - toString(){ - return `${this.functionName}(${this.args.map(node => node.toString()).join(', ')})`; - } - traverse(fn){ - fn(this); - this.args.forEach(arg => arg.traverse(fn)); - } - replaceChildren(fn){ - this.args = this.args.map(arg => arg.replaceNodes(fn)); - } - checkArugments({fn, context, argumentsExpected, resolvedArgs}){ - // Check that the number of arguments matches the number expected - if ( - !argumentsExpected.anyLength && - argumentsExpected.length !== resolvedArgs.length - ){ - context.storeError({ - type: 'error', - message: 'Incorrect number of arguments ' + - `to ${this.functionName} function, ` + - `expected ${argumentsExpected.length} got ${resolvedArgs.length}` - }); - return true; - } - - let failed = false; - // Check that each argument is of the correct type - resolvedArgs.forEach((node, index) => { - let type; - if (argumentsExpected.anyLength){ - type = argumentsExpected[0]; - } else { - type = argumentsExpected[index]; - } - if (typeof type === 'string'){ - // Type being a string means a constant node with matching type - if (node.type !== type) failed = true; - } else { - // Otherwise check that the node is an instance of the given type - if (!(node instanceof type)) failed = true; - } - if (failed && fn === 'reduce'){ - let typeName = typeof type === 'string' ? type : type.constructor.name; - let nodeName = node.type || node.constructor.name - context.storeError({ - type: 'error', - message: `Incorrect arguments to ${this.functionName} function` + - `expected ${typeName} got ${nodeName}` - }); - } - }); - return failed; - } -} diff --git a/app/imports/parser/parseTree/ConstantNode.js b/app/imports/parser/parseTree/ConstantNode.js deleted file mode 100644 index 708bfaba..00000000 --- a/app/imports/parser/parseTree/ConstantNode.js +++ /dev/null @@ -1,22 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; - -export default class ConstantNode extends ParseNode { - constructor({value, type}){ - super(...arguments); - // string, number, boolean, uncompiledNode - this.type = type; - this.value = value; - } - compile(){ - return this; - } - toString(){ - return `${this.value}`; - } - get isNumber(){ - return this.type === 'number'; - } - get isInteger(){ - return this.type === 'number' && Number.isInteger(this.value); - } -} diff --git a/app/imports/parser/parseTree/ErrorNode.js b/app/imports/parser/parseTree/ErrorNode.js deleted file mode 100644 index ff9565ee..00000000 --- a/app/imports/parser/parseTree/ErrorNode.js +++ /dev/null @@ -1,21 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; - -export default class ErrorNode extends ParseNode { - constructor({node, error, context}) { - super(...arguments); - this.node = node; - this.error = error; - if (context){ - context.storeError({ - type: 'error', - message: error, - }); - } - } - compile(){ - return this; - } - toString(){ - return this.error.toString(); - } -} diff --git a/app/imports/parser/parseTree/IfNode.js b/app/imports/parser/parseTree/IfNode.js deleted file mode 100644 index a94f21c2..00000000 --- a/app/imports/parser/parseTree/IfNode.js +++ /dev/null @@ -1,42 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class IfNode extends ParseNode { - constructor({condition, consequent, alternative}){ - super(...arguments); - this.condition = condition; - this.consequent = consequent; - this.alternative = alternative; - } - toString(){ - let {condition, consequent, alternative} = this; - return `${condition.toString()} ? ${consequent.toString()} : ${alternative.toString()}` - } - resolve(fn, scope, context){ - let condition = this.condition[fn](scope, context); - if (condition instanceof ConstantNode){ - if (condition.value){ - return this.consequent[fn](scope, context); - } else { - return this.alternative[fn](scope, context); - } - } else { - return new IfNode({ - condition: condition, - consequent: this.consequent, - alternative: this.alternative, - }); - } - } - traverse(fn){ - fn(this); - this.condition.traverse(fn); - this.consequent.traverse(fn); - this.alternative.traverse(fn); - } - replaceChildren(fn){ - this.condition = this.condition.replaceNodes(fn); - this.consequent = this.consequent.replaceNodes(fn); - this.alternative = this.alternative.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/IndexNode.js b/app/imports/parser/parseTree/IndexNode.js deleted file mode 100644 index fdd6c6c6..00000000 --- a/app/imports/parser/parseTree/IndexNode.js +++ /dev/null @@ -1,64 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ArrayNode from '/imports/parser/parseTree/ArrayNode.js'; -import ErrorNode from '/imports/parser/parseTree/ErrorNode.js'; - -export default class IndexNode extends ParseNode { - constructor({array, index}) { - super(...arguments); - this.array = array; - this.index = index; - } - resolve(fn, scope, context){ - let index = this.index[fn](scope, context); - let array = this.array[fn](scope, context); - - if (index.isInteger && array instanceof ArrayNode){ - if (index.value < 1 || index.value > array.values.length){ - if (context){ - context.storeError({ - type: 'warning', - message: `Index of ${index.value} is out of range for an array` + - ` of length ${array.values.length}`, - }); - } - } - let selection = array.values[index.value - 1]; - if (selection){ - let result = selection[fn](scope, context); - return result; - } - } else if (fn === 'reduce'){ - if (!(array instanceof ArrayNode)){ - return new ErrorNode({ - node: this, - error: 'Can not get the index of a non-array node: ' + - this.array.toString() + ' = ' + array.toString(), - context, - }); - } else if (!index.isInteger){ - return new ErrorNode({ - node: this, - error: array.toString() + ' is not an integer index of the array', - context, - }); - } - } - return new IndexNode({ - index, - array, - previousNodes: [this], - }); - } - toString(){ - return `${this.array.toString()}[${this.index.toString()}]`; - } - traverse(fn){ - fn(this); - this.array.traverse(fn); - this.index.traverse(fn); - } - replaceChildren(fn){ - this.array = this.array.replaceNodes(fn); - this.index = this.index.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/NotOperatorNode.js b/app/imports/parser/parseTree/NotOperatorNode.js deleted file mode 100644 index 47aca511..00000000 --- a/app/imports/parser/parseTree/NotOperatorNode.js +++ /dev/null @@ -1,34 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class NotOperatorNode extends ParseNode { - constructor({right}) { - super(...arguments); - this.right = right; - } - resolve(fn, scope, context){ - let rightNode = this.right[fn](scope, context); - if (!(rightNode instanceof ConstantNode)){ - return new NotOperatorNode({ - right: rightNode, - }); - } - let right = rightNode.value; - let result = !right; - return new ConstantNode({ - value: result, - type: typeof result, - }); - } - toString(){ - let {right} = this; - return `!${right.toString()}`; - } - traverse(fn){ - fn(this); - this.right.traverse(fn); - } - replaceChildren(fn){ - this.right = this.right.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/OperatorNode.js b/app/imports/parser/parseTree/OperatorNode.js deleted file mode 100644 index 56633993..00000000 --- a/app/imports/parser/parseTree/OperatorNode.js +++ /dev/null @@ -1,71 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class OperatorNode extends ParseNode { - constructor({left, right, operator, fn}) { - super(...arguments); - this.left = left; - this.right = right; - this.fn = fn; - this.operator = operator; - } - resolve(fn, scope, context){ - let leftNode = this.left[fn](scope, context); - let rightNode = this.right[fn](scope, context); - let left, right; - if (!(leftNode instanceof ConstantNode) || !(rightNode instanceof ConstantNode)){ - return new OperatorNode({ - left: leftNode, - right: rightNode, - operator: this.operator, - fn: this.fn, - }); - } else { - left = leftNode.value; - right = rightNode.value; - } - let result; - switch(this.operator){ - case '+': result = left + right; break; - case '-': result = left - right; break; - case '*': result = left * right; break; - case '/': result = left / right; break; - case '^': result = Math.pow(left, right); break; - case '%': result = left % right; break; - case '&': - case '&&': result = left && right; break; - case '|': - case '||': result = left || right; break; - case '=': - case '==': result = left == right; break; - case '===': result = left === right; break; - case '!=': result = left != right; break; - case '!==': result = left !== right; break; - case '>': result = left > right; break; - case '<': result = left < right; break; - case '>=': result = left >= right; break; - case '<=': result = left <= right; break; - } - return new ConstantNode({ - value: result, - type: typeof result, - }); - } - toString(){ - let {left, right, operator} = this; - // special case of adding a negative number - if (operator === '+' && right.isNumber && right.value < 0){ - return `${left.toString()} - ${-right.value}` - } - return `${left.toString()} ${operator} ${right.toString()}`; - } - traverse(fn){ - fn(this); - this.left.traverse(fn); - this.right.traverse(fn); - } - replaceChildren(fn){ - this.left = this.left.replaceNodes(fn); - this.right = this.right.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/ParenthesisNode.js b/app/imports/parser/parseTree/ParenthesisNode.js deleted file mode 100644 index db6b5e14..00000000 --- a/app/imports/parser/parseTree/ParenthesisNode.js +++ /dev/null @@ -1,30 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; - -export default class ParenthesisNode extends ParseNode { - constructor({content}) { - super(...arguments); - this.content = content; - } - resolve(fn, scope, context){ - let content = this.content[fn](scope, context); - if ( - fn === 'reduce' || - content.constructor.name === 'ConstantNode' || - content.constructor.name === 'ErrorNode' - ){ - return content; - } else { - return new ParenthesisNode({content}); - } - } - toString(){ - return `(${this.content.toString()})`; - } - traverse(fn){ - fn(this); - this.content.traverse(fn); - } - replaceChildren(fn){ - this.content = this.content.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/ParseNode.js b/app/imports/parser/parseTree/ParseNode.js deleted file mode 100644 index 740c7b14..00000000 --- a/app/imports/parser/parseTree/ParseNode.js +++ /dev/null @@ -1,43 +0,0 @@ -export default class ParseNode { - toString(){ - throw new Meteor.Error('toString not implemented on ' + this.constructor.name); - } - compile(scope, context){ - // Returns a ParseNode, a ConstantNode if possible - if(this.resolve) { - return this.resolve('compile', scope, context); - } else { - throw new Meteor.Error('Compile not implemented on ' + this.constructor.name); - } - } - // Compile, but turn rolls into arrays - roll(scope, context){ - if (this.resolve){ - return this.resolve('roll', scope, context); - } else { - return this.compile(scope, context); - } - } - // Compile, turn rolls into arrays, and reduce those arrays into single values - reduce(scope, context){ - if (this.resolve){ - return this.resolve('reduce', scope, context); - } else { - return this.roll(scope, context); - } - } - // If traverse isn't implemented, just apply it to the current node - traverse(fn){ - fn(this); - } - // replace nodes, only replace nodes if fn returns a value - replaceNodes(fn){ - let newNode = fn(this); - if (newNode) { - return newNode; - } else { - if (this.replaceChildren) this.replaceChildren(fn) - return this; - } - } -} diff --git a/app/imports/parser/parseTree/RollArrayNode.js b/app/imports/parser/parseTree/RollArrayNode.js deleted file mode 100644 index 978c20d6..00000000 --- a/app/imports/parser/parseTree/RollArrayNode.js +++ /dev/null @@ -1,22 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class RollArrayNode extends ParseNode { - constructor({values}) { - super(...arguments); - this.values = values; - } - compile(){ - return this; - } - toString(){ - return `[${this.values.join(', ')}]`; - } - reduce(){ - let total = this.values.reduce((a, b) => a + b); - return new ConstantNode({ - value: total, - type: 'number', - }); - } -} diff --git a/app/imports/parser/parseTree/RollNode.js b/app/imports/parser/parseTree/RollNode.js deleted file mode 100644 index 5e1d178f..00000000 --- a/app/imports/parser/parseTree/RollNode.js +++ /dev/null @@ -1,70 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import RollArrayNode from '/imports/parser/parseTree/RollArrayNode.js'; -import ErrorNode from '/imports/parser/parseTree/ErrorNode.js'; -import roll from '/imports/parser/roll.js'; -import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; - -export default class RollNode extends ParseNode { - constructor({left, right}) { - super(...arguments); - this.left = left; - this.right = right; - } - compile(scope, context){ - let left = this.left.compile(scope, context); - let right = this.right.compile(scope, context); - return new RollNode({left, right, previousNodes: [this]}); - } - toString(){ - if ( - this.left.isNumberNode && this.left.value === 1 - ){ - return `d${this.right.toString()}`; - } else { - return `${this.left.toString()}d${this.right.toString()}`; - } - } - roll(scope, context){ - let left = this.left.reduce(scope, context); - let right = this.right.reduce(scope, context); - if (!left.isInteger){ - return new ErrorNode({ - node: this, - error: 'Number of dice is not an integer', - }); - } - if (!right.isInteger){ - return new ErrorNode({ - node: this, - error: 'Dice size is not an integer', - }); - } - let number = left.value; - if (context.doubleRolls){ - number *= 2; - } - if (number > STORAGE_LIMITS.diceRollValuesCount) return new ErrorNode({ - node: this, - error: `Can't roll more than ${STORAGE_LIMITS.diceRollValuesCount} dice at once`, - context, - }); - let diceSize = right.value; - let values = roll(number, diceSize); - if (context){ - context.storeRoll({number, diceSize, values}); - } - return new RollArrayNode({values}); - } - reduce(scope, context){ - return this.roll(scope, context).reduce(scope, context); - } - traverse(fn){ - fn(this); - this.left.traverse(fn); - this.right.traverse(fn); - } - replaceChildren(fn){ - this.left = this.left.replaceNodes(fn); - this.right = this.right.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/SymbolNode.js b/app/imports/parser/parseTree/SymbolNode.js deleted file mode 100644 index b65a5523..00000000 --- a/app/imports/parser/parseTree/SymbolNode.js +++ /dev/null @@ -1,45 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class SymbolNode extends ParseNode { - constructor({name}){ - super(...arguments); - this.name = name; - } - toString(){ - return `${this.name}` - } - compile(scope){ - let value = scope && scope[this.name]; - let type = typeof value; - // For objects, get their value - if (type === 'object'){ - value = value.value; - type = typeof value; - } - if (type === 'string' || type === 'number' || type === 'boolean'){ - return new ConstantNode({value, type}); - } else if (type === 'undefined'){ - return new SymbolNode({ - name: this.name, - }); - } else { - throw new Meteor.Error(`Unexpected case: ${this.name} resolved to ${value}`); - } - } - reduce(scope, context){ - let result = this.compile(scope); - if (result instanceof SymbolNode){ - if (context) context.storeError({ - type: 'info', - message: `${result.toString()} not found, set to 0` - }); - return new ConstantNode({ - type: 'number', - value: 0, - }); - } else { - return result; - } - } -} diff --git a/app/imports/parser/parseTree/UnaryOperatorNode.js b/app/imports/parser/parseTree/UnaryOperatorNode.js deleted file mode 100644 index c8109e6a..00000000 --- a/app/imports/parser/parseTree/UnaryOperatorNode.js +++ /dev/null @@ -1,40 +0,0 @@ -import ParseNode from '/imports/parser/parseTree/ParseNode.js'; -import ConstantNode from '/imports/parser/parseTree/ConstantNode.js'; - -export default class UnaryOperatorNode extends ParseNode { - constructor({operator, right}) { - super(...arguments); - this.operator = operator; - this.right = right; - } - resolve(fn, scope, context){ - let rightNode = this.right[fn](scope, context); - if (rightNode.type !== 'number'){ - return new UnaryOperatorNode({ - operator: this.operator, - right: rightNode, - }); - } - let right = rightNode.value; - let result; - switch(this.operator){ - case '-': result = -right; break; - case '+': result = +right; break; - } - return new ConstantNode({ - value: result, - type: typeof result, - }); - } - toString(){ - let {right, operator} = this; - return `${operator}${right.toString()}`; - } - traverse(fn){ - fn(this); - this.right.traverse(fn); - } - replaceChildren(fn){ - this.right = this.right.replaceNodes(fn); - } -} diff --git a/app/imports/parser/parseTree/_index.js b/app/imports/parser/parseTree/_index.js new file mode 100644 index 00000000..9900c381 --- /dev/null +++ b/app/imports/parser/parseTree/_index.js @@ -0,0 +1,31 @@ +import accessor from './accessor.js'; +import array from './array.js'; +import call from './call.js'; +import constant from './constant.js'; +import error from './error.js'; +import ifNode from './if.js'; +import index from './index.js'; +import not from './not.js'; +import operator from './operator.js'; +import parenthesis from './parenthesis.js'; +import roll from './roll.js'; +import rollArray from './rollArray.js'; +import symbol from './symbol.js'; +import unaryOperator from './unaryOperator.js'; + +export default Object.freeze({ + accessor, + array, + call, + constant, + error, + if: ifNode, + index, + not, + operator, + parenthesis, + roll, + rollArray, + symbol, + unaryOperator, +}); diff --git a/app/imports/parser/parseTree/accessor.js b/app/imports/parser/parseTree/accessor.js new file mode 100644 index 00000000..855f9044 --- /dev/null +++ b/app/imports/parser/parseTree/accessor.js @@ -0,0 +1,72 @@ +import constant from './constant.js'; + +const accessor = { + create({name, path}) { + return { + parseType: 'accessor', + path, + name, + }; + }, + compile(node, scope, context){ + let value = scope && scope[node.name]; + // For objects, get their value + node.path.forEach(name => { + if (value === undefined) return; + value = value[name]; + }); + let valueType = typeof value; + // If the accessor returns an objet, get the object's value instead + while (valueType === 'object'){ + value = value.value; + valueType = typeof value; + } + // Return a parse node based on the type returned + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean'){ + return { + result: constant.create({ + value, + valueType + }), + context, + }; + } else if (valueType === 'undefined'){ + return { + result: accessor.create({ + name: node.name, + path: node.path, + }), + context, + }; + } else { + context.error(`${node.name} returned an unexpected type`); + context.error(JSON.stringify(value, null, 2)); + return { + result: accessor.create({ + name: node.name, + path: node.path, + }), + context, + }; + } + }, + reduce(node, scope, context){ + let { result } = accessor.compile(node, scope, context); + if (result.parseType === 'accessor'){ + context.error(`${accessor.toString(result)} not found, set to 0`); + return { + result: constant.create({ + value: 0, + }), + context + }; + } else { + return {result, context}; + } + }, + toString(node){ + return `${node.name}.${node.path.join('.')}`; + } +} + +export default accessor; diff --git a/app/imports/parser/parseTree/array.js b/app/imports/parser/parseTree/array.js new file mode 100644 index 00000000..fd1922c8 --- /dev/null +++ b/app/imports/parser/parseTree/array.js @@ -0,0 +1,53 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; +import constant from './constant.js'; + +const array = { + create({values}) { + return { + parseType: 'array', + values, + }; + }, + fromConstantArray(array){ + let values = array.map( value => { + let valueType = typeof value; + if ( + valueType === 'string' || + valueType === 'number' || + valueType === 'boolean' || + valueType === 'undefined' + ){ + return constant.create({value, valueType}); + } else { + throw `Unexpected type in constant array: ${valueType}` + } + }); + return array.create({values}); + }, + resolve(fn, node, scope, context){ + let values = node.values.map(node => { + let { result } = resolve(fn, node, scope, context); + return result; + }); + return { + result: array.create({values}), + context, + }; + }, + toString(node){ + return `[${node.values.map(value => toString(value)).join(', ')}]`; + }, + traverse(node, fn){ + fn(node); + node.values.forEach(value => traverse(value, fn)); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.values = node.values.map(value => map(value, fn)); + } + return resultingNode; + }, +} + +export default array; diff --git a/app/imports/parser/parseTree/call.js b/app/imports/parser/parseTree/call.js new file mode 100644 index 00000000..83cce223 --- /dev/null +++ b/app/imports/parser/parseTree/call.js @@ -0,0 +1,147 @@ +import error from './error.js'; +import constant from './constant.js'; +import functions from '/imports/parser/functions.js'; +import resolve, { toString, traverse, map } from '../resolve.js'; + +const call = { + create({functionName, args}) { + return { + parseType: 'call', + functionName, + args, + } + }, + resolve(fn, node, scope, context){ + let func = functions[node.functionName]; + // Check that the function exists + if (!func) { + context.error(`${node.functionName} is not a supported function`); + return { + result: error.create({ + node: node, + error: `${node.functionName} is not a supported function`, + }), + context, + }; + } + + // Resolve the arguments + let resolvedArgs = node.args.map(arg => { + let { result } = resolve(fn, arg, scope, context); + return result; + }); + + // Check that the arguments match what is expected + let checkFailed = call.checkArugments({ + fn, + resolvedArgs, + argumentsExpected: func.arguments, + context, + }); + + if (checkFailed){ + if (fn === 'reduce'){ + context.error(`Invalid arguments to ${node.functionName} function`); + return { + result: error.create({ + node: node, + error: `Invalid arguments to ${node.functionName} function`, + }), + context, + }; + } else { + return { + result: call.create({ + functionName: node.functionName, + args: resolvedArgs, + }), + context, + }; + } + } + + // Map contant nodes to constants before attempting to run the function + let mappedArgs = resolvedArgs.map(arg => { + if (arg.parseType === 'constant'){ + return arg.value; + } else { + return arg; + } + }); + + try { + // Run the function + let value = func.fn.apply(null, mappedArgs); + + let valueType = typeof value; + if (valueType === 'number' || valueType === 'string' || valueType === 'boolean'){ + // Convert constant results into constant nodes + return { + result: constant.create({ value, valueType }), + context, + }; + } else { + return { + result: value, + context, + }; + } + } catch (error) { + context.error(error.message || error); + return { + result: error.create({ + node: node, + error: error.message || error, + }), + context, + } + } + }, + toString(node){ + return `${node.functionName}(${node.args.map(arg => toString(arg)).join(', ')})`; + }, + traverse(node, fn){ + fn(node); + node.args.forEach(arg => traverse(arg, fn)); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.args = node.args.map(arg => map(arg, fn)); + } + return resultingNode; + }, + checkArugments({node, fn, argumentsExpected, resolvedArgs, context}){ + // Check that the number of arguments matches the number expected + if ( + !argumentsExpected.anyLength && + argumentsExpected.length !== resolvedArgs.length + ){ + context.error('Incorrect number of arguments ' + + `to ${node.functionName} function, ` + + `expected ${argumentsExpected.length} got ${resolvedArgs.length}`); + return true; + } + + let failed = false; + // Check that each argument is of the correct type + resolvedArgs.forEach((node, index) => { + let type; + if (argumentsExpected.anyLength){ + type = argumentsExpected[0]; + } else { + type = argumentsExpected[index]; + } + if (node.parseType !== type && node.valueType !== type) failed = true; + if (failed && fn === 'reduce'){ + let typeName = typeof type === 'string' ? type : type.constructor.name; + let nodeName = node.parseType; + context.error(`Incorrect arguments to ${node.functionName} function` + + `expected ${typeName} got ${nodeName}`); + } + }); + return failed; + } +} + +export default call; diff --git a/app/imports/parser/parseTree/constant.js b/app/imports/parser/parseTree/constant.js new file mode 100644 index 00000000..e382b897 --- /dev/null +++ b/app/imports/parser/parseTree/constant.js @@ -0,0 +1,17 @@ +const constant = { + create({value}){ + return { + parseType: 'constant', + valueType: typeof value, + value, + } + }, + compile(node, scope, context){ + return {result: node, context}; + }, + toString(node){ + return `${node.value}`; + }, +} + +export default constant; diff --git a/app/imports/parser/parseTree/error.js b/app/imports/parser/parseTree/error.js new file mode 100644 index 00000000..f79ac4af --- /dev/null +++ b/app/imports/parser/parseTree/error.js @@ -0,0 +1,17 @@ +const error = { + create({node, error}) { + return { + parseType: 'error', + node, + error, + } + }, + compile(node, scope, context){ + return {result: node, context}; + }, + toString(node){ + return node.error.toString(); + }, +} + +export default error; diff --git a/app/imports/parser/parseTree/if.js b/app/imports/parser/parseTree/if.js new file mode 100644 index 00000000..126b0463 --- /dev/null +++ b/app/imports/parser/parseTree/if.js @@ -0,0 +1,53 @@ +import resolve, { traverse, toString, map } from '../resolve'; + +const ifNode = { + create({condition, consequent, alternative}){ + return { + parseType: 'if', + condition, + consequent, + alternative, + }; + }, + toString(node){ + let {condition, consequent, alternative} = node; + return `${toString(condition)} ? ${toString(consequent)} : ${toString(alternative)}` + }, + resolve(fn, node, scope, context){ + let {result: condition} = resolve(fn, node.condition, scope, context); + + if (condition.parseType === 'constant'){ + if (condition.value){ + return resolve(fn, node.consequent, scope, context); + } else { + return resolve(fn, node.alternative, scope, context); + } + } else { + return { + result: ifNode.create({ + condition: condition, + consequent: node.consequent, + alternative: node.alternative, + }), + context, + }; + } + }, + traverse(node, fn){ + fn(node); + traverse(node.condition, fn); + traverse(node.consequent, fn); + traverse(node.alternative, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.condition = map(node.condition, fn); + node.consequent = map(node.consequent, fn); + node.alternative = map(node.alternative, fn); + } + return resultingNode; + }, +} + +export default ifNode; diff --git a/app/imports/parser/parseTree/index.js b/app/imports/parser/parseTree/index.js new file mode 100644 index 00000000..71fd151b --- /dev/null +++ b/app/imports/parser/parseTree/index.js @@ -0,0 +1,81 @@ +import resolve, { traverse, toString, map } from '../resolve'; +import error from './error'; + +const indexNode = { + create({array, index}) { + return { + parseType: 'index', + array, + index, + } + }, + resolve(fn, node, scope, context){ + let {result: index} = resolve(fn, node.index, scope, context); + let {result: array} = resolve(fn, node.array, scope, context); + + if ( + index.valueType === 'number' && + Number.isInteger(index.value) && + array.parseType === 'array' + ){ + if (index.value < 1 || index.value > array.values.length){ + context.error({ + type: 'warning', + message: `Index of ${index.value} is out of range for an array` + + ` of length ${array.values.length}`, + }); + } + let selection = array.values[index.value - 1]; + if (selection){ + return resolve(fn, selection, scope, context); + } + } else if (fn === 'reduce'){ + if (array.parseType !== 'array'){ + const message = `Can not get the index of a non-array node: ${node.array.toString()} = ${array.toString()}` + context.error(message); + return { + result: error.create({ + node, + error: message, + }), + context, + }; + } else if (!index.isInteger){ + const message = `${array.toString()} is not an integer index of the array` + context.error(message); + return { + result: error.create({ + node, + error: message, + }), + context, + }; + } + } + return { + result: indexNode.create({ + index, + array, + }), + context, + }; + }, + toString(node){ + return `${toString(node.array)}[${toString(node.index)}]`; + }, + traverse(node, fn){ + fn(node); + traverse(node.array, fn); + traverse(node.index, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.array = map(node.array, fn); + node.index = map(node.index, fn); + } + return resultingNode; + }, +} + +export default indexNode; diff --git a/app/imports/parser/parseTree/not.js b/app/imports/parser/parseTree/not.js new file mode 100644 index 00000000..6864f5eb --- /dev/null +++ b/app/imports/parser/parseTree/not.js @@ -0,0 +1,44 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; +import constant from './constant.js'; + +const not = { + create({right}) { + return { + parseType: 'not', + right, + } + }, + resolve(fn, node, scope, context){ + const {result: right} = resolve(fn, node.right, scope, context); + if (right.parseType !== 'constant'){ + return { + result: not.create({ + right: right, + }), + context, + }; + } + return { + result: constant.create({ + value: !right.value, + }), + context, + }; + }, + toString(node){ + return `!${toString(node.right)}`; + }, + traverse(node, fn){ + fn(node); + traverse(node.right, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.right = map(node.right, fn); + } + return resultingNode; + }, +} + +export default not; diff --git a/app/imports/parser/parseTree/operator.js b/app/imports/parser/parseTree/operator.js new file mode 100644 index 00000000..5e827e9a --- /dev/null +++ b/app/imports/parser/parseTree/operator.js @@ -0,0 +1,84 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; +import constant from './constant.js'; + +const operator = { + create({left, right, operator, fn}) { + return { + parseType: 'operator', + left, + right, + operator, + fn + }; + }, + resolve(fn, node, scope, context){ + const {result: leftNode} = resolve(fn, node.left, scope, context); + const {result: rightNode} = resolve(fn, node.right, scope, context); + let left, right; + if (leftNode.parseType !== 'constant' || rightNode.parseType !== 'constant'){ + return { + result: operator.create({ + left: leftNode, + right: rightNode, + operator: node.operator, + fn: node.fn, + }), + context, + }; + } else { + left = leftNode.value; + right = rightNode.value; + } + let result; + switch(node.operator){ + case '+': result = left + right; break; + case '-': result = left - right; break; + case '*': result = left * right; break; + case '/': result = left / right; break; + case '^': result = Math.pow(left, right); break; + case '%': result = left % right; break; + case '&': + case '&&': result = left && right; break; + case '|': + case '||': result = left || right; break; + case '=': + case '==': result = left == right; break; + case '===': result = left === right; break; + case '!=': result = left != right; break; + case '!==': result = left !== right; break; + case '>': result = left > right; break; + case '<': result = left < right; break; + case '>=': result = left >= right; break; + case '<=': result = left <= right; break; + } + return { + result: constant.create({ + value: result, + }), + context, + }; + }, + toString(node){ + let {left, right, operator} = node; + // special case of adding a negative number + if (operator === '+' && right.valueType === 'number' && right.value < 0){ + return `${toString(left)} - ${-right.value}` + } + return `${toString(left)} ${operator} ${toString(right)}`; + }, + traverse(node, fn){ + fn(node); + traverse(node.left, fn); + traverse(node.right, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.left = map(node.left, fn); + node.right = map(node.right, fn); + } + return resultingNode; + }, +} + +export default operator; diff --git a/app/imports/parser/parseTree/parenthesis.js b/app/imports/parser/parseTree/parenthesis.js new file mode 100644 index 00000000..f66e4364 --- /dev/null +++ b/app/imports/parser/parseTree/parenthesis.js @@ -0,0 +1,41 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; + +const parenthesis = { + create({content}) { + return { + parseType: 'parenthesis', + content, + }; + }, + resolve(fn, node, scope, context){ + const {result: content} = resolve(fn, node.content, scope, context); + if ( + fn === 'reduce' || + content.parseType === 'constant' || + content.parseType === 'error' + ){ + return {result: content, context}; + } else { + return { + result: parenthesis.create({content}), + context + }; + } + }, + toString(node){ + return `(${toString(node.content)})`; + }, + traverse(node, fn){ + fn(node); + traverse(node.content, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.content = map(node.content, fn); + } + return resultingNode; + }, +} + +export default parenthesis; diff --git a/app/imports/parser/parseTree/roll.js b/app/imports/parser/parseTree/roll.js new file mode 100644 index 00000000..c4e028c4 --- /dev/null +++ b/app/imports/parser/parseTree/roll.js @@ -0,0 +1,90 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; +import error from './error.js'; +import rollArray from './rollArray.js'; +import rollDice from '/imports/parser/rollDice.js'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; + +const rollNode = { + create({left, right}) { + return { + parseType: 'roll', + left, + right, + }; + }, + compile(node, scope, context){ + const {result: left} = resolve('compile', node.left, scope, context); + const {result: right} = resolve('compile', node.right, scope, context); + return { + result: rollNode.create({left, right}), + context, + }; + }, + toString(node){ + if ( + node.left.valueType === 'number' && node.left.value === 1 + ){ + return `d${toString(node.right)}`; + } else { + return `${toString(node.left)}d${toString(node.right)}`; + } + }, + roll(node, scope, context){ + const {result: left} = resolve('reduce', node.left, scope, context); + const {result: right} = resolve('reduce', node.right, scope, context); + if (left.valueType !== 'number' && !Number.isInteger(left.value)){ + return errorResult('Number of dice is not an integer', node, context); + } + if (right.valueType !== 'number' && !Number.isInteger(right.value)){ + return errorResult('Dice size is not an integer', node, context); + } + let number = left.value; + if (context.options.doubleRolls){ + number *= 2; + } + if (number > STORAGE_LIMITS.diceRollValuesCount){ + const message = `Can't roll more than ${STORAGE_LIMITS.diceRollValuesCount} dice at once`; + return errorResult(message, node, context); + } + let diceSize = right.value; + let values = rollDice(number, diceSize); + if (context){ + context.rolls.push({number, diceSize, values}); + } + return { + result: rollArray.create({ + values, + diceSize, + diceNum: left.value, + }), + context + }; + }, + reduce(node, scope, context){ + const {result} = rollNode.roll(node, scope, context); + return resolve('reduce', result, scope, context); + }, + traverse(node, fn){ + fn(node); + traverse(node.left, fn); + traverse(node.right, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.left = map(node.left, fn); + node.right = map(node.right, fn); + } + return resultingNode; + }, +} + +function errorResult(message, node, context){ + context.error(message); + return { + result: error.create({ node, error: message }), + context, + }; +} + +export default rollNode; diff --git a/app/imports/parser/parseTree/rollArray.js b/app/imports/parser/parseTree/rollArray.js new file mode 100644 index 00000000..49474acb --- /dev/null +++ b/app/imports/parser/parseTree/rollArray.js @@ -0,0 +1,32 @@ +import constant from './constant.js'; + +const rollArray = { + create({values, diceSize, diceNum}) { + return { + parseType: 'rollArray', + values, + diceSize, + diceNum, + }; + }, + compile(node, scope, context){ + return { + result: node, + context + }; + }, + toString(node){ + return `${node.diceNum || ''}d${node.diceSize} [${node.values.join(', ')}]`; + }, + reduce(node, scope, context){ + const total = node.values.reduce((a, b) => a + b, 0); + return { + result: constant.create({ + value: total, + }), + context, + }; + }, +} + +export default rollArray; diff --git a/app/imports/parser/parseTree/symbol.js b/app/imports/parser/parseTree/symbol.js new file mode 100644 index 00000000..208baa66 --- /dev/null +++ b/app/imports/parser/parseTree/symbol.js @@ -0,0 +1,61 @@ +import resolve from '../resolve.js'; +import constant from './constant.js'; + +const symbol = { + create({name}){ + return { + parseType: 'symbol', + name, + }; + }, + toString(node){ + return `${node.name}` + }, + compile(node, scope, context, calledFromReduce = false){ + let value = scope && scope[node.name]; + let type = typeof value; + // For objects, default to their .value + if (type === 'object'){ + value = value.value; + type = typeof value; + } + // For parse nodes, compile and return + if (value?.parseType){ + if (calledFromReduce){ + return resolve('reduce', value, scope, context); + } else { + return resolve('compile', value, scope, context); + } + } + if (type === 'string' || type === 'number' || type === 'boolean'){ + return { + result: constant.create({value}), + context, + }; + } else if (type === 'undefined'){ + return { + result: symbol.create({name: node.name}), + context, + }; + } else { + throw new Meteor.Error(`Unexpected case: ${node.name} resolved to ${value}`); + } + }, + reduce(node, scope, context){ + let {result} = symbol.compile(node, scope, context, true); + if (result.parseType === 'symbol'){ + context.error({ + type: 'info', + message: `${result.toString()} not found, set to 0` + }); + return { + result: constant.create({value: 0}), + context, + }; + } else { + return {result, context}; + } + } +} + +export default symbol; diff --git a/app/imports/parser/parseTree/unaryOperator.js b/app/imports/parser/parseTree/unaryOperator.js new file mode 100644 index 00000000..20065794 --- /dev/null +++ b/app/imports/parser/parseTree/unaryOperator.js @@ -0,0 +1,53 @@ +import resolve, { toString, traverse, map } from '../resolve.js'; +import constant from './constant.js'; + +const unaryOperator = { + create({operator, right}) { + return { + parseType: 'unaryOperator', + operator, + right, + }; + }, + resolve(fn, node, scope, context){ + const {result: rightNode} = resolve(fn, node.right, scope, context); + if (rightNode.valueType !== 'number'){ + return { + result: unaryOperator.create({ + operator: node.operator, + right: rightNode, + }), + context, + }; + } + let right = rightNode.value; + let result; + switch(node.operator){ + case '-': result = -right; break; + case '+': result = +right; break; + } + return { + result: constant.create({ + value: result, + parseType: typeof result, + }), + context, + }; + }, + toString(node){ + return `${node.operator}${toString(node.right)}`; + }, + traverse(node, fn){ + fn(node); + traverse(node.right, fn); + }, + map(node, fn){ + const resultingNode = fn(node); + if (resultingNode === node){ + node.right = map(node.right, fn); + } + return resultingNode; + }, +}; + +export default unaryOperator; diff --git a/app/imports/parser/parser.js b/app/imports/parser/parser.js index 13f24579..8532055b 100644 --- a/app/imports/parser/parser.js +++ b/app/imports/parser/parser.js @@ -7,20 +7,6 @@ export default function parser(){ return new nearley.Parser(nearleyGrammar); } -export class CompilationContext { - constructor({doubleRolls} = {}){ - this.errors = []; - this.rolls = []; - this.doubleRolls = doubleRolls; - } - storeError(e){ - this.errors.push(e); - } - storeRoll(r){ - this.rolls.push(r); - } -} - export function parse(string){ let parser = new nearley.Parser(nearleyGrammar); parser.feed(string); diff --git a/app/imports/parser/parser.test.js b/app/imports/parser/parser.test.js new file mode 100644 index 00000000..30f0b0b9 --- /dev/null +++ b/app/imports/parser/parser.test.js @@ -0,0 +1,11 @@ +import { parse } from './parser'; +import { assert } from 'chai'; + +describe('Parser', function(){ + it('parses valid text without error', function(){ + assert.typeOf(parse('1'), 'object'); + }); + it('parses various operations', function(){ + assert.typeOf(parse('1 + 2 * 3 / 4 * 1d8'), 'object'); + }); +}); diff --git a/app/imports/parser/resolve.js b/app/imports/parser/resolve.js new file mode 100644 index 00000000..7089901d --- /dev/null +++ b/app/imports/parser/resolve.js @@ -0,0 +1,79 @@ +import nodeTypeIndex from './parseTree/_index.js'; + +// Takes a parse ndoe and computes it to a set detail level +// returns {result, context} +export default function resolve(fn, node, scope, context = new Context()){ + if (!node) return {result: undefined, context}; + let type = nodeTypeIndex[node.parseType]; + if (!type){ + throw new Meteor.Error(`Parse node type: ${node.parseType} not implemented`); + } + if (type.resolve){ + return type.resolve(fn, node, scope, context); + } else if (type[fn]) { + return type[fn](node, scope, context); + } else if (fn === 'reduce' && type.roll) { + return type.roll(node, scope, context) + } else if (type.compile){ + return type.compile(node, scope, context) + } else { + throw new Meteor.Error('Compile not implemented on ' + node.parseType); + } +} + +export function toString(node){ + if (!node) return ''; + let type = nodeTypeIndex[node.parseType]; + if (!type.toString){ + throw new Meteor.Error('toString not implemented on ' + node.parseType); + } + return type.toString(node); +} + +export function traverse(node, fn){ + if (!node) return; + let type = nodeTypeIndex[node.parseType]; + if (!type){ + console.error(node); + throw new Meteor.Error('Not valid parse node'); + } + if (type.traverse){ + return type.traverse(node, fn); + } + return fn(node); +} + +export function map(node, fn){ + if (!node) return; + let type = nodeTypeIndex[node.parseType]; + if (!type){ + console.error(node); + throw new Meteor.Error('Not valid parse node'); + } + if (type.map){ + return type.map(node, fn); + } + return fn(node); +} + +export class Context { + constructor({errors = [], rolls = [], options = {}} = {}){ + this.errors = errors; + this.rolls = rolls; + this.options = options; + } + error(e){ + if (!e) return; + if (typeof e === 'string'){ + this.errors.push({ + type: 'error', + message: e, + }); + } else { + this.errors.push(e); + } + } + roll(r){ + this.rolls.push(r); + } +} diff --git a/app/imports/parser/roll.js b/app/imports/parser/rollDice.js similarity index 79% rename from app/imports/parser/roll.js rename to app/imports/parser/rollDice.js index 382a4cad..4e379ba6 100644 --- a/app/imports/parser/roll.js +++ b/app/imports/parser/rollDice.js @@ -1,4 +1,4 @@ -export default function roll(number, diceSize){ +export default function rollDice(number, diceSize){ let values = []; let randomSrc = DDP.randomStream('diceRoller'); for (let i = 0; i < number; i++){ diff --git a/app/imports/server/config/SimpleRestConfig.js b/app/imports/server/config/SimpleRestConfig.js index a4d746cf..72f63b40 100644 --- a/app/imports/server/config/SimpleRestConfig.js +++ b/app/imports/server/config/SimpleRestConfig.js @@ -1,6 +1,33 @@ +import { JsonRoutes, RestMiddleware } from 'meteor/simple:json-routes'; import { SimpleRest } from 'meteor/simple:rest'; +Meteor.startup(() => { + // Enable cross origin requests for all endpoints + JsonRoutes.setResponseHeaders({ + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', + }); +}); + SimpleRest.configure({ // No default collection methods get end points collections: [], }); + +// All errors are handled as JSON +JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson); + +// Hack to stop simple:rest adding routes automatically unless their URL +// has been explicitly set to 'api/...' +const oldAdd = JsonRoutes.add; +JsonRoutes.add = function(method, path, handler){ + if (path.substring(0,4) !== 'api/'){ + return; + } + oldAdd(method, path, handler); +} + +import '/imports/server/rest/restLogin.js'; diff --git a/app/imports/server/config/accountsEmailConfig.js b/app/imports/server/config/accountsEmailConfig.js index b1526d82..e6853515 100644 --- a/app/imports/server/config/accountsEmailConfig.js +++ b/app/imports/server/config/accountsEmailConfig.js @@ -1,4 +1,35 @@ import { Accounts } from 'meteor/accounts-base' +import emailTemplate from './emailTemplate.js'; Accounts.emailTemplates.from = 'no-reply@dicecloud.com'; Accounts.emailTemplates.siteName = 'DiceCloud'; + +Accounts.emailTemplates.enrollAccount = { + subject: () => 'DiceCloud Invite', + html: (user, url) => emailTemplate({ + heading: 'DiceCloud Invite', + text: 'You have been invited to DiceCloud, click the button below to begin.', + buttonText: 'Get Started', + url: url.replace( '#/', '' ), + }), +}; + +Accounts.emailTemplates.resetPassword = { + subject: () => 'DiceCloud Password Reset', + html: (user, url) => emailTemplate({ + heading: 'Password Reset', + text: 'If you did not request this password reset, please ignore this email.', + buttonText: 'Reset Password', + url: url.replace( '#/', '' ), + }), +}; + +Accounts.emailTemplates.verifyEmail = { + subject: () => 'DiceCloud Email Verification', + html: (user, url) => emailTemplate({ + heading: 'DiceCloud Email Verification', + text: 'Click below to verify your email address', + buttonText: 'Verify Email', + url: url.replace( '#/', '' ), + }), +}; diff --git a/app/imports/server/config/emailTemplate.js b/app/imports/server/config/emailTemplate.js new file mode 100644 index 00000000..80f6d6a4 --- /dev/null +++ b/app/imports/server/config/emailTemplate.js @@ -0,0 +1,167 @@ +export default function emailTemplate({heading, text, buttonText, url}){ + return ` + + + + + + + + + + + + + + + + + + + + +` +} diff --git a/app/imports/server/config/limitLoginTokens.js b/app/imports/server/config/limitLoginTokens.js new file mode 100644 index 00000000..594cb14c --- /dev/null +++ b/app/imports/server/config/limitLoginTokens.js @@ -0,0 +1,21 @@ +const MAX_LOGIN_TOKENS = 20; + +Accounts._insertHashedLoginToken = function(userId, hashedToken, query) { + query = query ? { ...query } : {}; + query._id = userId; + const user = Accounts.users.findOne(query); + let loginTokenLength = user?.services?.resume?.loginTokens?.length; + while (loginTokenLength >= MAX_LOGIN_TOKENS){ + loginTokenLength -=1; + Accounts.users.update(query, { + $pop: { + 'services.resume.loginTokens': -1 + } + }); + } + Accounts.users.update(query, { + $addToSet: { + 'services.resume.loginTokens': hashedToken + } + }); +}; diff --git a/app/imports/server/config/simpleSchemaDebug.js b/app/imports/server/config/simpleSchemaDebug.js index 1bf2e900..ce81e01d 100644 --- a/app/imports/server/config/simpleSchemaDebug.js +++ b/app/imports/server/config/simpleSchemaDebug.js @@ -1,5 +1,5 @@ import SimpleSchema from 'simpl-schema'; if (Meteor.isDevelopment){ - SimpleSchema.debug = true + //SimpleSchema.debug = true } diff --git a/app/imports/server/publications/archiveFiles.js b/app/imports/server/publications/archiveFiles.js new file mode 100644 index 00000000..8da1130e --- /dev/null +++ b/app/imports/server/publications/archiveFiles.js @@ -0,0 +1,7 @@ +import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; + +Meteor.publish('archiveCreatureFiles', function () { + return ArchiveCreatureFiles.find({ + userId: this.userId, + }).cursor; +}); diff --git a/app/imports/server/publications/index.js b/app/imports/server/publications/index.js index c58f6188..3bdc770a 100644 --- a/app/imports/server/publications/index.js +++ b/app/imports/server/publications/index.js @@ -10,3 +10,4 @@ import '/imports/server/publications/slotFillers.js'; import '/imports/server/publications/ownedDocuments.js'; import '/imports/server/publications/archivedCreatures.js'; import '/imports/server/publications/searchLibraryNodes.js'; +import '/imports/server/publications/archiveFiles.js'; diff --git a/app/imports/server/publications/searchLibraryNodes.js b/app/imports/server/publications/searchLibraryNodes.js index 88d7e678..ddbbf4ba 100644 --- a/app/imports/server/publications/searchLibraryNodes.js +++ b/app/imports/server/publications/searchLibraryNodes.js @@ -4,7 +4,6 @@ import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import { assertViewPermission } from '/imports/api/sharing/sharingPermissions.js'; Meteor.publish('selectedLibraryNodes', function(selectedNodeIds){ - console.log('attempting selectedLibraryNodes') check(selectedNodeIds, Array); // Limit to 20 selected nodes if (selectedNodeIds.length > 20){ diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index 105ffed2..611f1d23 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -3,8 +3,7 @@ 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 { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; -import recomputeInvetory from '/imports/api/creature/denormalise/recomputeInventory.js'; -import { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js'; +import computeCreature from '/imports/api/engine/computeCreature.js'; import VERSION from '/imports/constants/VERSION.js'; let schema = new SimpleSchema({ @@ -15,8 +14,12 @@ let schema = new SimpleSchema({ }); Meteor.publish('singleCharacter', function(creatureId){ - schema.validate({ creatureId }); - this.autorun(function (){ + try { + schema.validate({ creatureId }); + } catch (e){ + this.error(e); + } + this.autorun(function (computation){ let userId = this.userId; let creatureCursor creatureCursor = Creatures.find({ @@ -25,10 +28,9 @@ Meteor.publish('singleCharacter', function(creatureId){ let creature = creatureCursor.fetch()[0]; try { assertViewPermission(creature, userId) } catch(e){ return [] } - if (creature.computeVersion !== VERSION){ + if (creature.computeVersion !== VERSION && computation.firstRun){ try { - recomputeInvetory(creatureId); - recomputeCreatureById(creatureId) + computeCreature(creatureId) } catch(e){ console.error(e) } } diff --git a/app/imports/server/rest/apiPublications/creature.js b/app/imports/server/rest/apiPublications/creature.js new file mode 100644 index 00000000..f805c92b --- /dev/null +++ b/app/imports/server/rest/apiPublications/creature.js @@ -0,0 +1,46 @@ +import SimpleSchema from 'simpl-schema'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; +import computeCreature from '/imports/api/engine/computeCreature.js'; +import VERSION from '/imports/constants/VERSION.js'; + +Meteor.publish('api-creature', function(creatureId){ + try { + new SimpleSchema({ + creatureId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validate({ creatureId }); + } catch (e){ + this.error(e); + return; + } + const userId = this.userId; + const creatureCursor = Creatures.find({ + _id: creatureId, + }); + const creature = creatureCursor.fetch()[0]; + try { + assertViewPermission(creature, userId) + } catch(e){ + this.error(e); + return; + } + if (creature.computeVersion !== VERSION){ + try { + computeCreature(creatureId) + } catch(e){ + console.error(e) + } + } + return [ + creatureCursor, + CreatureProperties.find({ + 'ancestors.id': creatureId, + }), + ]; +}, { + url: 'api/creature/:0' +}); diff --git a/app/imports/server/rest/apiPublications/index.js b/app/imports/server/rest/apiPublications/index.js new file mode 100644 index 00000000..765d7988 --- /dev/null +++ b/app/imports/server/rest/apiPublications/index.js @@ -0,0 +1 @@ +import './creature.js'; diff --git a/app/imports/server/rest/index.js b/app/imports/server/rest/index.js new file mode 100644 index 00000000..b552e02a --- /dev/null +++ b/app/imports/server/rest/index.js @@ -0,0 +1,2 @@ +import './restLogin.js'; +import './apiPublications/index.js'; diff --git a/app/imports/server/rest/middleware/authenticateUserByToken.js b/app/imports/server/rest/middleware/authenticateUserByToken.js new file mode 100644 index 00000000..576f5430 --- /dev/null +++ b/app/imports/server/rest/middleware/authenticateUserByToken.js @@ -0,0 +1,57 @@ +var Fiber = Npm.require('fibers'); +import { RestMiddleware } from 'meteor/simple:json-routes'; + +/** + * SimpleRest middleware for validating a Meteor.user's login token + * + * This middleware must be processed after the request.token has been set to a + * valid login token for a Meteor.user account (from a separate layer of + * middleware). If authentication is successful, the request.userId will be set + * to the ID of the authenticated user. An invalid token will result in a error. + * + * @middleware + */ +const authenticateMeteorUserByToken = + function (req, res, next) { + Fiber(function () { + let userId; + try { + userId = getUserIdFromAuthToken(req.authToken); + } catch (e){ + RestMiddleware.handleErrorAsJson(e, req, res, next); + return; + } + if (userId) { + req.userId = userId; + } + + next(); + }).run(); + }; + +/** + * Retrieves the ID of the Meteor.user that the given auth token belongs to + * + * @param token An unhashed auth token + * @returns {String} The ID of the authenticated Meteor.user, or null if token + * is invalid + */ +function getUserIdFromAuthToken(token) { + if (!token) { + return null; + } + + var user = Meteor.users.findOne({ + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(token), + }); + if (user) { + return user._id; + } else { + const error = new Meteor.Error('Permission denied', + 'Invalid authentication token'); + error.statusCode = 403; + throw error; + } +} + +export default authenticateMeteorUserByToken; diff --git a/app/imports/server/rest/restLogin.js b/app/imports/server/rest/restLogin.js new file mode 100644 index 00000000..1099bc37 --- /dev/null +++ b/app/imports/server/rest/restLogin.js @@ -0,0 +1,85 @@ +import { JsonRoutes } from 'meteor/simple:json-routes'; +import authenticateMeteorUserByToken from './middleware/authenticateUserByToken.js'; +/** + * Login with username/email and password: + * POST /api/login + * Body: {"username": "", "password": ""} + * Alternative Body: {"email": "", "password": ""} + * Successful response: + * { + * "id": "", + * "token": "", + * "tokenExpires": "" + * } + * + * Warning: Your token may expire before the given date. + * Since each user has a limited pool of login tokens. If you get a permission + * error, you may need to login again to refresh your token + * + * Once you have your token, you can use it as a standard bearer token header + * in other API endpoints: + * HTTP.post("/methods/return-five-auth", { + * headers: { Authorization: "Bearer " } + * }, callback); +**/ + +JsonRoutes.Middleware.use(JsonRoutes.Middleware.parseBearerToken); +JsonRoutes.Middleware.use(authenticateMeteorUserByToken); + +JsonRoutes.add('options', 'api/login', function (req, res) { + JsonRoutes.sendResult(res); +}); + +JsonRoutes.add('post', 'api/login', function (req, res) { + var options = req.body; + + var user; + if (options.email) { + check(options, { + email: String, + password: String, + }); + user = Accounts.findUserByEmail(options.email); + } else { + check(options, { + username: String, + password: String, + }); + user = Accounts.findUserByUsername(options.username); + } + + if (!user) { + throw new Meteor.Error('not-found', + 'User with that username or email address not found.'); + } + + var result = Accounts._checkPassword(user, options.password); + check(result, { + userId: String, + error: Match.Optional(Meteor.Error), + }); + + if (result.error) { + throw result.error; + } + + var stampedLoginToken = Accounts._generateStampedLoginToken(); + check(stampedLoginToken, { + token: String, + when: Date, + }); + + Accounts._insertLoginToken(result.userId, stampedLoginToken); + + var tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when); + check(tokenExpiration, Date); + + JsonRoutes.sendResult(res, { + data: { + id: result.userId, + token: stampedLoginToken.token, + tokenExpires: tokenExpiration, + }, + }); + +}); diff --git a/app/imports/ui/components/MarkdownText.vue b/app/imports/ui/components/MarkdownText.vue index 8e89df50..681878d2 100644 --- a/app/imports/ui/components/MarkdownText.vue +++ b/app/imports/ui/components/MarkdownText.vue @@ -7,7 +7,7 @@ - - diff --git a/app/imports/ui/components/global/SmartSelect.vue b/app/imports/ui/components/global/SmartSelect.vue index 8b48ad6e..439f735b 100644 --- a/app/imports/ui/components/global/SmartSelect.vue +++ b/app/imports/ui/components/global/SmartSelect.vue @@ -15,6 +15,10 @@ slot="prepend" name="prepend" /> + diff --git a/app/imports/ui/components/global/TextField.vue b/app/imports/ui/components/global/TextField.vue index 7ff7617f..9c9f0919 100644 --- a/app/imports/ui/components/global/TextField.vue +++ b/app/imports/ui/components/global/TextField.vue @@ -2,6 +2,7 @@ + > + + + + diff --git a/app/imports/ui/components/propertyToolbar.vue b/app/imports/ui/components/propertyToolbar.vue index c6b474be..36b24414 100644 --- a/app/imports/ui/components/propertyToolbar.vue +++ b/app/imports/ui/components/propertyToolbar.vue @@ -102,21 +102,27 @@ /> + + {{ editing ? 'Done' : 'Edit' }} + mdi-check mdi-pencil diff --git a/app/imports/ui/creature/archive/ArchiveDialog.vue b/app/imports/ui/creature/archive/ArchiveDialog.vue index 03b687f2..0182b339 100644 --- a/app/imports/ui/creature/archive/ArchiveDialog.vue +++ b/app/imports/ui/creature/archive/ArchiveDialog.vue @@ -60,12 +60,13 @@ + + diff --git a/app/imports/ui/pages/EmailVerificationError.vue b/app/imports/ui/pages/EmailVerificationError.vue new file mode 100644 index 00000000..e01d0090 --- /dev/null +++ b/app/imports/ui/pages/EmailVerificationError.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/app/imports/ui/pages/EmailVerificationSuccess.vue b/app/imports/ui/pages/EmailVerificationSuccess.vue new file mode 100644 index 00000000..759e998f --- /dev/null +++ b/app/imports/ui/pages/EmailVerificationSuccess.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/app/imports/ui/pages/Home.vue b/app/imports/ui/pages/Home.vue index 1b638bc8..171ade01 100644 --- a/app/imports/ui/pages/Home.vue +++ b/app/imports/ui/pages/Home.vue @@ -94,7 +94,7 @@ > diff --git a/app/imports/ui/pages/Maintenance.vue b/app/imports/ui/pages/Maintenance.vue new file mode 100644 index 00000000..52e63fd8 --- /dev/null +++ b/app/imports/ui/pages/Maintenance.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/imports/ui/pages/ResetPassword.vue b/app/imports/ui/pages/ResetPassword.vue new file mode 100644 index 00000000..f901e3a5 --- /dev/null +++ b/app/imports/ui/pages/ResetPassword.vue @@ -0,0 +1,139 @@ + + + diff --git a/app/imports/ui/pages/SignIn.vue b/app/imports/ui/pages/SignIn.vue index 5bb545cd..87f83356 100644 --- a/app/imports/ui/pages/SignIn.vue +++ b/app/imports/ui/pages/SignIn.vue @@ -33,7 +33,10 @@ required @keyup.enter="submit" /> - + Reset Password
@@ -50,7 +53,7 @@ Register diff --git a/app/imports/ui/pages/Tabletop.vue b/app/imports/ui/pages/Tabletop.vue index 55998eca..fd1f17ac 100644 --- a/app/imports/ui/pages/Tabletop.vue +++ b/app/imports/ui/pages/Tabletop.vue @@ -1,27 +1,40 @@ diff --git a/app/imports/ui/properties/components/attributes/HealthBarCard.vue b/app/imports/ui/properties/components/attributes/HealthBarCard.vue index 9465b86a..496aa7e7 100644 --- a/app/imports/ui/properties/components/attributes/HealthBarCard.vue +++ b/app/imports/ui/properties/components/attributes/HealthBarCard.vue @@ -3,8 +3,8 @@ mdi-chevron-up @@ -24,7 +24,7 @@ mdi-chevron-down @@ -35,10 +35,10 @@ align-end >
- {{ currentValue }} + {{ model.value }}
- /{{ model.value }} + /{{ model.total }}
@@ -73,9 +73,6 @@ export default { hover: false, }}, computed: { - currentValue(){ - return this.model.value - (this.model.damage || 0); - }, signedConMod(){ return numberToSignedString(this.model.constitutionMod); }, diff --git a/app/imports/ui/properties/components/attributes/ResourceCard.vue b/app/imports/ui/properties/components/attributes/ResourceCard.vue index a9546d18..6f3482a9 100644 --- a/app/imports/ui/properties/components/attributes/ResourceCard.vue +++ b/app/imports/ui/properties/components/attributes/ResourceCard.vue @@ -8,7 +8,7 @@ mdi-chevron-up @@ -16,7 +16,7 @@ mdi-chevron-down @@ -26,10 +26,10 @@ class="layout align-center value pl-2 pr-3" >
- {{ currentValue }} + {{ model.value }}
- /{{ value }} + /{{ model.total }}
- {{ name }} + {{ model.name }}
@@ -48,24 +48,18 @@ + + diff --git a/app/imports/ui/properties/components/features/FeatureCard.vue b/app/imports/ui/properties/components/features/FeatureCard.vue index 79062699..0f706b3e 100644 --- a/app/imports/ui/properties/components/features/FeatureCard.vue +++ b/app/imports/ui/properties/components/features/FeatureCard.vue @@ -11,10 +11,9 @@ - @@ -22,12 +21,12 @@ + + diff --git a/app/imports/ui/properties/forms/ActionForm.vue b/app/imports/ui/properties/forms/ActionForm.vue index d347dd1a..a340d19e 100644 --- a/app/imports/ui/properties/forms/ActionForm.vue +++ b/app/imports/ui/properties/forms/ActionForm.vue @@ -1,57 +1,82 @@ @@ -84,17 +180,20 @@ import getEffectIcon from '/imports/ui/utility/getEffectIcon.js'; import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; import attributeListMixin from '/imports/ui/properties/forms/shared/lists/attributeListMixin.js'; - import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue'; + import { EffectSchema } from '/imports/api/properties/Effects.js'; + import FormSection from '/imports/ui/properties/forms/shared/FormSection.vue'; const ICON_SPIN_DURATION = 300; export default { components: { - CalculationErrorList, - }, + FormSection, + }, mixins: [propertyFormMixin, attributeListMixin], data(){ return { displayedIcon: 'add', iconClass: '', + addExtraTagsLoading: false, + oldOperation: undefined, operations: [ {value: 'base', text: 'Base Value'}, {value: 'add', text: 'Add'}, @@ -110,6 +209,14 @@ ], }}, computed: { + radioGroup(){ + return this.model.targetByTags ? 'tags' : 'stats'; + }, + extraTagsFull(){ + if (!this.model.extraTags) return false; + let maxCount = EffectSchema.get('extraTags', 'maxCount'); + return this.model.extraTags.length >= maxCount; + }, needsValue(){ switch(this.model.operation) { case 'base': return true; @@ -123,7 +230,6 @@ case 'passiveAdd': return true; case 'fail': return false; case 'conditional': return false; - case 'rollBonus': return true; default: return true; } }, @@ -138,9 +244,8 @@ case 'advantage': return 'If this stat is the basis for a check, that check will be at advantage'; case 'disadvantage': return 'If this stat is the basis for a check, that check will be at advantage'; case 'passiveAdd': return 'This value will be added to the passive check'; - case 'fail': return 'Stat based on this attribute will always fail'; + case 'fail': return 'Targeted skills and checks will always fail'; case 'conditional': return 'Add a text note to this stat'; - case 'rollBonus': return 'Add this value to rolls based on this stat'; default: return ''; } }, @@ -168,6 +273,32 @@ }, methods: { getEffectIcon, + changeTargetByTags(value){ + if(value === 'stats'){ + this.$emit('change', {path: ['targetByTags'], value: undefined}); + if (this.oldOperation && this.oldOperation !== this.model.operation){ + this.$emit('change', {path: ['operation'], value: this.oldOperation}); + } + } else if (value === 'tags'){ + this.$emit('change', {path: ['targetByTags'], value: true}); + if (this.model.operation !== 'add'){ + this.oldOperation = this.model.operation; + this.$emit('change', {path: ['operation'], value: 'add'}); + } + } + }, + addExtraTags(){ + this.addExtraTagsLoading = true; + this.$emit('push', { + path: ['extraTags'], + value: { + _id: Random.id(), + operation: 'OR', + tags: [], + }, + ack: () => this.addExtraTagsLoading = false, + }); + }, } }; diff --git a/app/imports/ui/properties/forms/FeatureForm.vue b/app/imports/ui/properties/forms/FeatureForm.vue index 4f8bf154..77f3a77b 100644 --- a/app/imports/ui/properties/forms/FeatureForm.vue +++ b/app/imports/ui/properties/forms/FeatureForm.vue @@ -7,23 +7,23 @@ :error-messages="errors.name" @change="change('name', ...arguments)" /> - - - - import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; - import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue'; export default { - components: { - CalculationErrorList, - }, mixins: [propertyFormMixin], data(){ return{ enabledOptions: [ diff --git a/app/imports/ui/properties/forms/ItemConsumedForm.vue b/app/imports/ui/properties/forms/ItemConsumedForm.vue index 634fe171..d65fd6bc 100644 --- a/app/imports/ui/properties/forms/ItemConsumedForm.vue +++ b/app/imports/ui/properties/forms/ItemConsumedForm.vue @@ -1,22 +1,33 @@ diff --git a/app/imports/ui/properties/forms/ReferenceForm.vue b/app/imports/ui/properties/forms/ReferenceForm.vue index 95608437..8c580322 100644 --- a/app/imports/ui/properties/forms/ReferenceForm.vue +++ b/app/imports/ui/properties/forms/ReferenceForm.vue @@ -1,9 +1,14 @@ @@ -33,10 +36,12 @@ import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue'; import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; import updateReferenceNode from '/imports/api/library/methods/updateReferenceNode.js'; + import PropertyField from '/imports/ui/properties/viewers/shared/PropertyField.vue'; export default { components: { TreeNodeView, + PropertyField, }, mixins: [propertyFormMixin], data(){return { diff --git a/app/imports/ui/properties/forms/ResourcesForm.vue b/app/imports/ui/properties/forms/ResourcesForm.vue index c50c1a03..886ba8c4 100644 --- a/app/imports/ui/properties/forms/ResourcesForm.vue +++ b/app/imports/ui/properties/forms/ResourcesForm.vue @@ -12,6 +12,12 @@ @push="({path, value, ack}) => $emit('push', {path: ['attributesConsumed', ...path], value, ack})" @pull="({path, ack}) => $emit('pull', {path: ['attributesConsumed', ...path], ack})" /> +
+ Ammo +
import AttributesConsumedListForm from '/imports/ui/properties/forms/AttributesConsumedListForm.vue'; import ItemsConsumedListForm from '/imports/ui/properties/forms/ItemsConsumedListForm.vue'; - import ItemConsumedSchema from '/imports/api/properties/subSchemas/ItemConsumedSchema.js'; - import AttributeConsumedSchema from '/imports/api/properties/subSchemas/AttributeConsumedSchema.js'; import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js'; export default { @@ -86,7 +90,7 @@ this.addResourceLoading = true; this.$emit('push', { path: ['attributesConsumed'], - value: AttributeConsumedSchema.clean({}), + value: {_id: Random.id()}, ack: this.acknowledgeAddResult, }); }, @@ -94,7 +98,7 @@ this.addResourceLoading = true; this.$emit('push', { path: ['itemsConsumed'], - value: ItemConsumedSchema.clean({}), + value: {_id: Random.id()}, ack: this.acknowledgeAddResult, }); }, diff --git a/app/imports/ui/properties/forms/RollForm.vue b/app/imports/ui/properties/forms/RollForm.vue index 3d575a6b..c0ef80df 100644 --- a/app/imports/ui/properties/forms/RollForm.vue +++ b/app/imports/ui/properties/forms/RollForm.vue @@ -1,28 +1,38 @@ diff --git a/app/imports/ui/properties/treeNodeViews/EffectTreeNode.vue b/app/imports/ui/properties/treeNodeViews/EffectTreeNode.vue index a3d73f7a..60d22b14 100644 --- a/app/imports/ui/properties/treeNodeViews/EffectTreeNode.vue +++ b/app/imports/ui/properties/treeNodeViews/EffectTreeNode.vue @@ -14,15 +14,21 @@ + @@ -37,7 +43,8 @@ export default { mixins: [treeNodeViewMixin], computed: { resolvedValue(){ - return this.model.result !== undefined ? this.model.result : this.model.calculation; + return (this.model.amount && this.model.amount.value) !== undefined ? + this.model.amount.value : this.model.amount && this.model.amount.calculation; }, effectIcon(){ let value = this.resolvedValue; @@ -46,19 +53,32 @@ export default { displayedValue(){ let value = this.resolvedValue; switch(this.model.operation) { - case 'base': return value; - case 'add': return isFinite(value) ? Math.abs(value) : value; + case 'base': return value || 0; + case 'add': return isFinite(value) ? Math.abs(value) : value || 0; case 'mul': return value; case 'min': return value; case 'max': return value; case 'advantage': return; case 'disadvantage': return; - case 'passiveAdd': return isFinite(value) ? Math.abs(value) : value; + case 'passiveAdd': return isFinite(value) ? Math.abs(value) : value || 0; case 'fail': return; case 'conditional': return; default: return undefined; } }, + displayedStats(){ + if (!this.model.stats) return 'Selected stats'; + return this.model.stats.join(', '); + }, + displayedTags(){ + if (!this.model.targetTags) return 'Selected tags'; + const tags = this.model.targetTags.join(', '); + if (!this.model.extraTags) return tags; + const extraTags = this.model.extraTags.map(ex => { + return ` ${ex.operation} ${ex.tags.join(', ')}` + }).join(' '); + return tags + extraTags; + } } } diff --git a/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js b/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js index 3ce03502..26d52ca0 100644 --- a/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js +++ b/app/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js @@ -1,5 +1,6 @@ import DefaultTreeNode from '/imports/ui/properties/treeNodeViews/DefaultTreeNode.vue'; import AdjustmentTreeNode from '/imports/ui/properties/treeNodeViews/AdjustmentTreeNode.vue'; +import BranchTreeNode from '/imports/ui/properties/treeNodeViews/BranchTreeNode.vue'; import ItemTreeNode from '/imports/ui/properties/treeNodeViews/ItemTreeNode.vue'; import DamageTreeNode from '/imports/ui/properties/treeNodeViews/DamageTreeNode.vue'; import EffectTreeNode from '/imports/ui/properties/treeNodeViews/EffectTreeNode.vue'; @@ -10,6 +11,7 @@ import ReferenceTreeNode from '/imports/ui/properties/treeNodeViews/ReferenceTre export default { default: DefaultTreeNode, adjustment: AdjustmentTreeNode, + branch: BranchTreeNode, classLevel: ClassLevelTreeNode, damage: DamageTreeNode, effect: EffectTreeNode, diff --git a/app/imports/ui/properties/viewers/ActionViewer.vue b/app/imports/ui/properties/viewers/ActionViewer.vue index 4c1e45ef..4a8ef728 100644 --- a/app/imports/ui/properties/viewers/ActionViewer.vue +++ b/app/imports/ui/properties/viewers/ActionViewer.vue @@ -1,128 +1,116 @@ - - diff --git a/app/imports/ui/properties/viewers/AttributeViewer.vue b/app/imports/ui/properties/viewers/AttributeViewer.vue index 7119d389..b1225c8c 100644 --- a/app/imports/ui/properties/viewers/AttributeViewer.vue +++ b/app/imports/ui/properties/viewers/AttributeViewer.vue @@ -1,111 +1,194 @@ diff --git a/app/imports/ui/properties/viewers/BranchViewer.vue b/app/imports/ui/properties/viewers/BranchViewer.vue new file mode 100644 index 00000000..bc5c64c2 --- /dev/null +++ b/app/imports/ui/properties/viewers/BranchViewer.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/app/imports/ui/properties/viewers/BuffViewer.vue b/app/imports/ui/properties/viewers/BuffViewer.vue index 613b8464..6730c4b6 100644 --- a/app/imports/ui/properties/viewers/BuffViewer.vue +++ b/app/imports/ui/properties/viewers/BuffViewer.vue @@ -1,15 +1,22 @@ diff --git a/app/imports/ui/properties/viewers/ClassLevelViewer.vue b/app/imports/ui/properties/viewers/ClassLevelViewer.vue index 6d8f9c02..27dca3ca 100644 --- a/app/imports/ui/properties/viewers/ClassLevelViewer.vue +++ b/app/imports/ui/properties/viewers/ClassLevelViewer.vue @@ -1,18 +1,25 @@ @@ -21,6 +28,11 @@ import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyV export default { mixins: [propertyViewerMixin], + inject: { + context: { + default: {}, + }, + }, } diff --git a/app/imports/ui/properties/viewers/ConstantViewer.vue b/app/imports/ui/properties/viewers/ConstantViewer.vue index c6e62cb0..e38839d8 100644 --- a/app/imports/ui/properties/viewers/ConstantViewer.vue +++ b/app/imports/ui/properties/viewers/ConstantViewer.vue @@ -1,23 +1,23 @@ diff --git a/app/imports/ui/properties/viewers/ContainerViewer.vue b/app/imports/ui/properties/viewers/ContainerViewer.vue index 90138387..d66f9f08 100644 --- a/app/imports/ui/properties/viewers/ContainerViewer.vue +++ b/app/imports/ui/properties/viewers/ContainerViewer.vue @@ -1,107 +1,107 @@ diff --git a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue index 9f4bafe8..70b746bf 100644 --- a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue +++ b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue @@ -1,9 +1,15 @@ diff --git a/app/imports/ui/properties/viewers/DamageViewer.vue b/app/imports/ui/properties/viewers/DamageViewer.vue index 3bb69284..524964ab 100644 --- a/app/imports/ui/properties/viewers/DamageViewer.vue +++ b/app/imports/ui/properties/viewers/DamageViewer.vue @@ -1,9 +1,22 @@ @@ -12,6 +25,12 @@ export default { mixins: [propertyViewerMixin], + computed: { + type(){ + if (this.model.damageType === 'healing') return this.model.damageType; + return `${this.model.damageType} damage` + }, + } } diff --git a/app/imports/ui/properties/viewers/EffectViewer.vue b/app/imports/ui/properties/viewers/EffectViewer.vue index 0730ca71..dadcde7f 100644 --- a/app/imports/ui/properties/viewers/EffectViewer.vue +++ b/app/imports/ui/properties/viewers/EffectViewer.vue @@ -1,38 +1,73 @@ diff --git a/app/imports/ui/properties/viewers/SavingThrowViewer.vue b/app/imports/ui/properties/viewers/SavingThrowViewer.vue index 8bd22491..02b4fbcb 100644 --- a/app/imports/ui/properties/viewers/SavingThrowViewer.vue +++ b/app/imports/ui/properties/viewers/SavingThrowViewer.vue @@ -1,14 +1,23 @@ diff --git a/app/imports/ui/properties/viewers/SkillViewer.vue b/app/imports/ui/properties/viewers/SkillViewer.vue index df8be16a..c3e0c580 100644 --- a/app/imports/ui/properties/viewers/SkillViewer.vue +++ b/app/imports/ui/properties/viewers/SkillViewer.vue @@ -1,84 +1,117 @@ @@ -100,6 +133,25 @@ export default { inject: { context: { default: {} } }, + data(){return { + proficiencyText: { + 0: 'Not proficient', + 1: 'Proficient', + 0.49: 'Half proficiency bonus rounded down', + 0.5: 'Half proficiency bonus rounded up', + 2: 'Double proficiency bonus', + }, + skillTypes: { + skill: 'Skill', + save: 'Save', + check: 'Check', + tool: 'Tool', + weapon: 'Weapon', + armor: 'Armor', + language: 'Language', + utility: 'Utility', + }, + }}, computed: { displayedModifier(){ let mod = this.model.value; @@ -139,23 +191,23 @@ export default { name: 'Skill base value', operation: 'base', calculation: prop.baseValueCalculation, - result: prop.baseValue, + amount: {value: prop.baseValue?.value}, stats: [prop.variableName], ancestors: prop.ancestors, - }) ).filter(effect => effect.result); + }) ).filter(effect => effect.amount?.value); } else { return []; } }, effects(){ - if (this.context.creatureId){ + if (this.context.creatureId && this.model.variableName){ let creatureId = this.context.creatureId; return CreatureProperties.find({ 'ancestors.id': creatureId, stats: this.model.variableName, type: 'effect', removed: {$ne: true}, - }); + }).fetch(); } else { return []; } @@ -189,7 +241,7 @@ export default { type: 'proficiency', removed: {$ne: true}, inactive: {$ne: true}, - }); + }).fetch(); } else { return []; } @@ -211,7 +263,7 @@ export default { _id: abilityProp._id, name: abilityProp.name, operation: 'base', - result: abilityProp.modifier, + amount: {value: abilityProp.modifier}, stats: [this.model.variableName], ancestors: abilityProp.ancestors, } @@ -222,7 +274,7 @@ export default { let creature = Creatures.findOne(creatureId) return creature && creature.variables.proficiencyBonus && - creature.variables.proficiencyBonus.currentValue; + creature.variables.proficiencyBonus.value; }, }, } diff --git a/app/imports/ui/properties/viewers/SlotFillerViewer.vue b/app/imports/ui/properties/viewers/SlotFillerViewer.vue index 1da985d0..0834ff2b 100644 --- a/app/imports/ui/properties/viewers/SlotFillerViewer.vue +++ b/app/imports/ui/properties/viewers/SlotFillerViewer.vue @@ -1,37 +1,55 @@ diff --git a/app/imports/ui/properties/viewers/SlotViewer.vue b/app/imports/ui/properties/viewers/SlotViewer.vue index 7dbb3795..bdd07cd5 100644 --- a/app/imports/ui/properties/viewers/SlotViewer.vue +++ b/app/imports/ui/properties/viewers/SlotViewer.vue @@ -1,29 +1,45 @@