Refactored computations again, split into multiple files, lots still to do

This commit is contained in:
Stefan Zermatten
2020-03-17 16:13:18 +02:00
parent 1a0c2bca78
commit 74fef2bd39
20 changed files with 636 additions and 640 deletions

View File

@@ -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';

View File

@@ -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,
};
},
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
});
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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}
});
});
}
}

View File

@@ -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}
});
});
}
}

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
const RESERVED_VARIABLE_NAMES = Object.freeze([
'allChecks',
'allSaves',
'attackRolls',
]);
export default RESERVED_VARIABLE_NAMES;

View File

@@ -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: {

View File

@@ -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";