Merge branch 'master' into version-2-tabletop

This commit is contained in:
Stefan Zermatten
2023-06-27 10:41:44 +02:00
321 changed files with 8950 additions and 6113 deletions

View File

@@ -8,6 +8,8 @@ import { damagePropertyWork } from '/imports/api/creature/creatureProperties/met
import numberToSignedString from '/imports/api/utility/numberToSignedString.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature.js';
import { getPropertyDecendants } from '/imports/api/engine/loadCreatures.js';
import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
export default function applyAction(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
@@ -51,11 +53,11 @@ export default function applyAction(node, actionContext) {
}
function applyAttackWithoutTarget({ attack, actionContext }) {
delete actionContext.scope['$attackHit'];
delete actionContext.scope['$attackMiss'];
delete actionContext.scope['$criticalHit'];
delete actionContext.scope['$criticalMiss'];
delete actionContext.scope['$attackRoll'];
delete actionContext.scope['~attackHit'];
delete actionContext.scope['~attackMiss'];
delete actionContext.scope['~criticalHit'];
delete actionContext.scope['~criticalMiss'];
delete actionContext.scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
const scope = actionContext.scope;
@@ -66,16 +68,16 @@ function applyAttackWithoutTarget({ attack, actionContext }) {
criticalMiss,
} = rollAttack(attack, scope);
let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit';
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
if (!criticalMiss) {
scope['$attackHit'] = { value: true }
scope['~attackHit'] = { value: true }
}
if (!criticalHit) {
scope['$attackMiss'] = { value: true };
scope['~attackMiss'] = { value: true };
}
actionContext.addLog({
@@ -87,12 +89,12 @@ function applyAttackWithoutTarget({ attack, actionContext }) {
function applyAttackToTarget({ attack, target, actionContext }) {
const scope = actionContext.scope;
delete scope['$attackHit'];
delete scope['$attackMiss'];
delete scope['$criticalHit'];
delete scope['$criticalMiss'];
delete scope['$attackDiceRoll'];
delete scope['$attackRoll'];
delete scope['~attackHit'];
delete scope['~attackMiss'];
delete scope['~criticalHit'];
delete scope['~criticalMiss'];
delete scope['~attackDiceRoll'];
delete scope['~attackRoll'];
recalculateCalculation(attack, actionContext);
@@ -109,9 +111,9 @@ function applyAttackToTarget({ attack, target, actionContext }) {
let name = criticalHit ? 'Critical Hit!' :
criticalMiss ? 'Critical Miss!' :
result > armor ? 'Hit!' : 'Miss!';
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
name += ' (Disadvantage)';
}
@@ -121,9 +123,9 @@ function applyAttackToTarget({ attack, target, actionContext }) {
inline: true,
});
if (criticalMiss || result < armor) {
scope['$attackMiss'] = { value: true };
scope['~attackMiss'] = { value: true };
} else {
scope['$attackHit'] = { value: true };
scope['~attackHit'] = { value: true };
}
} else {
actionContext.addLog({
@@ -141,7 +143,7 @@ function applyAttackToTarget({ attack, target, actionContext }) {
function rollAttack(attack, scope) {
const rollModifierText = numberToSignedString(attack.value, true);
let value, resultPrefix;
if (scope['$attackAdvantage'] === 1) {
if (scope['~attackAdvantage']?.value === 1) {
const [a, b] = rollDice(2, 20);
if (a >= b) {
value = a;
@@ -150,7 +152,7 @@ function rollAttack(attack, scope) {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else if (scope['$attackAdvantage'] === -1) {
} else if (scope['~attackAdvantage']?.value === -1) {
const [a, b] = rollDice(2, 20);
if (a <= b) {
value = a;
@@ -163,23 +165,23 @@ function rollAttack(attack, scope) {
value = rollDice(1, 20)[0];
resultPrefix = `1d20 [${value}] ${rollModifierText}`
}
scope['$attackDiceRoll'] = { value };
scope['~attackDiceRoll'] = { value };
const result = value + attack.value;
scope['$attackRoll'] = { result };
scope['~attackRoll'] = { value: result };
const { criticalHit, criticalMiss } = applyCrits(value, scope);
return { resultPrefix, result, value, criticalHit, criticalMiss };
}
function applyCrits(value, scope) {
let criticalHitTarget = scope.criticalHitTarget?.value || 20;
const criticalHitTarget = scope['~criticalHitTarget']?.value || 20;
let criticalHit = value >= criticalHitTarget;
let criticalMiss;
if (criticalHit) {
scope['$criticalHit'] = { value: true };
scope['~criticalHit'] = { value: true };
} else {
criticalMiss = value === 1;
if (criticalMiss) {
scope['$criticalMiss'] = { value: true };
scope['~criticalMiss'] = { value: true };
}
}
return { criticalHit, criticalMiss };
@@ -211,6 +213,7 @@ function spendResources(prop, actionContext) {
let itemQuantityAdjustments = [];
let spendLog = [];
let gainLog = [];
let ammoChildren = [];
try {
prop.resources.itemsConsumed.forEach(itemConsumed => {
recalculateCalculation(itemConsumed.quantity, actionContext);
@@ -221,11 +224,8 @@ function spendResources(prop, actionContext) {
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 ||
!itemConsumed?.quantity?.value ||
!isFinite(itemConsumed.quantity.value)
) return;
itemQuantityAdjustments.push({
@@ -242,12 +242,14 @@ function spendResources(prop, actionContext) {
} else if (itemConsumed.quantity.value < 0) {
gainLog.push(logName + ': ' + -itemConsumed.quantity.value);
}
ammoChildren.push(...getItemChildren(item, actionContext, prop));
});
} catch (e) {
actionContext.addLog({
name: 'Error',
value: e,
value: e.toString(),
});
console.error(e);
return true;
}
// No more errors should be thrown after this line
@@ -303,4 +305,36 @@ function spendResources(prop, actionContext) {
value: spendLog.join('\n'),
inline: true,
});
// Apply the ammo children
ammoChildren.forEach(prop => {
applyProperty(prop, actionContext);
});
}
function getItemChildren(item, actionContext, prop) {
// Skip if the prop or the item are ancestors of one another, otherwise infinite loop
if (hasAncestorRelationship(item, prop)) return [];
// Get the item children
const itemProperties = getPropertyDecendants(actionContext.creature._id, item._id);
// Tree them up
const propertyForest = nodeArrayToTree(itemProperties);
return propertyForest
}
function hasAncestorRelationship(a, b) {
let top, bottom;
if (a.ancestors.length === b.ancestors.length) {
// Can't be ancestors of one another if they have the same number of ancestors
return false;
} else if (a.ancestors.length > b.ancestors.length) {
// longer ancestor list goes on the bottom
top = b;
bottom = a;
} else {
top = a;
bottom = b;
}
const expectedAncestorPosition = top.ancestors.length;
return bottom.ancestors[expectedAncestorPosition]?.id === top._id;
}

View File

@@ -3,22 +3,22 @@ import recalculateCalculation from './shared/recalculateCalculation.js';
import rollDice from '/imports/parser/rollDice.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
export default function applyBranch(node, actionContext){
export default function applyBranch(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const applyChildren = function(){
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
const scope = actionContext.scope;
const targets = actionContext.targets;
const prop = node.node;
switch(prop.branchType){
switch (prop.branchType) {
case 'if':
recalculateCalculation(prop.condition, actionContext);
if (prop.condition?.value) applyChildren();
break;
case 'index':
if (node.children.length){
if (node.children.length) {
recalculateCalculation(prop.condition, actionContext);
if (!isFinite(prop.condition?.value)) {
actionContext.addLog({
@@ -35,31 +35,31 @@ export default function applyBranch(node, actionContext){
}
break;
case 'hit':
if (scope['$attackHit']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On hit**'});
if (scope['~attackHit']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On hit**' });
applyChildren();
}
break;
case 'miss':
if (scope['$attackMiss']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On miss**'});
if (scope['~attackMiss']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On miss**' });
applyChildren();
}
break;
case 'failedSave':
if (scope['$saveFailed']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On failed save**'});
if (scope['~saveFailed']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On failed save**' });
applyChildren();
}
break;
case 'successfulSave':
if (scope['$saveSucceeded']?.value){
if (!targets.length && !prop.silent) actionContext.addLog({value: '**On save**',});
if (scope['~saveSucceeded']?.value) {
if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On save**', });
applyChildren();
}
break;
case 'random':
if (node.children.length){
if (node.children.length) {
let index = rollDice(1, node.children.length)[0] - 1;
applyNodeTriggers(node, 'after', actionContext);
applyProperty(node.children[index], actionContext);

View File

@@ -21,7 +21,10 @@ export default function applyBuff(node, actionContext) {
const prop = node.node;
let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
// Then copy the decendants of the buff to the targets
// Mark the buff as dirty for recalculation
prop.dirty = true;
// Then copy the descendants of the buff to the targets
let propList = [prop];
function addChildrenToPropList(children, { skipCrystalize } = {}) {
children.forEach(child => {
@@ -97,7 +100,7 @@ function copyNodeListToTarget(propList, target, oldParent) {
/**
* Replaces all variables with their resolved values
* except variables of the form `$target.thing.total` become `thing.total`
* except variables of the form `~target.thing.total` become `thing.total`
*/
function crystalizeVariables({ propList, actionContext }) {
propList.forEach(prop => {
@@ -116,8 +119,8 @@ function crystalizeVariables({ propList, actionContext }) {
node.parseType !== 'accessor' && node.parseType !== 'symbol'
) return node;
// Handle variables
if (node.name === '$target') {
// strip $target
if (node.name === '~target') {
// strip ~target
if (node.parseType === 'accessor') {
node.name = node.path.shift();
if (!node.path.length) {
@@ -127,7 +130,7 @@ function crystalizeVariables({ propList, actionContext }) {
// Can't strip symbols
actionContext.addLog({
name: 'Error',
value: 'Variable `$target` should not be used without a property: $target.property',
value: 'Variable `~target` should not be used without a property: ~target.property',
});
}
return node;

View File

@@ -27,7 +27,7 @@ export default function applyDamage(node, actionContext) {
// Choose target
let damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
// Determine if the hit is critical
let criticalHit = scope['$criticalHit']?.value &&
let criticalHit = scope['~criticalHit']?.value &&
prop.damageType !== 'healing' // Can't critically heal
;
// Double the damage rolls if the hit is critical
@@ -73,12 +73,12 @@ export default function applyDamage(node, actionContext) {
damage = Math.floor(damage);
// Convert extra damage into the stored type
if (prop.damageType === 'extra' && scope['$lastDamageType']) {
prop.damageType = scope['$lastDamageType'];
if (prop.damageType === 'extra' && scope['~lastDamageType']?.value) {
prop.damageType = scope['~lastDamageType']?.value;
}
// Store current damage type
if (prop.damageType !== 'healing') {
scope['$lastDamageType'] = prop.damageType;
scope['~lastDamageType'] = { value: prop.damageType };
}
// Memoise the damage suffix for the log
@@ -193,14 +193,18 @@ function dealDamage({ target, damageType, amount, actionContext }) {
let healthBars = getPropertiesOfType(target._id, 'attribute');
// Keep only the healthbars that can take damage/healing
remove(healthBars, (bar) =>
bar.attributeType !== 'healthBar' ||
bar.inactive ||
bar.removed ||
bar.overridden ||
(amount >= 0 && bar.healthBarNoDamage) ||
(amount < 0 && bar.healthBarNoHealing)
);
healthBars = healthBars.filter((bar) => {
if (bar.attributeType !== 'healthBar' || bar.inactive || bar.removed || bar.overridden) {
return false;
}
if (damageType === 'healing' && bar.healthBarNoHealing) {
return false;
}
if (damageType !== 'healing' && amount >= 0 && bar.healthBarNoDamage) {
return false;
}
return true;
});
// Sort healthbars by damage/healing order or tree order as a fallback
healthBars.sort((a, b) => {

View File

@@ -4,22 +4,22 @@ import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/appl
import resolve, { toString } from '/imports/parser/resolve.js';
import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js';
export default function applyRoll(node, actionContext){
export default function applyRoll(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.node;
const applyChildren = function(){
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
if (prop.roll?.calculation){
if (prop.roll?.calculation) {
const logValue = [];
// roll the dice only and store that string
applyEffectsToCalculationParseNode(prop.roll, actionContext);
const {result: rolled, context} = resolve('roll', prop.roll.parseNode, actionContext.scope);
if (rolled.parseType !== 'constant'){
const { result: rolled, context } = resolve('roll', prop.roll.parseNode, actionContext.scope);
if (rolled.parseType !== 'constant') {
logValue.push(toString(rolled));
}
logErrors(context.errors, actionContext);
@@ -28,28 +28,28 @@ export default function applyRoll(node, actionContext){
context.errors = [];
// Resolve the roll to a final value
const {result: reduced} = resolve('reduce', rolled, actionContext.scope, context);
const { result: reduced } = resolve('reduce', rolled, actionContext.scope, context);
logErrors(context.errors, actionContext);
// Store the result
if (reduced.parseType === 'constant'){
if (reduced.parseType === 'constant') {
prop.roll.value = reduced.value;
} else if (reduced.parseType === 'error'){
} else if (reduced.parseType === 'error') {
prop.roll.value = null;
} else {
prop.roll.value = toString(reduced);
}
// If we didn't end up with a constant of finite amount, give up
if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)){
// If we didn't end up with a constant or a number of finite value, give up
if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) {
return applyChildren();
}
const value = reduced.value;
actionContext.scope[prop.variableName] = value;
actionContext.scope[prop.variableName] = { value };
logValue.push(`**${value}**`);
if (!prop.silent){
if (!prop.silent) {
actionContext.addLog({
name: prop.name,
value: logValue.join('\n'),

View File

@@ -8,6 +8,7 @@ import { applyUnresolvedEffects } from '/imports/api/engine/actions/doCheck.js';
export default function applySavingThrow(node, actionContext) {
applyNodeTriggers(node, 'before', actionContext);
const prop = node.node;
const originalTargets = actionContext.targets;
let saveTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets;
@@ -31,22 +32,22 @@ export default function applySavingThrow(node, actionContext) {
// If there are no save targets, apply all children as if the save both
// succeeeded and failed
if (!saveTargets?.length) {
scope['$saveFailed'] = { value: true };
scope['$saveSucceeded'] = { value: true };
scope['~saveFailed'] = { value: true };
scope['~saveSucceeded'] = { value: true };
applyNodeTriggers(node, 'after', actionContext);
return node.children.forEach(child => applyProperty(child, actionContext));
}
// Each target makes the saving throw
saveTargets.forEach(target => {
delete scope['$saveFailed'];
delete scope['$saveSucceeded'];
delete scope['$saveDiceRoll'];
delete scope['$saveRoll'];
delete scope['~saveFailed'];
delete scope['~saveSucceeded'];
delete scope['~saveDiceRoll'];
delete scope['~saveRoll'];
const applyChildren = function () {
applyNodeTriggers(node, 'after', actionContext);
actionContext.targets = [target]
applyNodeTriggers(node, 'after', actionContext);
node.children.forEach(child => applyProperty(child, actionContext));
};
@@ -90,14 +91,14 @@ export default function applySavingThrow(node, actionContext) {
value = values[0];
resultPrefix = `1d20 [ ${value} ] ${rollModifierText}`
}
scope['$saveDiceRoll'] = { value };
scope['~saveDiceRoll'] = { value };
const result = value + rollModifier || 0;
scope['$saveRoll'] = { value: result };
scope['~saveRoll'] = { value: result };
const saveSuccess = result >= dc;
if (saveSuccess) {
scope['$saveSucceeded'] = { value: true };
scope['~saveSucceeded'] = { value: true };
} else {
scope['$saveFailed'] = { value: true };
scope['~saveFailed'] = { value: true };
}
if (!prop.silent) actionContext.addLog({
name: saveSuccess ? 'Successful save' : 'Failed save',
@@ -106,4 +107,6 @@ export default function applySavingThrow(node, actionContext) {
});
return applyChildren();
});
// reset the targets after the save to each child
actionContext.targets = originalTargets;
}

View File

@@ -35,7 +35,7 @@ export function applyTrigger(trigger, prop, actionContext) {
if (trigger.inactive) {
return;
}
// Prevent triggers from firing if their condition is false
if (trigger.condition?.parseNode) {
recalculateCalculation(trigger.condition, actionContext);
@@ -61,11 +61,11 @@ export function applyTrigger(trigger, prop, actionContext) {
value: trigger.description,
inline: false,
}
if (trigger.description?.text){
if (trigger.description?.text) {
recalculateInlineCalculations(trigger.description, actionContext);
content.value = trigger.description.value;
}
if(!trigger.silent) actionContext.addLog(content);
if (!trigger.silent) actionContext.addLog(content);
// Get all the trigger's properties and apply them
const properties = getPropertyDecendants(actionContext.creature._id, trigger._id);
@@ -78,7 +78,7 @@ export function applyTrigger(trigger, prop, actionContext) {
trigger.firing = false;
}
function triggerMatchTags(trigger, prop) {
export function triggerMatchTags(trigger, prop) {
let matched = false;
const propTags = getEffectivePropTags(prop);
// Check the target tags
@@ -89,23 +89,26 @@ function triggerMatchTags(trigger, prop) {
matched = true;
}
// Check the extra tags
trigger.extraTags?.forEach(extra => {
if (extra.operation === 'OR') {
if (matched) return;
if (
!extra.tags.length ||
difference(extra.tags, propTags).length === 0
) {
matched = true;
}
} else if (extra.operation === 'NOT') {
if (
extra.tags.length &&
intersection(extra.tags, propTags)
) {
return false;
if (trigger.extraTags) {
for (const extra of trigger.extraTags) {
if (extra.operation === 'OR') {
if (matched) break;
if (
!extra.tags.length ||
difference(extra.tags, propTags).length === 0
) {
matched = true;
}
} else if (extra.operation === 'NOT') {
if (
extra.tags.length &&
intersection(extra.tags, propTags).length > 0
) {
matched = false;
break;
}
}
}
});
}
return matched;
}

View File

@@ -0,0 +1,67 @@
import { triggerMatchTags } from '/imports/api/engine/actions/applyTriggers.js';
import clean from '/imports/api/engine/computation/utility/cleanProp.testFn.js';
import { assert } from 'chai';
export default function () {
const prop = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'no1', 'yes2', 'no2', 'or1', 'or2'],
});
const positiveProp = clean({
id: 'propWithTags',
type: 'action',
tags: ['yes1', 'notUsed', 'yes2', 'or1', 'or2'],
});
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
}), prop),
'Trigger matches on a single target tag'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1', 'yes2'],
}), prop),
'Trigger matches on a multiple target tags'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['yes1'],
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when not tags are present'
);
assert.isFalse(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), prop),
'Trigger correctly fails to match when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'NOT', tags: ['no1'] }]
}), positiveProp),
'Trigger matches when only not tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when OR tags are present'
);
assert.isTrue(
triggerMatchTags(clean({
type: 'trigger',
targetTags: ['missing1'],
extraTags: [{ operation: 'OR', tags: ['or1'] }]
}), positiveProp),
'Trigger matches when only OR tags are present'
);
}

View File

@@ -1,16 +1,17 @@
import '/imports/api/simpleSchemaConfig.js';
//import testTypes from './testTypes/index.js';
import applyTriggers from '/imports/api/engine/actions/applyTriggers.testFn.js';
import { doActionWork } from './doAction.js';
import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
function cleanProp(prop){
function cleanProp(prop) {
let schema = CreatureProperties.simpleSchema(prop);
return schema.clean(prop);
}
function cleanCreature(creature){
function cleanCreature(creature) {
let schema = Creatures.simpleSchema(creature);
return schema.clean(creature);
}
@@ -28,7 +29,7 @@ const testActionContext = {
}),
scope: {},
addLog(content) {
if (content.name || content.value){
if (content.name || content.value) {
this.log.content.push(content);
}
},
@@ -40,8 +41,8 @@ const action = cleanProp({
});
const actionAncestors = [];
describe('Do Action', function(){
it('Does an empty action', function(){
describe('Do Action', function () {
it('Does an empty action', function () {
doActionWork({
properties: [action],
ancestors: actionAncestors,
@@ -51,3 +52,7 @@ describe('Do Action', function(){
});
//testTypes.forEach(test => it(test.text, test.fn));
});
describe('Action utility functions', function () {
it('Triggers match tags', applyTriggers);
})

View File

@@ -117,7 +117,8 @@ const doAction = new ValidatedMethod({
}
}
actionContext.scope['slotLevel'] = slotLevel;
actionContext.scope['slotLevel'] = { value: slotLevel };
actionContext.scope['~slotLevel'] = { value: slotLevel };
// Do the action
doActionWork({

View File

@@ -81,7 +81,7 @@ function rollCheck(prop, actionContext) {
rollModifier += effectBonus;
let value, values, resultPrefix;
if (scope['$checkAdvantage'] === 1) {
if (scope['~checkAdvantage']?.value === 1) {
logName += ' (Advantage)';
const [a, b] = rollDice(2, 20);
if (a >= b) {
@@ -91,7 +91,7 @@ function rollCheck(prop, actionContext) {
value = b;
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `;
}
} else if (scope['$checkAdvantage'] === -1) {
} else if (scope['~checkAdvantage']?.value === -1) {
logName += ' (Disadvantage)';
const [a, b] = rollDice(2, 20);
if (a <= b) {
@@ -107,9 +107,9 @@ function rollCheck(prop, actionContext) {
resultPrefix = `1d20 [ ${value} ] ${rollModifierText} = `
}
const result = (value + rollModifier) || 0;
scope['$checkDiceRoll'] = value;
scope['$checkRoll'] = result;
scope['$checkModifier'] = rollModifier;
scope['~checkDiceRoll'] = { value };
scope['~checkRoll'] = { value: result };
scope['~checkModifier'] = { value: rollModifier };
actionContext.addLog({
name: logName,
value: `${resultPrefix} **${result}**`,

View File

@@ -2,7 +2,7 @@
* Only computes `totalFilled`, need to compute `quantityExpected.value`
* before `spacesLeft` can be computed
*/
export default function computeSlotQuantityFilled(node, dependencyGraph){
export default function computeSlotQuantityFilled(node, dependencyGraph) {
let slot = node.node;
if (slot.type !== 'propertySlot') return;
slot.totalFilled = 0;
@@ -10,9 +10,8 @@ export default function computeSlotQuantityFilled(node, dependencyGraph){
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++;

View File

@@ -1,16 +1,31 @@
import walkDown from '/imports/api/engine/computation/utility/walkdown.js';
import { getEffectTagTargets } from '/imports/api/engine/computation/buildComputation/linkTypeDependencies.js';
export default function computeToggleDependencies(node, dependencyGraph){
export default function computeToggleDependencies(node, dependencyGraph, computation, forest) {
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;
// Only for toggles
if (prop.type !== 'toggle') return;
if (prop.targetByTags) {
// Find all the props targeted by tags, and disable them and their children
getEffectTagTargets(prop, computation).forEach(targetId => {
const target = forest.nodeIndex[targetId];
if (!target) return;
target.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(target.node._id, prop._id, 'toggle');
walkDown(target.children, child => {
// The child nodes depend on the toggle
child.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(child.node._id, prop._id, 'toggle');
});
});
}
// We don't need to link direct children of static toggles, it's already done
if (prop.disabled || prop.enabled) return;
walkDown(node.children, child => {
// The child nodes depend on the toggle condition compuation
// The child nodes depend on the toggle
child.node._computationDetails.toggleAncestors.push(prop);
dependencyGraph.addLink(child.node._id, prop._id, 'toggle');
});

View File

@@ -29,9 +29,9 @@ export default function linkTypeDependencies(dependencyGraph, prop, computation)
function dependOnCalc({ dependencyGraph, prop, key }) {
let calc = get(prop, key);
if (!calc) return;
if (!calc?.type) return;
if (calc.type !== '_calculation') {
throw `Expected calculation got ${calc.type}`
throw `Failed to dependOnCal for prop: ${prop._id}, key: ${key}. Expected calculation got ${calc.type}`
}
dependencyGraph.addLink(prop._id, `${prop._id}.${key}`, 'calculation');
}
@@ -164,7 +164,7 @@ function linkEffects(dependencyGraph, prop, computation) {
}
// Returns an array of IDs of the properties the effect targets
function getEffectTagTargets(effect, computation) {
export function getEffectTagTargets(effect, computation) {
let targets = getTargetListFromTags(effect.targetTags, computation);
let notIds = [];
if (effect.extraTags) {
@@ -218,7 +218,6 @@ function getDefaultCalculationField(prop) {
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;
@@ -268,20 +267,45 @@ function linkPointBuy(dependencyGraph, prop) {
if (prop.inactive) return;
}
function linkProficiencies(dependencyGraph, prop) {
function linkProficiencies(dependencyGraph, prop, computation) {
// The stats depend on the proficiency
if (prop.inactive) return;
prop.stats.forEach(statName => {
if (!statName) return;
dependencyGraph.addLink(statName, prop._id, prop.type);
});
if (prop.targetByTags) {
// Tag targeted proficiencies depend on the creature's proficiencyBonus,
// since they add it directly to the targeted field
dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
getEffectTagTargets(prop, computation).forEach(targetId => {
const targetProp = computation.propsById[targetId];
if (
(targetProp.type === 'attribute' || targetProp.type === 'skill')
&& targetProp.variableName
&& !prop.targetField
) {
// If the field wasn't specified and we're targeting an attribute or
// skill, just treat it like a normal proficiency on its variable name
dependencyGraph.addLink(targetProp.variableName, prop._id, 'proficiency');
} else {
// Otherwise target a field on that property
const key = prop.targetField || getDefaultCalculationField(targetProp);
const calcObj = get(targetProp, key);
if (calcObj && calcObj.calculation) {
dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency');
}
}
});
} else {
prop.stats.forEach(statName => {
if (!statName) return;
dependencyGraph.addLink(statName, prop._id, 'proficiency');
});
}
}
function linkSavingThrow(dependencyGraph, prop) {
dependOnCalc({ dependencyGraph, prop, key: 'dc' });
}
function linkSkill(dependencyGraph, prop) {
function linkSkill(dependencyGraph, prop, computation) {
// Depends on base value
dependOnCalc({ dependencyGraph, prop, key: 'baseValue' });
// Link dependents
@@ -293,6 +317,20 @@ function linkSkill(dependencyGraph, prop) {
}
// Skills depend on the creature's proficiencyBonus
dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
// Skills can apply their value as a proficiency bonus to calculations based on tag
if (prop.targetByTags) {
getEffectTagTargets(prop, computation).forEach(targetId => {
const targetProp = computation.propsById[targetId];
// Always target a field on the target property, applying a skill to an attribute or
// other skill isn't supported
const key = prop.targetField || getDefaultCalculationField(targetProp);
const calcObj = get(targetProp, key);
if (calcObj && calcObj.calculation) {
dependencyGraph.addLink(`${targetProp._id}.${key}`, prop._id, 'proficiency');
}
});
}
}
function linkSlot(dependencyGraph, prop) {

View File

@@ -5,14 +5,14 @@ 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){
export default function parseCalculationFields(prop, schemas) {
discoverInlineCalculationFields(prop, schemas);
parseAllCalculationFields(prop, schemas);
}
function discoverInlineCalculationFields(prop, schemas){
function discoverInlineCalculationFields(prop, schemas) {
// For each key in the schema
schemas[prop.type].inlineCalculationFields().forEach( calcKey => {
schemas[prop.type]?.inlineCalculationFields?.()?.forEach(calcKey => {
// That ends in .inlineCalculations
applyFnToKey(prop, calcKey, (prop, key) => {
const inlineCalcObj = get(prop, key);
@@ -22,7 +22,7 @@ function discoverInlineCalculationFields(prop, schemas){
// Extract the calculations and store them on the property
let string = inlineCalcObj.text;
// If there is no text, delete the whole field
if (!string){
if (!string) {
unset(prop, calcKey);
return;
}
@@ -32,7 +32,7 @@ function discoverInlineCalculationFields(prop, schemas){
// Has the text, if it matches the existing hash, stop
const inlineCalcHash = cyrb53(inlineCalcObj.text);
if (inlineCalcHash === inlineCalcObj.hash){
if (inlineCalcHash === inlineCalcObj.hash) {
return;
}
inlineCalcObj.hash = inlineCalcHash;
@@ -41,7 +41,7 @@ function discoverInlineCalculationFields(prop, schemas){
// 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){
for (let match of matches) {
let calculation = match[1];
inlineCalcObj.inlineCalculations.push({
calculation,
@@ -51,9 +51,9 @@ function discoverInlineCalculationFields(prop, schemas){
});
}
function parseAllCalculationFields(prop, schemas){
function parseAllCalculationFields(prop, schemas) {
// For each computed key in the schema
schemas[prop.type].computedFields().forEach( calcKey => {
schemas[prop.type]?.computedFields?.()?.forEach(calcKey => {
// Determine the level the calculation should compute down to
let parseLevel = schemas[prop.type].getDefinition(calcKey).parseLevel || 'reduce';
@@ -66,7 +66,7 @@ function parseAllCalculationFields(prop, schemas){
const calcObj = get(prop, key);
if (!calcObj) return;
// Delete the whole calculation object if the calculation string isn't set
if (!calcObj.calculation){
if (!calcObj.calculation) {
unset(prop, calcKey);
return;
}
@@ -84,10 +84,10 @@ function parseAllCalculationFields(prop, schemas){
});
}
function 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){
if (calcHash === calcObj.hash) {
return;
}
calcObj.hash = calcHash;
@@ -100,6 +100,6 @@ function parseCalculation(calcObj){
message: prettifyParseError(e),
};
calcObj.parseError = error;
calcObj.parseNode = errorNode.create({error});
calcObj.parseNode = errorNode.create({ error });
}
}

View File

@@ -1,9 +1,9 @@
import applyFnToKey from '../utility/applyFnToKey.js';
import { unset } from 'lodash';
export default function removeSchemaFields(schemas, prop){
export default function removeSchemaFields(schemas, prop) {
schemas.forEach(schema => {
schema.removeBeforeComputeFields().forEach(
schema?.removeBeforeComputeFields?.().forEach(
key => applyFnToKey(prop, key, unset)
);
});

View File

@@ -2,7 +2,7 @@ import { buildComputationFromProps } from '/imports/api/engine/computation/build
import { assert } from 'chai';
import clean from '../../utility/cleanProp.testFn.js';
export default function(){
export default function () {
const computation = buildComputationFromProps(testProperties);
const totalFilled = computation.propsById['slotId'].totalFilled;
assert.equal(totalFilled, 4);
@@ -13,24 +13,24 @@ var testProperties = [
clean({
_id: 'slotId',
type: 'propertySlot',
ancestors: [{id: 'charId'}],
ancestors: [{ id: 'charId' }],
}),
// Children
clean({
_id: 'slotFillerId',
type: 'slotFiller',
type: 'folder',
slotQuantityFilled: 3,
slotFillerType: 'item',
ancestors: [{id: 'charId'}, {id: 'slotId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }],
}),
clean({
_id: 'slotChildId',
type: 'item',
ancestors: [{id: 'charId'}, {id: 'slotId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }],
}),
clean({
_id: 'slotGrandchildId',
type: 'effect',
ancestors: [{id: 'charId'}, {id: 'slotId'}, {id: 'slotChildId'}],
ancestors: [{ id: 'charId' }, { id: 'slotId' }, { id: 'slotChildId' }],
}),
];

View File

@@ -29,7 +29,7 @@ import removeSchemaFields from './buildComputation/removeSchemaFields.js';
* computed toggles
*/
export default function buildCreatureComputation(creatureId){
export default function buildCreatureComputation(creatureId) {
const creature = getCreature(creatureId);
const variables = getVariables(creatureId);
const properties = getProperties(creatureId);
@@ -37,7 +37,7 @@ export default function buildCreatureComputation(creatureId){
return computation;
}
export function buildComputationFromProps(properties, creature, variables){
export function buildComputationFromProps(properties, creature, variables) {
const computation = new CreatureComputation(properties, creature, variables);
// Dependency graph where edge(a, b) means a depends on b
@@ -49,14 +49,14 @@ export function buildComputationFromProps(properties, creature, variables){
const dependencyGraph = computation.dependencyGraph;
// Link the denormalizedStats from the creature
if (creature && creature.denormalizedStats){
if (creature.denormalizedStats.xp){
if (creature && creature.denormalizedStats) {
if (creature.denormalizedStats.xp) {
dependencyGraph.addNode('xp', {
baseValue: creature.denormalizedStats.xp,
type: '_variable'
});
}
if (creature.denormalizedStats.milestoneLevels){
if (creature.denormalizedStats.milestoneLevels) {
dependencyGraph.addNode('milestoneLevels', {
baseValue: creature.denormalizedStats.milestoneLevels,
type: '_variable'
@@ -93,7 +93,7 @@ export function buildComputationFromProps(properties, creature, variables){
// Inactive status must be complete for the whole tree before toggle deps
// are calculated
walkDown(forest, node => {
computeToggleDependencies(node, dependencyGraph);
computeToggleDependencies(node, dependencyGraph, computation, forest);
computeSlotQuantityFilled(node, dependencyGraph);
});

View File

@@ -6,6 +6,7 @@ import pointBuy from './computeByType/computePointBuy.js';
import propertySlot from './computeByType/computeSlot.js';
import container from './computeByType/computeContainer.js';
import spellList from './computeByType/computeSpellList.js';
import toggle from './computeByType/computeToggle.js';
import _calculation from './computeByType/computeCalculation.js';
export default Object.freeze({
@@ -19,4 +20,5 @@ export default Object.freeze({
propertySlot,
spell: action,
spellList,
toggle,
});

View File

@@ -1,12 +1,17 @@
import evaluateCalculation from '../../utility/evaluateCalculation.js';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
export default function computeCalculation(computation, node){
export default function computeCalculation(computation, node) {
const calcObj = node.data;
evaluateCalculation(calcObj, computation.scope);
if (calcObj.effects || calcObj.proficiencies) {
calcObj.baseValue = calcObj.value;
}
aggregateCalculationEffects(node, computation);
aggregateCalculationProficiencies(node, computation);
}
export function aggregateCalculationEffects(node, computation){
function aggregateCalculationEffects(node, computation) {
const calcObj = node.data;
delete calcObj.effects;
computation.dependencyGraph.forEachLinkedNode(
@@ -34,15 +39,77 @@ export function aggregateCalculationEffects(node, computation){
},
true // enumerate only outbound links
);
if (calcObj.effects && typeof calcObj.value === 'number'){
calcObj.baseValue = calcObj.value;
if (calcObj.effects && typeof calcObj.value === 'number') {
calcObj.effects.forEach(effect => {
if (
effect.operation === 'add' &&
effect.amount && typeof effect.amount.value === 'number'
){
) {
calcObj.value += effect.amount.value
}
});
}
}
function aggregateCalculationProficiencies(node, computation) {
const calcObj = node.data;
delete calcObj.proficiencies;
delete calcObj.proficiency;
let profBonus = computation.scope['proficiencyBonus']?.value || 0;
// Go through all the links and collect them on the calculation
computation.dependencyGraph.forEachLinkedNode(
node.id,
(linkedNode, link) => {
// Only proficiency links
if (link.data !== 'proficiency') return;
// That have data
if (!linkedNode.data) return;
// Ignoring inactive props
if (linkedNode.data.inactive) return;
// Compute the proficiency and value
let proficiency, value;
if (linkedNode.data.type === 'proficiency') {
proficiency = linkedNode.data.value || 0;
// Multiply the proficiency bonus by the actual proficiency
if (proficiency === 0.49) {
// Round down proficiency bonus in the special case
value = Math.floor(profBonus * 0.5);
} else {
value = Math.ceil(profBonus * proficiency);
}
} else if (linkedNode.data.type === 'skill') {
value = linkedNode.data.value || 0;
proficiency = linkedNode.data.proficiency || 0;
}
// Collate proficiencies
calcObj.proficiencies = calcObj.proficiencies || [];
calcObj.proficiencies.push({
_id: linkedNode.data._id,
name: linkedNode.data.name,
type: linkedNode.data.type,
proficiency,
value,
});
},
true // enumerate only outbound links
);
// Apply the highest proficiency, marking all others as overridden
if (calcObj.proficiencies && typeof calcObj.value === 'number') {
calcObj.proficiency = 0;
calcObj.proficiencyBonus = 0;
let currentProf;
calcObj.proficiencies.forEach(prof => {
if (prof.value > calcObj.proficiencyBonus) {
if (currentProf) currentProf.overridden = true;
calcObj.proficiencyBonus = prof.value;
calcObj.proficiency = prof.proficiency;
currentProf = prof;
} else {
prof.overridden = true;
}
});
calcObj.value += calcObj.proficiencyBonus;
}
}

View File

@@ -1,6 +1,6 @@
export default function computSlot(computation, node){
export default function computeSlot(computation, node) {
const prop = node.data;
if (prop.quantityExpected && prop.quantityExpected.value){
if (prop.quantityExpected && prop.quantityExpected.value) {
prop.spaceLeft = prop.quantityExpected.value - prop.totalFilled;
}
}

View File

@@ -0,0 +1,7 @@
export default function computeToggle(computation, node) {
const prop = node.data;
if (!prop.enabled && !prop.disabled && prop.condition && !prop.condition.value) {
prop.inactive = true;
prop.deactivatedBySelf = true;
}
}

View File

@@ -1,16 +1,40 @@
import getAggregatorResult from './getAggregatorResult.js';
export default function computeVariableAsAttribute(computation, node, prop){
export default function computeVariableAsAttribute(computation, node, prop) {
let result = getAggregatorResult(node) || 0;
prop.total = result;
// Apply damage in a way that respects the damage rules, modifying damage if need be
// Bound the damage
if (!prop.ignoreLowerLimit && prop.damage > prop.total) {
console.log(`reducing damage from ${prop.damage} to ${prop.total}`);
prop.damage = prop.total;
}
if (!prop.ignoreUpperLimit && prop.damage < 0) {
console.log(`increasing damage from ${prop.damage} to 0`);
prop.damage = 0;
}
// Apply damage
prop.value = prop.total - (prop.damage || 0);
// Proficiency
prop.proficiency = node.data.proficiency;
// Advantage/disadvantage
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;
}
}
// Ability scores get modifiers
if (prop.attributeType === 'ability'){
if (prop.attributeType === 'ability') {
prop.modifier = Math.floor((prop.value - 10) / 2);
}

View File

@@ -1,12 +1,12 @@
import aggregate from './aggregate/index.js';
export default function computeVariableAsSkill(computation, node, prop){
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){
if (prop.skillType !== 'save' && ability) {
aggregateAbilityEffects({
computation,
skillNode: node,
@@ -21,7 +21,7 @@ export default function computeVariableAsSkill(computation, node, prop){
let profBonus = computation.scope['proficiencyBonus']?.value || 0;
// Multiply the proficiency bonus by the actual proficiency
if(prop.proficiency === 0.49){
if (prop.proficiency === 0.49) {
// Round down proficiency bonus in the special case
profBonus = Math.floor(profBonus * 0.5);
} else {
@@ -37,7 +37,7 @@ export default function computeVariableAsSkill(computation, node, prop){
prop.effects = node.data.effects;
// If there is no aggregator, determine if the prop can hide, then exit
if (!aggregator){
if (!aggregator) {
prop.hide = statBase === undefined &&
prop.proficiency == 0 ||
undefined;
@@ -52,20 +52,32 @@ export default function computeVariableAsSkill(computation, node, prop){
if (aggregator.set !== undefined) {
result = aggregator.set;
}
if (Number.isFinite(result)){
if (Number.isFinite(result)) {
result = Math.floor(result);
}
prop.value = result;
// Advantage/disadvantage
if (aggregator.advantage && !aggregator.disadvantage){
if (aggregator.advantage && !aggregator.disadvantage) {
prop.advantage = 1;
} else if (aggregator.disadvantage && !aggregator.advantage){
} else if (aggregator.disadvantage && !aggregator.advantage) {
prop.advantage = -1;
} else {
prop.advantage = 0;
}
// Passive bonus
prop.passiveBonus = aggregator.passiveAdd;
// +/- 5 to passive bonus if the skill has advantage/disadvantage
if (
prop.advantage === 1
&& Number.isFinite(prop.passiveBonus)
) {
prop.passiveBonus += 5;
} else if (
prop.advantage === -1
&& Number.isFinite(prop.passiveBonus)
) {
prop.bassiveBonus -= 5;
}
// conditional benefits
prop.conditionalBenefits = aggregator.conditional;
// Roll bonuses
@@ -76,7 +88,8 @@ export default function computeVariableAsSkill(computation, node, prop){
prop.rollBonuses = aggregator.rollBonus;
}
function aggregateAbilityEffects({computation, skillNode, abilityNode}){
function aggregateAbilityEffects({ computation, skillNode, abilityNode }) {
if (!abilityNode?.id) return;
computation.dependencyGraph.forEachLinkedNode(
abilityNode.id,
(linkedNode, link) => {
@@ -85,15 +98,15 @@ function aggregateAbilityEffects({computation, skillNode, abilityNode}){
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 (link.data === 'effect') {
if (![
'advantage', 'disadvantage', 'passiveAdd', 'fail', 'conditional'
].includes(linkedNode.data.operation)){
].includes(linkedNode.data.operation)) {
return;
}
}
// Apply the aggregations
let arg = {node: skillNode, linkedNode, link};
let arg = { node: skillNode, linkedNode, link };
aggregate.effect(arg);
aggregate.proficiency(arg);
},

View File

@@ -1,13 +1,16 @@
export default function evaluateToggles(computation, node){
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 (!toggle.condition) return;
if (!toggle.condition.value){
if (
(!toggle.enabled && !toggle.disabled && toggle.condition && !toggle.condition.value)
|| (toggle.disabled)
) {
prop.inactive = true;
prop.deactivatedByToggle = true;
prop.deactivatingToggleId = toggle._id;
}
});
}

View File

@@ -0,0 +1,64 @@
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, 8,
'The proficiency bonus should not change the strength score'
);
assert.equal(
prop('strengthId').modifier, -1,
'The proficiency bonus should not change the strength modifier'
);
assert.exists(prop('actionId').attackRoll.proficiencies, 'The proficiency aggregator should be here')
assert.exists(prop('actionId').attackRoll.proficiencies[0], 'The proficiency should be here')
// attack roll = strength.mod + proficiencyBonus/2 rounded down
// = -1 + 13/2 = -1 + 6 = 5
assert.equal(
prop('actionId').attackRoll.value, 5,
'The proficiency should apply correctly to modify the attack roll'
);
}
var testProperties = [
clean({
_id: 'strengthId',
variableName: 'strength',
type: 'attribute',
attributeType: 'ability',
baseValue: {
calculation: '8'
},
}),
clean({
_id: 'actionId',
type: 'action',
ancestors: [{ id: 'charId' }],
attackRoll: {
calculation: 'strength.modifier',
},
tags: ['rapier', 'martial weapon', 'weapon', 'attack']
}),
clean({
_id: 'profBonusId',
type: 'attribute',
variableName: 'proficiencyBonus',
ancestors: [{ id: 'charId' }],
baseValue: {
calculation: '13'
},
}),
clean({
_id: 'tagTargetedProficiency',
type: 'proficiency',
stats: ['strength'], // Should be ignored, we are targeting by tags
value: 0.49,
targetByTags: true,
targetTags: ['martial weapon']
}),
];

View File

@@ -6,29 +6,33 @@ import computeInventory from './computeInventory.testFn.js';
import computeDamageMultipliers from './computeDamageMultipliers.testFn.js';
import computeEffects from './computeEffects.testFn.js';
import computeSkills from './computeSkills.testFn.js';
import computeProficiencies from './computeProficiencies.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,
}, {
text: 'Computes proficiencies',
fn: computeProficiencies,
}];

View File

@@ -3,12 +3,12 @@ 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(computation){
export default function writeAlteredProperties(computation) {
let bulkWriteOperations = [];
// Loop through all properties on the memo
computation.props.forEach(changed => {
let schema = propertySchemasIndex[changed.type];
if (!schema){
if (!schema) {
console.warn('No schema for ' + changed.type);
return;
}
@@ -20,12 +20,13 @@ export default function writeAlteredProperties(computation){
'deactivatedBySelf',
'deactivatedByAncestor',
'deactivatedByToggle',
'deactivatingToggleId',
'damage',
'dirty',
...schema.objectKeys(),
];
op = addChangedKeysToOp(op, keys, original, changed);
if (op){
if (op) {
bulkWriteOperations.push(op);
}
});
@@ -37,10 +38,10 @@ 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 (!EJSON.equals(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){
if (value === undefined) {
// Unset values that become undefined
addUnsetOp(op, key);
} else {
@@ -52,32 +53,32 @@ function addChangedKeysToOp(op, keys, original, changed) {
return op;
}
function newOperation(_id, type){
function newOperation(_id, type) {
let newOp = {
updateOne: {
filter: {_id},
filter: { _id },
update: {},
}
};
if (Meteor.isClient){
if (Meteor.isClient) {
newOp.type = type;
}
return newOp;
}
function addSetOp(op, key, value){
if (op.updateOne.update.$set){
function addSetOp(op, key, value) {
if (op.updateOne.update.$set) {
op.updateOne.update.$set[key] = value;
} else {
op.updateOne.update.$set = {[key]: value};
op.updateOne.update.$set = { [key]: value };
}
}
function addUnsetOp(op, key){
if (op.updateOne.update.$unset){
function addUnsetOp(op, key) {
if (op.updateOne.update.$unset) {
op.updateOne.update.$unset[key] = 1;
} else {
op.updateOne.update.$unset = {[key]: 1};
op.updateOne.update.$unset = { [key]: 1 };
}
}
@@ -100,14 +101,14 @@ function writePropertiesSequentially(bulkWriteOps) {
// in the UI because of incompatibility with latency compensation. If the
// duplicate redraws can be fixed, this is a strictly better way of processing
// writes
function bulkWriteProperties(bulkWriteOps){
function bulkWriteProperties(bulkWriteOps) {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (Meteor.isServer) {
CreatureProperties.rawCollection().bulkWrite(
bulkWriteOps,
{ordered : false},
function(e){
{ ordered: false },
function (e) {
if (e) {
console.error('Bulk write failed: ');
console.error(e);

View File

@@ -15,18 +15,21 @@ export default function writeScope(creatureId, computation) {
let $set, $unset;
for (const key in scope){
for (const key in scope) {
// Mongo can't handle keys that start with a dollar sign
if (key[0] === '$' || key[0] === '_') continue;
// Remove large properties that aren't likely to be accessed
delete scope[key].parent;
delete scope[key].ancestors;
// Remove empty keys
for (const subKey in scope[key]) {
if (scope[key][subKey] === undefined) {
delete scope[key][subKey]
}
}
// Only update changed fields
if (!EJSON.equals(variables[key], scope[key])) {
if (!$set) $set = {};
@@ -53,9 +56,19 @@ export default function writeScope(creatureId, computation) {
const update = {};
if ($set) update.$set = $set;
if ($unset) update.$unset = $unset;
CreatureVariables.update({_creatureId: creatureId}, update);
CreatureVariables.update({ _creatureId: creatureId }, update);
}
if (computation.creature?.dirty) {
Creatures.update({_id: creatureId}, {$unset: { dirty: 1 }});
Creatures.update({ _id: creatureId }, { $unset: { dirty: 1 } });
}
}
/*
function calculateSize(computation) {
const sizeEstimator = {
creature: computation.creature,
variables: computation.variables,
props: computation.originalPropsById,
};
return MongoInternals.NpmModule.BSON.calculateObjectSize(sizeEstimator, { checkKeys: false })
}
*/

View File

@@ -4,7 +4,7 @@ import writeAlteredProperties from './computation/writeComputation/writeAlteredP
import writeScope from './computation/writeComputation/writeScope.js';
import writeErrors from './computation/writeComputation/writeErrors.js';
export default function computeCreature(creatureId){
export default function computeCreature(creatureId) {
if (Meteor.isClient) return;
// console.log('compute ' + creatureId);
const computation = buildCreatureComputation(creatureId);
@@ -16,7 +16,7 @@ function computeComputation(computation, creatureId) {
computeCreatureComputation(computation);
writeAlteredProperties(computation);
writeScope(creatureId, computation);
} catch (e){
} catch (e) {
const errorText = e.reason || e.message || e.toString();
computation.errors.push({
type: 'crash',
@@ -32,6 +32,19 @@ function computeComputation(computation, creatureId) {
console.error(logError);
throw e;
} finally {
checkPropertyCount(computation)
writeErrors(creatureId, computation.errors);
}
}
const MAX_PROPS = 1000;
function checkPropertyCount(computation) {
const count = computation.props.length;
if (count <= MAX_PROPS) return;
computation.errors.push({
type: 'warning',
details: {
error: `This character sheet has too many properties and may perform poorly ( ${count} / ${MAX_PROPS} )`
},
});
}