From 74fef2bd396c12fb7085b7a16c856a7a3e83e84f Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 17 Mar 2020 16:13:18 +0200 Subject: [PATCH] Refactored computations again, split into multiple files, lots still to do --- .../api/creature/CreatureProperties.js | 2 +- .../creature/computation/ComputationMemo.js | 148 +++++ .../creature/computation/EffectAggregator.js | 64 ++ .../api/creature/computation/combineStat.js | 69 ++ .../api/creature/computation/computeEffect.js | 15 + .../api/creature/computation/computeMemo.js | 12 + .../api/creature/computation/computeStat.js | 29 + .../computedValueOfVariableName.js | 10 + .../computation/creatureComputation.js | 76 +++ .../computation/evaluateCalculation.js | 30 + .../computation/getCalculationProperties.js | 40 ++ .../creature/computation/logAlterations.js | 16 + .../api/creature/computation/writeCreature.js | 98 +++ .../api/creature/creatureComputation.js | 623 ------------------ .../creature/mixins/recomputeCreatureMixin.js | 2 +- app/imports/api/properties/Skills.js | 24 +- app/imports/api/properties/SpellLists.js | 7 - .../constants/RESERVED_VARIABLE_NAMES.js | 7 + .../ui/creature/character/CharacterSheet.vue | 2 +- app/server/main.js | 2 +- 20 files changed, 636 insertions(+), 640 deletions(-) create mode 100644 app/imports/api/creature/computation/ComputationMemo.js create mode 100644 app/imports/api/creature/computation/EffectAggregator.js create mode 100644 app/imports/api/creature/computation/combineStat.js create mode 100644 app/imports/api/creature/computation/computeEffect.js create mode 100644 app/imports/api/creature/computation/computeMemo.js create mode 100644 app/imports/api/creature/computation/computeStat.js create mode 100644 app/imports/api/creature/computation/computedValueOfVariableName.js create mode 100644 app/imports/api/creature/computation/creatureComputation.js create mode 100644 app/imports/api/creature/computation/evaluateCalculation.js create mode 100644 app/imports/api/creature/computation/getCalculationProperties.js create mode 100644 app/imports/api/creature/computation/logAlterations.js create mode 100644 app/imports/api/creature/computation/writeCreature.js delete mode 100644 app/imports/api/creature/creatureComputation.js create mode 100644 app/imports/constants/RESERVED_VARIABLE_NAMES.js diff --git a/app/imports/api/creature/CreatureProperties.js b/app/imports/api/creature/CreatureProperties.js index b1b4679c..626c5e9d 100644 --- a/app/imports/api/creature/CreatureProperties.js +++ b/app/imports/api/creature/CreatureProperties.js @@ -1,6 +1,6 @@ import SimpleSchema from 'simpl-schema'; import ChildSchema, { RefSchema } from '/imports/api/parenting/ChildSchema.js'; -import { recomputeCreature } from '/imports/api/creature/creatureComputation.js'; +import { recomputeCreature } from '/imports/api/creature/computation/creatureComputation.js'; import LibraryNodes from '/imports/api/library/LibraryNodes.js'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js'; import { softRemove } from '/imports/api/parenting/softRemove.js'; diff --git a/app/imports/api/creature/computation/ComputationMemo.js b/app/imports/api/creature/computation/ComputationMemo.js new file mode 100644 index 00000000..3f55ee45 --- /dev/null +++ b/app/imports/api/creature/computation/ComputationMemo.js @@ -0,0 +1,148 @@ +import { includes, cloneDeep } from 'lodash'; + +export default class ComputationMemo { + constructor(props){ + this.statsByVariableName = {}; + this.originalPropsById = {}; + this.propsById = {}; + this.skillsByAbility = {}; + this.unassignedEffects = []; + props.filter((prop) => { + // skip effects and proficiencies for the next pass + if (prop.type === 'effect' || prop.type === 'proficiency') return true; + // Add all the stats + this.addStat(prop); + }).forEach((prop) => { + // Now add all effects and proficiencies + if (prop.type === 'effect'){ + this.addEffect(prop); + } else if (prop.type === 'proficiency') { + this.addProficiency(prop); + } + }); + } + registerProperty(prop){ + this.originalPropsById[prop._id] = cloneDeep(prop); + this.propsById[prop._id] = prop; + prop.computationDetails = propDetails(prop); + return prop; + } + addStat(prop){ + prop = this.registerProperty(prop); + let variableName = prop.variableName; + if (!variableName) return; + if (this.statsByVariableName[variableName]){ + prop.value = NaN; + prop.computationDetails.error = 'variableNameCollision'; + return; + } + this.statsByVariableName[variableName] = prop; + if ( + prop.type === 'skill' && + includes(['skill', 'check'], prop.skillType) && + 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 => { + target.computationDetails.effects.push(prop); + }); + if (!targets.length){ + this.unassignedEffects.push(prop); + } + } + getEffectTargets(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 (isSkillOperation(prop) && isAbility(target)){ + let extras = this.skillsByAbility[statName] || []; + targets.add(...extras) + } + }); + return targets; + } + addProficiency(prop){ + prop = this.registerProperty(prop); + let targets = getProficiencyTargets(prop); + targets.forEach(target => { + target.computationDetails.proficiencies.push(prop); + }); + } + getProficiencyTargets(prop){ + let targets = new Set(); + if (!prop.stats) return targets; + proficiency.stats.forEach(statName => { + let target = this.statsByVariableName[statName]; + if (!target) return; + targets.add(target); + if (isAbility(target) && isSkillCheck(prop)) { + let extras = this.skillsByAbility[statName] || []; + targets.add(...extras) + } + }); + return targets; + } +} + +const skillOperations = [ + 'advantage', + 'disadvantage', + 'passiveAdd', + 'fail', + 'conditional', + 'rollBonus', +]; + +function isAbility(prop){ + return prop.type === 'attribute' && + prop.attributeType === 'ability' +} + +function isSkillCheck(prop){ + return includes(['skill', 'check'], prop.skillType); +} + +function isSkillOperation(prop){ + return skillOperations.includes(prop.operation); +} + +function propDetails(prop){ + return propDetailsByType[prop.type]() || {}; +} + +const propDetailsByType = { + attribute(){ + return { + computed: false, + busyComputing: false, + effects: [], + }; + }, + skill(){ + return { + computed: false, + busyComputing: false, + effects: [], + proficiencies: [], + }; + }, + effect(){ + return { + computed: false, + }; + }, +} diff --git a/app/imports/api/creature/computation/EffectAggregator.js b/app/imports/api/creature/computation/EffectAggregator.js new file mode 100644 index 00000000..7dadd06e --- /dev/null +++ b/app/imports/api/creature/computation/EffectAggregator.js @@ -0,0 +1,64 @@ +export default class EffectAggregator{ + constructor(stat){ + this.base = stat.baseValue || 0; + this.add = 0; + this.mul = 1; + this.min = Number.NEGATIVE_INFINITY; + this.max = Number.POSITIVE_INFINITY; + this.advantage = 0; + this.disadvantage = 0; + this.passiveAdd = 0; + this.fail = 0; + this.conditional = []; + this.rollBonus = []; + } + addEffect(effect){ + let result = effect.result; + switch(effect.operation){ + case "base": + // Take the largest base value + this.base = result > this.base ? result : this.base; + 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 "advantage": + // Sum number of advantages + this.advantage++; + break; + case "disadvantage": + // Sum number of disadvantages + this.disadvantage++; + break; + case "passiveAdd": + // Add all passive adds together + 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/combineStat.js b/app/imports/api/creature/computation/combineStat.js new file mode 100644 index 00000000..e56b7197 --- /dev/null +++ b/app/imports/api/creature/computation/combineStat.js @@ -0,0 +1,69 @@ +import computeStat from '/imports/api/creature/computation/computeStat.js'; +import computedValueOfVariableName from '/imports/api/creature/computation/computedValueOfVariableName.js' + + +export default function combineStat(stat, aggregator, memo){ + if (stat.type === "attribute"){ + combineAttribute(stat, aggregator); + } else if (stat.type === "skill"){ + combineSkill(stat, aggregator, memo); + } else if (stat.type === "damageMultiplier"){ + combineDamageMultiplier(stat, memo); + } +} + +function combineAttribute(stat, aggregator){ + let result = (aggregator.base + aggregator.add) * aggregator.mul; + if (result < aggregator.min) result = aggregator.min; + if (result > aggregator.max) result = aggregator.max; + if (!stat.decimal) result = Math.floor(result); + stat.value = result; + if (stat.attributeType === "ability") { + stat.mod = Math.floor((result - 10) / 2); + } +} + +function combineSkill(stat, aggregator, memo){ + // Skills are based on some ability Modifier + let abilityMod = 0; + let ability = memo.statsByVariableName[stat.ability] + if (stat.ability && ability){ + if (!ability.computationDetails.computed){ + computeStat(ability, memo); + } + stat.abilityMod = ability.mod; + } + // Combine all the child proficiencies + for (let i in stat.proficiencies){ + let prof = stat.proficiencies[i]; + if (prof.value > stat.proficiency) stat.proficiency = prof.value; + } + // Get the character's proficiency bonus to apply + let profBonus = computedValueOfVariableName('proficiencyBonus', memo); + /** TODO level needs to be on the memo somewhere + if (typeof profBonus !== "number"){ + profBonus = Math.floor(char.level / 4 + 1.75); + } + */ + // Multiply the proficiency bonus by the actual proficiency + profBonus *= stat.proficiency; + // Combine everything to get the final result + let result = (abilityMod + profBonus + stat.add) * stat.mul; + if (result < stat.min) result = stat.min; + if (result > stat.max) result = stat.max; + result = Math.floor(result); + if (stat.base > result) result = stat.base; + stat.value = result; +} + +function combineDamageMultiplier(stat){ + if (stat.immunityCount) return 0; + 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/computeEffect.js b/app/imports/api/creature/computation/computeEffect.js new file mode 100644 index 00000000..455bb65d --- /dev/null +++ b/app/imports/api/creature/computation/computeEffect.js @@ -0,0 +1,15 @@ +import evaluateCalculation from '/imports/api/creature/computation/evaluateCalculation.js'; + +export default function computeEffect(effect, memo){ + if (effect.computed) return; + if (_.isFinite(effect.calculation)){ + effect.result = +effect.calculation; + } else if(effect.operation === "conditional" || effect.operation === "rollBonuses"){ + effect.result = effect.calculation; + } else if(_.contains(["advantage", "disadvantage", "fail"], effect.operation)){ + effect.result = 1; + } else { + effect.result = evaluateCalculation(effect.calculation, memo); + } + effect.computationDetails.computed = true; +} diff --git a/app/imports/api/creature/computation/computeMemo.js b/app/imports/api/creature/computation/computeMemo.js new file mode 100644 index 00000000..2a22dfaa --- /dev/null +++ b/app/imports/api/creature/computation/computeMemo.js @@ -0,0 +1,12 @@ +import { each, forOwn } from 'lodash'; +import computeStat from '/imports/api/creature/computation/computeStat.js'; +import computeEffect from '/imports/api/creature/computation/computeEffect.js'; + +export default function computeMemo(memo){ + forOwn(memo.statsByVariableName, (stat) => { + computeStat (stat, memo); + }); + each(memo.unassignedEffects, (effect) => { + computeEffect(effect, memo); + }); +} diff --git a/app/imports/api/creature/computation/computeStat.js b/app/imports/api/creature/computation/computeStat.js new file mode 100644 index 00000000..0a8f58d6 --- /dev/null +++ b/app/imports/api/creature/computation/computeStat.js @@ -0,0 +1,29 @@ +import combineStat from '/imports/api/creature/computation/combineStat.js'; +import computeEffect from '/imports/api/creature/computation/computeEffect.js'; +import EffectAggregator from '/imports/api/creature/computation/EffectAggregator.js'; +import { each } 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'; + return; + } + // Compute and aggregate all the effects + let aggregator = new EffectAggregator(stat) + each(stat.computationDetails.effects, (effect) => { + computeEffect(effect, memo); + aggregator.addEffect(effect); + }); + // Conglomerate all the effects to compute the final stat values + combineStat(stat, aggregator, memo); + // Mark the attribute as computed + stat.computationDetails.computed = true; + stat.computationDetails.busyComputing = false; +} diff --git a/app/imports/api/creature/computation/computedValueOfVariableName.js b/app/imports/api/creature/computation/computedValueOfVariableName.js new file mode 100644 index 00000000..78e2887b --- /dev/null +++ b/app/imports/api/creature/computation/computedValueOfVariableName.js @@ -0,0 +1,10 @@ +import computeStat from '/imports/api/creature/computation/computeStat.js'; + +export default function computedValueOfVariableName(sub, memo){ + const stat = memo.statsByVariableName[sub]; + if (!stat) return null; + if (!stat.computationDetails.computed){ + computeStat(stat, char); + } + return stat.result; +} diff --git a/app/imports/api/creature/computation/creatureComputation.js b/app/imports/api/creature/computation/creatureComputation.js new file mode 100644 index 00000000..33837fb1 --- /dev/null +++ b/app/imports/api/creature/computation/creatureComputation.js @@ -0,0 +1,76 @@ +// TODO allow abilities to get advantage/disadvantage, making all skills that are based +// on them disadvantaged as well + +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import SimpleSchema from 'simpl-schema'; +import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js'; +import ComputationMemo from '/imports/api/creature/computation/ComputationMemo.js'; +import computeMemo from '/imports/api/creature/computation/computeMemo.js'; +import getCalculationProperties from '/imports/api/creature/computation/getCalculationProperties.js'; +import logAlterations from '/imports/api/creature/computation/logAlterations.js'; +import * as math from 'mathjs'; + +export const recomputeCreature = new ValidatedMethod({ + + name: "Creatures.methods.recomputeCreature", + + validate: new SimpleSchema({ + charId: { type: String } + }).validator(), + + run({charId}) { + // Permission + assertEditPermission(charId, 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); + }, + +}); + +/** + * 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 recomputeCreatureById(creatureId){ + let props = getCalculationProperties(creatureId); + let computationMemo = new ComputationMemo(props); + console.log({toCompute: computationMemo}); + computeMemo(computationMemo); + console.log({computed: computationMemo}); + logAlterations(computationMemo); + //writeAlteredProps(computationMemo); + return computationMemo; +} diff --git a/app/imports/api/creature/computation/evaluateCalculation.js b/app/imports/api/creature/computation/evaluateCalculation.js new file mode 100644 index 00000000..110b5fbc --- /dev/null +++ b/app/imports/api/creature/computation/evaluateCalculation.js @@ -0,0 +1,30 @@ +import computedValueOfVariableName from '/imports/api/creature/computation/computedValueOfVariableName.js' + +export default function evaluateCalculation(string, memo){ + if (!string) return string; + // Parse the string using mathjs + let calc; + try { + calc = math.parse(string); + } catch (e) { + return string; + } + // Replace all symbols with known values + let substitutedCalc = calc.transform(node => { + if (node.isSymbolNode) { + let val = computedValueOfVariableName(node.name, memo); + if (val === null) return node; + return new math.expression.node.ConstantNode(val); + } + else { + return node; + } + }); + + // Evaluate the expression to a number or return with substitutions + try { + return substitutedCalc.eval(); + } catch (e){ + return substitutedCalc.toString(); + } +} diff --git a/app/imports/api/creature/computation/getCalculationProperties.js b/app/imports/api/creature/computation/getCalculationProperties.js new file mode 100644 index 00000000..2922155f --- /dev/null +++ b/app/imports/api/creature/computation/getCalculationProperties.js @@ -0,0 +1,40 @@ +import Creatures from "/imports/api/creature/Creatures.js"; +import CreatureProperties from "/imports/api/creature/CreatureProperties.js"; + +export default function getCalculationProperties(creatureId){ + // First get ids of disabled properties and unequiped items + let disabledAncestorIds = CreatureProperties.find({ + 'ancestors.id': creatureId, + $or: [ + {disabled: true}, + {equipped: false}, + ], + }, { + fields: {_id: 1}, + }).map(prop => prop._id); + + // Then get the ids of creatures that are children of this creature + // to isolate their decendent properties from this calculation + Creatures.find({ + 'ancestors.id': creatureId, + }, { + fields: {_id: 1}, + }).forEach(prop => { + disabledAncestorIds.push(prop._id); + }); + + // Get all the properties that aren't from the excluded decendents + return CreatureProperties.find({ + 'ancestors.id': { + $eq: creatureId, + $nin: disabledAncestorIds, + }, + type: {$in: [ + 'attribute', + 'skill', + 'damageMultiplier', + 'effect', + 'proficiency', + ]}, + }).fetch(); +} diff --git a/app/imports/api/creature/computation/logAlterations.js b/app/imports/api/creature/computation/logAlterations.js new file mode 100644 index 00000000..86d482ef --- /dev/null +++ b/app/imports/api/creature/computation/logAlterations.js @@ -0,0 +1,16 @@ +import { isEqual, forOwn } from 'lodash'; +import { ComputedOnlySkilLSchema } from '/imports/api/properties/Skills.js'; + +export default function logAlterations(memo){ + forOwn(memo.originalPropsById, old => { + let changed = memo.propsById[old._id]; + delete changed.computationDetails; + + if (!isEqual(old, changed)){ + console.log({change: {old, changed}}) + } + }); +} + +// TODO use this as a starting point to write only computed fields that have +// changed diff --git a/app/imports/api/creature/computation/writeCreature.js b/app/imports/api/creature/computation/writeCreature.js new file mode 100644 index 00000000..d5c4febe --- /dev/null +++ b/app/imports/api/creature/computation/writeCreature.js @@ -0,0 +1,98 @@ + +function writeCreature(char) { + //TODO these functions don't filter the stats before trying to write + writeAttributes(char); + writeSkills(char); + writeDamageMultipliers(char); + writeEffects(char); +} + +/* + * Write all the attributes from the in-memory char object to the Attirbute docs + */ +function writeAttributes(char) { + let bulkWriteOps = _.map(char.atts, (att, variableName) => { + let op = { + updateMany: { + filter: {'ancestors.id': char.id, variableName}, + update: {'$set': { + value: att.result, + rollBonuses: skill.rollBonus, + }}, + } + }; + if (typeof att.mod === 'number'){ + op.updateMany.update.$set.mod = att.mod; + } else { + op.updateMany.update.$unset = {mod: 1}; + } + return op; + }); + bulkWriteProperties({bulkWriteOps, selectorType: 'attribute'}); +} + +function writeSkills(char) { + let bulkWriteOps = _.map(char.skills, (skill, variableName) => { + let op = { + updateMany: { + filter: {'ancestors.id': char.id, variableName}, + update: {$set: { + value: skill.result, + abilityMod: skill.abilityMod, + advantage: skill.advantage, + passiveBonus: skill.passiveAdd, + proficiency: skill.proficiency, + conditionalBenefits: skill.conditional, + rollBonuses: skill.rollBonus, + fail: skill.fail, + }}, + } + }; + return op; + }); + bulkWriteProperties({bulkWriteOps, selectorType: 'skill'}); +} + +function writeDamageMultipliers(char) { + let bulkWriteOps = _.map(char.dms, (dm, variableName) => { + let op = { + updateMany: { + filter: {'ancestors.id': char.id, variableName}, + update: {$set: { + value: dm.result, + }}, + } + }; + return op; + }); + bulkWriteProperties({bulkWriteOps, selectorType: 'damageMultiplier'}); +} + +function writeEffects(char){ + let bulkWriteOps = _.map(char.computedEffects, effect => ({ + updateOne: { + filter: {_id: effect._id}, + update: {$set: { + result: effect.result, + }}, + }, + })); + if (!bulkWriteOps.length) return; + bulkWriteProperties({bulkWriteOps, selectorType: 'effect'}); +} + +function bulkWriteProperties({bulkWriteOps, selectorType}){ + if (!bulkWriteOps.length) return; + if (Meteor.isServer){ + CreatureProperties.rawCollection().bulkWrite(bulkWriteOps, {ordered : false}, function(e){ + if (e) console.error(e); + }); + } else { + _.each(bulkWriteOps, op => { + CreatureProperties.update(op.updateMany.filter, op.updateMany.update, { + multi: true, + selector: {type: selectorType} + }); + }); + } +} diff --git a/app/imports/api/creature/creatureComputation.js b/app/imports/api/creature/creatureComputation.js deleted file mode 100644 index 1cc90667..00000000 --- a/app/imports/api/creature/creatureComputation.js +++ /dev/null @@ -1,623 +0,0 @@ -// TODO allow abilities to get advantage/disadvantage, making all skills that are based -// on them disadvantaged as well - -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import SimpleSchema from 'simpl-schema'; -import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js'; -import Creatures from "/imports/api/creature/Creatures.js"; -import CreatureProperties from "/imports/api/creature/CreatureProperties.js"; -import * as math from 'mathjs'; -import { includes } from 'lodash'; - -export const recomputeCreature = new ValidatedMethod({ - - name: "Creatures.methods.recomputeCreature", - - validate: new SimpleSchema({ - charId: { type: String } - }).validator(), - - run({charId}) { - // Permission - assertEditPermission(charId, 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); - }, - -}); - -/** - * 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 recomputeCreatureById(creatureId){ - let char = buildCreature(creatureId); - char = computeCreature(char); - writeCreature(char); - return char; -} - -// Load creature details into memory from database -function buildCreature(creatureId){ - let char = { - id: creatureId, - stats: {}, - skillsByAbility: {}, - unassignedEffects: [], - computedEffects: [], - }; - // Get all relevant properties and store them on the in-memory creature - getCreatureProps(creatureId).filter(prop => { - const key = prop.variableName; - switch(prop.type){ - case 'attribute': - case 'skill': - let skill = statObject(prop); - // Store the skill as a stat on the in-memory creature - if (!char.stats[key]) char.stats[key] = skill; - // Store a reference to the skill under the relevant ability as well - if (skill.ability){ - if (!char.skillsByAbility[skill.ability]){ - char.skillsByAbility[skill.ability] = []; - } - char.skillsByAbility[skill.ability].push(skill); - } - return false; - case 'damageMultiplier': - if (!char.stats[key]) char.stats[key] = damageMultiplierObject(); - return false; - default: - return true; - } - }).forEach(prop => { - // In a second pass through the props, assign effects and proficiencies to - // their matched attributes and skills - switch(prop.type){ - case 'effect': - let effect = effectObject(prop); - // Assign the effect to all the stats it directly targets - let targets = getEffectTargets(char, effect); - targets.forEach(target => - target.effects.push(effect) - ); - if (!targets.length){ - char.unassignedEffects.push(effect); - } - break; - case 'proficiency': - let proficiency = proficiencyObject(prop); - getProficiencyTargets(char, proficiency).forEach(target => - target.proficiencies.push(proficiency) - ); - break; - } - }); - // Add direct properties from creature to variable list - const fields = { xp: 1, weightCarried: 1, level: 1}; - const creature = Creatures.findOne(creatureId, {fields}); - for (let key in fields){ - if (!char.stats[key]){ - char.stats[key] = { - computed: true, - type: 'creatureProperty', - result: creature[key] || 0, - }; - } - } - return char; -} - -function getCreatureProps(creatureId){ - // First get ids of disabled properties and unequiped items - let disabledAncestorIds = CreatureProperties.find({ - 'ancestors.id': creatureId, - $or: [ - {disabled: true}, - {equipped: false}, - ], - }, { - fields: {_id: 1}, - }).map(prop => prop._id); - - // Then get the ids of creatures that are children of this creature - // to isolate their decendent properties from this calculation - Creatures.find({ - 'ancestors.id': creatureId, - }, { - fields: {_id: 1}, - }).forEach(prop => { - disabledAncestorIds.push(prop._id); - }); - - // Get all the properties that aren't from the excluded decendents - return CreatureProperties.find({ - 'ancestors.id': { - $eq: creatureId, - $nin: disabledAncestorIds, - }, - type: {$in: [ - 'attribute', - 'skill', - 'damageMultiplier', - 'effect', - 'proficiency', - ]}, - }).fetch(); -} - -function getProficiencyTargets(char, proficiency){ - let extraTargets = []; - if (!proficiency.stats) return []; - let targets = proficiency.stats.map(targetStat => { - let target = char[targetStat]; - // Proficiencies targeting ability scores apply to 'skill' and check' skills - // based on that ability as well - if ( - target && - target.type === 'attribute' && - target.attributeType === 'ability' && - char.skillsByAbility[targetStat] - ) { - extraTargets.push( - ...char.skillsByAbility[targetStat].filter(skill => - includes(['skill', 'check'], skill.skillType) - ) - ); - } - return target; - }).filter(target => !!target); - return targets.concat(extraTargets); -} - -function getEffectTargets(char, effect){ - const skillOperations = [ - 'advantage', - 'disadvantage', - 'passiveAdd', - 'fail', - 'conditional', - 'rollBonus', - ]; - let extraTargets = []; - if (!effect.stats) return []; - let targets = effect.stats.map(targetStat => { - let target = char[targetStat]; - // Certain effects targeting ability scores apply to skills - // based on that ability as well - if ( - includes(skillOperations, effect.operation) && - target && - target.type === 'attribute' && - target.attributeType === 'ability' && - char.skillsByAbility[targetStat] - ) { - extraTargets.push(...char.skillsByAbility[targetStat]); - } - return target; - }); - return targets.concat(extraTargets); -} - -function statObject(prop){ - return { - computed: false, - busyComputing: false, - type: prop.type, - attributeType: prop.attributeType, - skillType: prop.skillType, - ability: prop.ability, - base: prop.baseValue || 0, - proficiency: prop.baseProficiency || 0, - decimal: prop.decimal, - result: 0, - mod: 0, // The resulting modifier if this is an ability - add: 0, - mul: 1, - min: Number.NEGATIVE_INFINITY, - max: Number.POSITIVE_INFINITY, - advantage: 0, - disadvantage: 0, - passiveAdd: 0, - fail: 0, - conditional: 0, - rollBonuses: 0, - effects: [], - proficiencies: [], - }; -} - -function damageMultiplierObject(){ - return { - computed: false, - busyComputing: false, - type: "damageMultiplier", - result: 0, - immunityCount: 0, - ressistanceCount: 0, - vulnerabilityCount: 0, - effects: [], - }; -}; - -function effectObject(prop){ - return { - _id: prop._id, - computed: false, - result: 0, - operation: prop.operation, - calculation: prop.calculation, - }; -} - -function proficiencyObject(prop){ - return { - value: prop.value, - }; -} - -function computeCreature(char){ - for (let statName in char.stats){ - let stat = char.stats[statName]; - computeStat (stat, char); - } - for (let effect of char.unassignedEffects){ - computeEffect(effect, char); - } - return char; -} - -function computeStat(stat, char){ - - // If the stat is already computed, skip it - if (stat.computed) return; - - // If the stat is busy being computed, make it NaN and mark computed - if (stat.busyComputing){ - // Trying to compute this stat again while it is already computing. - // We must be in a dependency loop. - stat.computed = true; - stat.result = NaN; - stat.busyComputing = false; - return; - } - - // Iterate over each effect which applies to the stat - for (let i in stat.effects){ - computeEffect(stat.effects[i], char); - // apply the effect to the stat - applyEffect(stat.effects[i], stat); - } - - // Conglomerate all the effects to compute the final stat values - combineStat(stat, char); - - // Mark the attribute as computed - stat.computed = true; - stat.busyComputing = false; -} - -/** - * Compute a the result of a single effect - */ -function computeEffect(effect, char){ - if (effect.computed) return; - if (_.isFinite(effect.calculation)){ - effect.result = +effect.calculation; - } else if(effect.operation === "conditional" || effect.operation === "rollBonuses"){ - effect.result = effect.calculation; - } else if(_.contains(["advantage", "disadvantage", "fail"], effect.operation)){ - effect.result = 1; - } else { - effect.result = evaluateCalculation(effect.calculation, char); - } - effect.computed = true; - char.computedEffects.push(effect); -} - -/** - * Apply a computed effect to its stat - */ -function applyEffect(effect, stat){ - if (!_.has(stat, effect.operation)){ - return; - } - switch(effect.operation){ - case "base": - // Take the largest base value - stat.base = effect.result > stat.base ? effect.result : stat.base; - break; - case "add": - // Add all adds together - stat.add += effect.result; - break; - case "mul": - // Multiply the muls together - stat.mul *= effect.result; - break; - case "min": - // Take the largest min value - stat.min = effect.result > stat.min ? effect.result : stat.min; - break; - case "max": - // Take the smallest max value - stat.max = effect.result < stat.max ? effect.result : stat.max; - break; - case "advantage": - // Sum number of advantages - stat.advantage++; - break; - case "disadvantage": - // Sum number of disadvantages - stat.disadvantage++; - break; - case "passiveAdd": - // Add all passive adds together - stat.passiveAdd += effect.result; - break; - case "fail": - // Sum number of fails - stat.fail++; - break; - case "conditional": - // Sum number of conditionals - stat.conditional++; - break; - case "rollBonus": - // Sum number of roll bonuses - stat.rollBonus++; - break; - } -} - -/** - * Combine the results of multiple effects to get the result of the stat - */ -function combineStat(stat, char){ - if (stat.type === "attribute"){ - combineAttribute(stat, char); - } else if (stat.type === "skill"){ - combineSkill(stat, char); - } else if (stat.type === "damageMultiplier"){ - combineDamageMultiplier(stat, char); - } -} - -/** - * combineAttribute - Combine attributes's results into final values - */ -function combineAttribute(stat, char){ - stat.result = (stat.base + stat.add) * stat.mul; - if (stat.result < stat.min) stat.result = stat.min; - if (stat.result > stat.max) stat.result = stat.max; - if (!stat.decimal) stat.result = Math.floor(stat.result); - if (stat.attributeType === "ability") { - stat.mod = Math.floor((stat.result - 10) / 2); - } -} - -/** - * Combine skills results into final values - */ -function combineSkill(stat, char){ - // Skills are based on some ability Modifier - let abilityMod = 0; - let ability = char.stats[stat.ability] - if (stat.ability && ability){ - if (!ability.computed){ - computeStat(ability, char); - } - stat.abilityMod = ability.mod; - } - // Combine all the child proficiencies - for (let i in stat.proficiencies){ - let prof = stat.proficiencies[i]; - if (prof.value > stat.proficiency) stat.proficiency = prof.value; - } - // Get the character's proficiency bonus to apply - let profBonus; - if (char.stats.proficiencyBonus){ - if (!char.stats.proficiencyBonus.computed){ - computeStat(char.stats.proficiencyBonus, char); - } - profBonus = char.stats.proficiencyBonus.result; - } else { - profBonus = Math.floor(char.level / 4 + 1.75); - } - // Multiply the proficiency bonus by the actual proficiency - profBonus *= stat.proficiency; - // Combine everything to get the final result - stat.result = (abilityMod + profBonus + stat.add) * stat.mul; - if (stat.result < stat.min) stat.result = stat.min; - if (stat.result > stat.max) stat.result = stat.max; - stat.result = Math.floor(stat.result); - if (stat.base > stat.result) stat.result = stat.base; -} - -/** - * Combine damageMultiplier's results into final values - */ -function combineDamageMultiplier(stat){ - if (stat.immunityCount) return 0; - if (stat.ressistanceCount && !stat.vulnerabilityCount){ - stat.result = 0.5; - } else if (!stat.ressistanceCount && stat.vulnerabilityCount){ - stat.result = 2; - } else { - stat.result = 1; - } -} - -/** - * Get the value of a key, compute it if necessary - */ -function getComputedValueOfKey(sub, char){ - const stat = char.stats[sub]; - if (!stat) return null; - if (!stat.computed){ - computeStat(stat, char); - } - return stat.result; -} - -/** - * Evaluate a string computation in the context of a char - */ -function evaluateCalculation(string, char){ - if (!string) return string; - // Parse the string using mathjs - let calc; - try { - calc = math.parse(string); - } catch (e) { - return string; - } - // Replace all symbols with known values - let substitutedCalc = calc.transform(node => { - if (node.isSymbolNode) { - let val = getComputedValueOfKey(node.name, char); - if (val === null) return node; - return new math.expression.node.ConstantNode(val); - } - else { - return node; - } - }); - - // Evaluate the expression to a number or return with substitutions - try { - return substitutedCalc.eval(); - } catch (e){ - return substitutedCalc.toString(); - } -} - -function writeCreature(char) { - //TODO these functions don't filter the stats before trying to write - writeAttributes(char); - writeSkills(char); - writeDamageMultipliers(char); - writeEffects(char); -} - -/* - * Write all the attributes from the in-memory char object to the Attirbute docs - */ -function writeAttributes(char) { - let bulkWriteOps = _.map(char.atts, (att, variableName) => { - let op = { - updateMany: { - filter: {'ancestors.id': char.id, variableName}, - update: {'$set': { - value: att.result, - rollBonuses: skill.rollBonus, - }}, - } - }; - if (typeof att.mod === 'number'){ - op.updateMany.update.$set.mod = att.mod; - } else { - op.updateMany.update.$unset = {mod: 1}; - } - return op; - }); - bulkWriteProperties({bulkWriteOps, selectorType: 'attribute'}); -} - -function writeSkills(char) { - let bulkWriteOps = _.map(char.skills, (skill, variableName) => { - let op = { - updateMany: { - filter: {'ancestors.id': char.id, variableName}, - update: {$set: { - value: skill.result, - abilityMod: skill.abilityMod, - advantage: skill.advantage, - passiveBonus: skill.passiveAdd, - proficiency: skill.proficiency, - conditionalBenefits: skill.conditional, - rollBonuses: skill.rollBonus, - fail: skill.fail, - }}, - } - }; - return op; - }); - bulkWriteProperties({bulkWriteOps, selectorType: 'skill'}); -} - -function writeDamageMultipliers(char) { - let bulkWriteOps = _.map(char.dms, (dm, variableName) => { - let op = { - updateMany: { - filter: {'ancestors.id': char.id, variableName}, - update: {$set: { - value: dm.result, - }}, - } - }; - return op; - }); - bulkWriteProperties({bulkWriteOps, selectorType: 'damageMultiplier'}); -} - -function writeEffects(char){ - let bulkWriteOps = _.map(char.computedEffects, effect => ({ - updateOne: { - filter: {_id: effect._id}, - update: {$set: { - result: effect.result, - }}, - }, - })); - if (!bulkWriteOps.length) return; - bulkWriteProperties({bulkWriteOps, selectorType: 'effect'}); -} - -function bulkWriteProperties({bulkWriteOps, selectorType}){ - if (!bulkWriteOps.length) return; - if (Meteor.isServer){ - CreatureProperties.rawCollection().bulkWrite(bulkWriteOps, {ordered : false}, function(e){ - if (e) console.error(e); - }); - } else { - _.each(bulkWriteOps, op => { - CreatureProperties.update(op.updateMany.filter, op.updateMany.update, { - multi: true, - selector: {type: selectorType} - }); - }); - } -} diff --git a/app/imports/api/creature/mixins/recomputeCreatureMixin.js b/app/imports/api/creature/mixins/recomputeCreatureMixin.js index 4878ce09..a69cfc2e 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/creatureComputation.js'; +import { recomputeCreatureById } from '/imports/api/creature/computation/creatureComputation.js'; export default function recomputeCreatureMixin(methodOptions){ let runFunc = methodOptions.run; diff --git a/app/imports/api/properties/Skills.js b/app/imports/api/properties/Skills.js index cd9cb6d5..c68f1d67 100644 --- a/app/imports/api/properties/Skills.js +++ b/app/imports/api/properties/Skills.js @@ -51,8 +51,7 @@ let SkillSchema = new SimpleSchema({ }, }); - -let ComputedSkillSchema = new SimpleSchema({ +let ComputedOnlySkillSchema = new SimpleSchema({ // Computed value of skill to be added to skill rolls value: { type: Number, @@ -80,16 +79,29 @@ let ComputedSkillSchema = new SimpleSchema({ allowedValues: [0, 0.5, 1, 2], defaultValue: 0, }, - // Computed number of total conditional benefits + // Computed number of total conditional benefits conditionalBenefits: { - type: SimpleSchema.Integer, + type: Array, optional: true, + }, + 'conditionalBenefits.$': { + type: String, + }, + // Computed number of things forcing this skill to fail + rollBonuses: { + type: Array, + optional: true, + }, + 'rollBonuses.$': { + type: String, }, // Computed number of things forcing this skill to fail fail: { type: SimpleSchema.Integer, optional: true, }, -}).extend(SkillSchema); +}) -export { SkillSchema, ComputedSkillSchema }; +let ComputedSkillSchema = ComputedOnlySkillSchema.extend(SkillSchema); + +export { SkillSchema, ComputedSkillSchema, ComputedOnlySkillSchema }; diff --git a/app/imports/api/properties/SpellLists.js b/app/imports/api/properties/SpellLists.js index 8c604419..0646296a 100644 --- a/app/imports/api/properties/SpellLists.js +++ b/app/imports/api/properties/SpellLists.js @@ -1,17 +1,10 @@ import SimpleSchema from 'simpl-schema'; -import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; let SpellListSchema = new SimpleSchema({ name: { type: String, optional: true, }, - // The technical, lowercase, single-word name used in formulae - variableName: { - type: String, - regEx: VARIABLE_NAME_REGEX, - min: 3, - }, description: { type: String, optional: true, diff --git a/app/imports/constants/RESERVED_VARIABLE_NAMES.js b/app/imports/constants/RESERVED_VARIABLE_NAMES.js new file mode 100644 index 00000000..4c8f86db --- /dev/null +++ b/app/imports/constants/RESERVED_VARIABLE_NAMES.js @@ -0,0 +1,7 @@ +const RESERVED_VARIABLE_NAMES = Object.freeze([ + 'allChecks', + 'allSaves', + 'attackRolls', +]); + +export default RESERVED_VARIABLE_NAMES; diff --git a/app/imports/ui/creature/character/CharacterSheet.vue b/app/imports/ui/creature/character/CharacterSheet.vue index 826d322b..8dcf5017 100644 --- a/app/imports/ui/creature/character/CharacterSheet.vue +++ b/app/imports/ui/creature/character/CharacterSheet.vue @@ -102,7 +102,7 @@ import SpellsTab from '/imports/ui/creature/character/characterSheetTabs/SpellsTab.vue'; import PersonaTab from '/imports/ui/creature/character/characterSheetTabs/PersonaTab.vue'; import TreeTab from '/imports/ui/creature/character/characterSheetTabs/TreeTab.vue'; - import { recomputeCreature } from '/imports/api/creature/creatureComputation.js'; + import { recomputeCreature } from '/imports/api/creature/computation/creatureComputation.js'; export default { props: { diff --git a/app/server/main.js b/app/server/main.js index 0b532ad5..538329ec 100644 --- a/app/server/main.js +++ b/app/server/main.js @@ -1,5 +1,5 @@ import "/imports/server/publications/index.js"; -import "/imports/api/creature/creatureComputation.js"; +import "/imports/api/creature/computation/creatureComputation.js"; import "/imports/api/parenting/deleteRemovedDocuments.js"; import "/imports/server/config/simpleSchemaDebug.js"; import "/imports/api/parenting/organizeMethods.js";