diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js index e75eb3c6..1c118fee 100644 --- a/app/imports/api/creature/creatures/Creatures.js +++ b/app/imports/api/creature/creatures/Creatures.js @@ -133,6 +133,7 @@ let CreatureSchema = new SimpleSchema({ 'computeErrors.$.details' : { type: Object, blackbox: true, + optional: true, }, // Tabletop diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index a92255c1..0366bf49 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js @@ -11,11 +11,11 @@ export default function applyAction(node, {creature, targets, scope, log}){ const prop = node.node; if (prop.target === 'self') targets = [creature]; - // Log the name and description + // Log the name and summary let content = { name: prop.name }; - if (prop.description?.text){ - recalculateInlineCalculations(prop.description, scope, log); - content.value = prop.description.value; + if (prop.summary?.text){ + recalculateInlineCalculations(prop.summary, scope, log); + content.value = prop.summary.value; } if (content.name || content.value){ log.content.push(content); @@ -33,7 +33,7 @@ export default function applyAction(node, {creature, targets, scope, log}){ targets.forEach(target => { applyAttackToTarget({attack, target, scope, log}); // Apply the children, but only to the current target - applyChildren(node, {targets: [target], scope, log}); + applyChildren(node, {creature, targets: [target], scope, log}); }); } else { applyAttackWithoutTarget({attack, scope, log}); @@ -65,6 +65,13 @@ function applyAttackWithoutTarget({attack, scope, log}){ } else if(scope['$attackAdvantage'] === -1){ name += ' (Disadvantage)'; } + if (!criticalMiss){ + scope['$attackHit'] = {value: true} + } + if (!criticalHit){ + scope['$attackMiss'] = {value: true}; + } + log.content.push({ name, value: `${resultPrefix}\n**${result}**`, @@ -106,10 +113,10 @@ function applyAttackToTarget({attack, target, scope, log}){ value: `${resultPrefix}\n**${result}**`, inline: true, }); - if ((result > armor) || (criticalHit)){ - scope['$attackHit'] = true; + if (criticalMiss || result < armor){ + scope['$attackMiss'] = {value: true}; } else { - scope['$attackMiss'] = true; + scope['$attackHit'] = {value: true}; } } else { log.content.push({ @@ -127,7 +134,7 @@ function applyAttackToTarget({attack, target, scope, log}){ function rollAttack(attack, scope){ const rollModifierText = numberToSignedString(attack.value, true); let value, resultPrefix; - if (attack.advantage === 1 || scope['$attackAdvantage']){ + if (scope['$attackAdvantage'] === 1){ const [a, b] = rollDice(2, 20); if (a >= b) { value = a; @@ -136,7 +143,7 @@ function rollAttack(attack, scope){ value = b; resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; } - } else if (attack.advantage === -1 || scope['$attackDisadvantage']){ + } else if (scope['$attackAdvantage'] === -1){ const [a, b] = rollDice(2, 20); if (a <= b) { value = a; @@ -161,15 +168,10 @@ function applyCrits(value, scope){ let criticalMiss; if (criticalHit){ scope['$criticalHit'] = {value: true}; - scope['$attackHit'] = {value: true}; } else { criticalMiss = value === 1; if (criticalMiss){ - scope['$criticalMiss'] = 1; - scope['$attackMiss'] = {value: true}; - } else { - // Untargeted attacks hit by default - scope['$attackHit'] = {value: true} + scope['$criticalMiss'] = {value: true}; } } return {criticalHit, criticalMiss}; diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js index 63aa2e1d..6a46e07c 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js @@ -16,17 +16,47 @@ export default function applyBranch(node, { recalculateCalculation(prop.condition, scope, log); if (prop.condition?.value) applyChildren(); break; + case 'index': + if (node.children.length){ + recalculateCalculation(prop.condition, scope, log); + if (!isFinite(prop.condition?.value)) { + log.content.push({ + name: 'Branch Error', + value: 'Index did not resolve into a valid number' + }); + break; + } + let index = Math.floor(prop.condition?.value); + if (index < 1) index = 1; + if (index > node.children.length) index = node.children.length; + applyProperty(node.children[index - 1], { + creature, targets, scope, log + }); + } + break; case 'hit': - if (scope['$attackHit']?.value) applyChildren(); + if (scope['$attackHit']?.value){ + if (!targets.length) log.content.push({value: '**On hit**'}); + applyChildren(); + } break; case 'miss': - if (scope['$attackMiss']?.value) applyChildren(); + if (scope['$attackMiss']?.value){ + if (!targets.length) log.content.push({value: '**On miss**'}); + applyChildren(); + } break; case 'failedSave': - if (scope['$saveFailed']?.value) applyChildren(); + if (scope['$saveFailed']?.value){ + if (!targets.length) log.content.push({value: '**On failed save**'}); + applyChildren(); + } break; case 'successfulSave': - if (scope['$saveSucceeded']?.value) applyChildren(); + if (scope['$saveSucceeded']?.value){ + if (!targets.length) log.content.push({value: '**On save**',}); + applyChildren(); + } break; case 'random': if (node.children.length){ diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js index 32d9f35a..66857f6c 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js @@ -10,6 +10,7 @@ import { get } from 'lodash'; import resolve, { map, toString } from '/imports/parser/resolve.js'; import symbol from '/imports/parser/parseTree/symbol.js'; import logErrors from './shared/logErrors.js'; +import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs.js'; import cyrb53 from '/imports/api/engine/computation/utility/cyrb53.js'; export default function applyBuff(node, {creature, targets, scope, log}){ @@ -32,7 +33,30 @@ export default function applyBuff(node, {creature, targets, scope, log}){ collection: prop.parent.collection, }; buffTargets.forEach(target => { + // Apply the buff copyNodeListToTarget(propList, target, oldParent); + + //Log the buff + if (prop.name || prop.description?.value){ + if (target._id === creature._id){ + // Targeting self + log.content.push({ + name: prop.name, + value: prop.description?.value, + }); + } else { + // Targeting other + insertCreatureLog.call({ + log: { + creatureId: target._id, + content: [{ + name: prop.name, + value: prop.description?.value, + }], + } + }); + } + } }); // Don't apply the children of the buff, they get copied to the target instead diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index 5e2153fc..0978d07a 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -1,3 +1,4 @@ +import { some, intersection, difference } from 'lodash'; import applyProperty from '../applyProperty.js'; import { dealDamageWork } from '/imports/api/creature/creatureProperties/methods/dealDamage.js'; import {insertCreatureLog} from '/imports/api/creature/log/CreatureLogs.js'; @@ -35,15 +36,12 @@ export default function applyDamage(node, { const logValue = []; const logName = prop.damageType === 'healing' ? 'Healing' : 'Damage'; - // Compile the dice roll and store that string first - // const {result: compiled} = resolve('compiled', prop.amount.parseNode, scope, context); - // logValue.push(toString(compiled)); - // logErrors(context.errors, log); - // roll the dice only and store that string applyEffectsToCalculationParseNode(prop.amount, log); const {result: rolled} = resolve('roll', prop.amount.parseNode, scope, context); - logValue.push(toString(rolled)); + if (rolled.parseType !== 'constant'){ + logValue.push(toString(rolled)); + } logErrors(context.errors, log); // Reset the errors so we don't log the same errors twice @@ -61,14 +59,16 @@ export default function applyDamage(node, { } else { prop.amount.value = toString(reduced); } - - const damage = +reduced.value; + let damage = +reduced.value; // If we didn't end up with a constant of finite amount, give up - if (reduced?.parseType !== 'constant' && !isFinite(reduced.value)){ + if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)){ return applyChildren(); } + // Round the damage to a whole number + damage = Math.floor(damage); + // Memoise the damage suffix for the log let suffix = (criticalHit ? ' critical ' : ' ') + prop.damageType + @@ -78,6 +78,14 @@ export default function applyDamage(node, { // Iterate through all the targets damageTargets.forEach(target => { + // Apply weaknesses/resistances/immunities + damage = applyDamageMultipliers({ + target, + damage, + damageProp: prop, + logValue + }); + // Deal the damage to the target let damageDealt = dealDamageWork({ creature: target, @@ -114,3 +122,51 @@ export default function applyDamage(node, { }); return applyChildren(); } + +function applyDamageMultipliers({target, damage, damageProp, logValue}){ + const damageType = damageProp?.damageType; + if (!damageType) return damage; + + const multiplier = target?.variables?.[damageType]; + if (!multiplier) return damage; + + const damageTypeText = damageType == 'healing' ? 'healing': `${damageType} damage`; + + if ( + multiplier.immunity && + some(multiplier.immunities, multiplierAppliesTo(damageProp)) + ){ + logValue.push(`Immune to ${damageTypeText}`); + return 0; + } else { + if ( + multiplier.resistance && + some(multiplier.resistances, multiplierAppliesTo(damageProp)) + ){ + logValue.push(`Resistant to ${damageTypeText}`); + damage = Math.floor(damage / 2); + } + if ( + multiplier.vulnerability && + some(multiplier.vulnerabilities, multiplierAppliesTo(damageProp)) + ){ + logValue.push(`Vulnerable to ${damageTypeText}`); + damage = Math.floor(damage * 2); + } + } + return damage; +} + +function multiplierAppliesTo(damageProp){ + return multiplier => { + const hasRequiredTags = difference( + multiplier.includeTags, damageProp.tags + ).length === 0; + + const hasNoExcludedTags = intersection( + multiplier.excludeTags, damageProp.tags + ).length === 0; + + return hasRequiredTags && hasNoExcludedTags; + } +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js index d37aabfe..a097f977 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js @@ -1,20 +1,58 @@ import applyProperty from '../applyProperty.js'; -import recalculateCalculation from './shared/recalculateCalculation.js'; +import logErrors from './shared/logErrors.js'; +import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js'; +import resolve, { toString } from '/imports/parser/resolve.js'; export default function applyRoll(node, {creature, targets, scope, log}){ const prop = node.node; - if (prop.roll?.calculation){ - recalculateCalculation(prop.roll, scope, log); + const applyChildren = node.children.forEach(child => applyProperty(child, { + creature, targets, scope, log + })); - if (isFinite(prop.roll.value)){ - scope[prop.variableName] = prop.roll.value; + if (prop.roll?.calculation){ + const logValue = []; + + // roll the dice only and store that string + applyEffectsToCalculationParseNode(prop.roll, log); + const {result: rolled, context} = resolve('roll', prop.roll.parseNode, scope); + if (rolled.parseType !== 'constant'){ + logValue.push(toString(rolled)); + } + logErrors(context.errors, log); + + // Reset the errors so we don't log the same errors twice + context.errors = []; + + // Resolve the roll to a final value + const {result: reduced} = resolve('reduce', rolled, scope, context); + logErrors(context.errors, log); + + // Store the result + if (reduced.parseType === 'constant'){ + prop.roll.value = reduced.value; + } 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)){ + return applyChildren(); + } + const value = reduced.value; + + scope[prop.variableName] = value; + logValue.push(`**${value}**`); + + if (!prop.silent){ + log.content.push({ + name: prop.name, + value: logValue.join('\n'), + inline: true, + }); } - log.content.push({ - name: prop.name, - value: prop.variableName + ' = ' + prop.roll.calculation + ' = ' + prop.roll.value, - inline: true, - }); } return node.children.forEach(child => applyProperty(child, { creature, targets, scope, log diff --git a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js index b94ccc13..503442ab 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js @@ -22,10 +22,21 @@ export default function applySavingThrow(node, {creature, targets, scope, log}){ } log.content.push({ name: prop.name, - value: ' DC ' + dc, + value: `DC **${dc}**`, inline: true, }); + // 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}; + return node.children.forEach(child => applyProperty(child, { + creature, targets, scope, log + })); + } + + // Each target makes the saving throw saveTargets.forEach(target => { delete scope['$saveFailed']; delete scope['$saveSucceeded']; @@ -55,24 +66,24 @@ export default function applySavingThrow(node, {creature, targets, scope, log}){ const [a, b] = rollDice(2, 20); if (a >= b) { value = a; - resultPrefix = `Advantage: 1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText} = `; + resultPrefix = `Advantage\n1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; } else { value = b; - resultPrefix = `Advantage: 1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `; + resultPrefix = `Advantage\n1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; } } else if (save.advantage === -1){ const [a, b] = rollDice(2, 20); if (a <= b) { value = a; - resultPrefix = `Disadvantage: 1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText} = `; + resultPrefix = `Disadvantage\n1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; } else { value = b; - resultPrefix = `Disadvantage: 1d20 [ ~~${a}~~, ${b} ] ${rollModifierText} = `; + resultPrefix = `Disadvantage\n1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; } } else { values = rollDice(1, 20); value = values[0]; - resultPrefix = `1d20 [ ${value} ] ${rollModifierText} = ` + resultPrefix = `1d20 [ ${value} ] ${rollModifierText}` } scope['$saveDiceRoll'] = {value}; const result = value + save.value || 0; @@ -84,8 +95,8 @@ export default function applySavingThrow(node, {creature, targets, scope, log}){ scope['$saveFailed'] = {value: true}; } log.content.push({ - name: 'Save', - value: resultPrefix + result + (saveSuccess ? 'Passed' : 'Failed'), + name: saveSuccess ? 'Successful save' : 'Failed save', + value: resultPrefix + '\n**' + result + '**', inline: true, }); return applyChildren(); diff --git a/app/imports/api/engine/computation/CreatureComputation.js b/app/imports/api/engine/computation/CreatureComputation.js index d6c45ebe..90e28f44 100644 --- a/app/imports/api/engine/computation/CreatureComputation.js +++ b/app/imports/api/engine/computation/CreatureComputation.js @@ -20,16 +20,28 @@ export default class CreatureComputation { // Store by id this.propsById[prop._id] = prop; + // Store tags + const storePropOnTag = (prop, tag) => { + if (!tag) return; + if (this.propsWithTag[tag]){ + this.propsWithTag[tag].push(prop._id); + } else { + this.propsWithTag[tag] = [prop._id]; + } + } // Store sets of ids in each tag if (prop.tags){ prop.tags.forEach(tag => { - if (this.propsWithTag[tag]){ - this.propsWithTag[tag].push(prop._id); - } else { - this.propsWithTag[tag] = [prop._id]; - } + storePropOnTag(prop, tag); }); } + // Store tags for the property type + storePropOnTag(prop, `#${prop.type}`); + // Store tags for some string properties + storePropOnTag(prop, prop.damageType); + storePropOnTag(prop, prop.skillType); + storePropOnTag(prop, prop.attributeType); + storePropOnTag(prop, prop.reset); // Store the prop in the dependency graph this.dependencyGraph.addNode(prop._id, prop); diff --git a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js index 9394edba..848cf1d3 100644 --- a/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js +++ b/app/imports/api/engine/computation/buildComputation/linkTypeDependencies.js @@ -218,7 +218,7 @@ function linkDamageMultiplier(dependencyGraph, prop){ prop.damageTypes.forEach(damageType => { // Remove all non-letter characters from the damage name const damageName = damageType.replace(/[^a-z]/gi, '') - dependencyGraph.addLink(`${damageName}Multiplier`, prop._id, prop.type); + dependencyGraph.addLink(damageName, prop._id, prop.type); }); } @@ -242,7 +242,7 @@ function linkSkill(dependencyGraph, prop){ } // Skills depend on the creature's proficiencyBonus dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus'); - + // Depends on base value dependOnCalc({dependencyGraph, prop, key: 'baseValue'}); } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js index 09cb0b00..279ad3d6 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable.js @@ -54,6 +54,21 @@ function combineAggregations(computation, node){ function computeVariableProp(computation, node, prop){ if (!prop) return; + + // Combine damage multipliers in all props so that they can't be overridden + if (node.data.immunity){ + prop.immunity = node.data.immunity; + prop.immunities = node.data.immunities; + } + if (node.data.resistance){ + prop.resistance = node.data.resistance; + prop.resistances = node.data.resistances; + } + if (node.data.vulnerability){ + prop.vulnerability = node.data.vulnerability; + prop.vulnerabilities = node.data.vulnerabilities; + } + if (prop.type === 'attribute'){ computeVariableAsAttribute(computation, node, prop); } else if (prop.type === 'skill'){ @@ -73,21 +88,16 @@ function combineMultiplierAggregator(node){ if (!aggregator) return; // Combine - let value; - if (aggregator.immunityCount){ - value = 0; - } else if ( - aggregator.resistanceCount && - !aggregator.vulnerabilityCount - ){ - value = 0.5; - } else if ( - !aggregator.resistanceCount && - aggregator.vulnerabilityCount - ){ - value = 2; - } else { - value = 1; + if (aggregator.immunities?.length){ + node.data.immunity = true; + node.data.immunities = aggregator.immunities; + } + if (aggregator.resistances?.length){ + node.data.resistance = true; + node.data.resistances = aggregator.resistances; + } + if (aggregator.vulnerabilities?.length){ + node.data.vulnerability = true; + node.data.vulnerabilities = aggregator.vulnerabilities; } - node.data.damageMultiplyValue = value; } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js index 15b5309c..d165b1bd 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/aggregate/aggregateDamageMultiplier.js @@ -1,22 +1,36 @@ +import { pick } from 'lodash'; + export default function aggregateDamageMultipliers({node, linkedNode, link}){ if (link.data !== 'damageMultiplier') return; const multiplierValue = linkedNode.data.value; if (multiplierValue === undefined) return; + // Store an aggregator, its presence indicates damage multipliers target this // variable if (!node.data.multiplierAggregator) node.data.multiplierAggregator = { - immunityCount: 0, - resistanceCount: 0, - vulnerabilityCount: 0, + immunities: [], + resistances: [], + vulnerabilities: [], } // Store a short reference to the aggregator const aggregator = node.data.multiplierAggregator; - // Sum the counts of each type of multiplier + + // Make a stripped down copy of the multiplier to store in the aggregator + const keysToStore = ['_id', 'name']; + if (linkedNode.data.excludeTags?.length){ + keysToStore.push('excludeTags'); + } + if (linkedNode.data.includeTags?.length){ + keysToStore.push('includeTags'); + } + const storedMultiplier = pick(linkedNode.data, keysToStore); + + // Store the multiplier in the appropriate field if (multiplierValue === 0){ - aggregator.immunityCount += 1; + aggregator.immunities.push(storedMultiplier); } else if (multiplierValue === 0.5){ - aggregator.resistanceCount += 1; + aggregator.resistances.push(storedMultiplier); } else if (multiplierValue === 2){ - aggregator.vulnerabilityCount += 1; + aggregator.vulnerabilities.push(storedMultiplier); } } diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js index c10c164d..61e081f8 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeImplicitVariable.js @@ -6,6 +6,21 @@ import getAggregatorResult from './getAggregatorResult.js'; */ export default function computeImplicitVariable(node){ const prop = {}; + + // Combine damage multipliers + if (node.data.immunity){ + prop.immunity = node.data.immunity; + prop.immunities = node.data.immunities; + } + if (node.data.resistance){ + prop.resistance = node.data.resistance; + prop.resistances = node.data.resistances; + } + if (node.data.vulnerability){ + prop.vulnerability = node.data.vulnerability; + prop.vulnerabilities = node.data.vulnerabilities; + } + const result = getAggregatorResult(node); if (result !== undefined){ prop.value = result; diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js index 21137749..6ec5df2d 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsAttribute.js @@ -1,7 +1,7 @@ import getAggregatorResult from './getAggregatorResult.js'; export default function computeVariableAsAttribute(computation, node, prop){ - let result = getAggregatorResult(node, prop) || 0; + let result = getAggregatorResult(node) || 0; prop.total = result; prop.value = prop.total - (prop.damage || 0); diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js index da0ed54b..6b9c3222 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js @@ -29,8 +29,9 @@ export default function computeVariableAsSkill(computation, node, prop){ } // Combine everything to get the final result - const statBase = node.data.baseValue; + const statBase = node.data.baseValue || 0; const aggregator = node.data.effectAggregator; + const aggregatorBase = aggregator?.base || 0; // If there is no aggregator, determine if the prop can hide, then exit if (!aggregator){ @@ -41,7 +42,7 @@ export default function computeVariableAsSkill(computation, node, prop){ return; } // Combine aggregator - const base = (statBase > aggregator.base ? statBase : aggregator.base) || 0; + const base = statBase > aggregatorBase ? statBase : aggregatorBase; let result = (base + prop.abilityMod + profBonus + aggregator.add) * aggregator.mul; if (result < aggregator.min) result = aggregator.min; if (result > aggregator.max) result = aggregator.max; diff --git a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js index 95905904..3201009b 100644 --- a/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js +++ b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/getAggregatorResult.js @@ -1,15 +1,10 @@ import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js'; export default function getAggregatorResult(node){ - // Work out the base value as the greater of the deining stat value or - // the damage multiplier value + // Work out the base value as the greater of the deining stat value // This baseValue comes from aggregating definitions let statBase = node.data.baseValue; - const damageMultiplyValue = node.data.damageMultiplyValue; - if (statBase === undefined || damageMultiplyValue > statBase){ - statBase = damageMultiplyValue; - } // get a reference to the aggregator const aggregator = node.data.effectAggregator; diff --git a/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js b/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js index ef1ec555..ee777627 100644 --- a/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js +++ b/app/imports/api/engine/computation/computeComputation/tests/computeDamageMultipliers.testFn.js @@ -7,9 +7,11 @@ export default function(){ const computation = buildComputationFromProps(testProperties); computeCreatureComputation(computation); const scope = id => computation.scope[id]; - assert.equal(scope('blugeoningMultiplier').value, 1); - assert.equal(scope('customDamageMultiplier').value, 0.5); - assert.equal(scope('slashingMultiplier').value, 0); + assert.isTrue(scope('blugeoning').vulnerability); + assert.isTrue(scope('customDamage').resistance); + assert.isNotTrue(scope('customDamage').immunity); + assert.isNotTrue(scope('customDamage').vulnerability); + assert.isTrue(scope('slashing').immunity); } var testProperties = [ diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.js index fc94edd2..a45188bf 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.js @@ -12,10 +12,19 @@ export default function computeCreature(creatureId){ writeAlteredProperties(computation); writeScope(creatureId, computation.scope); } catch (e){ + const errorText = e.reason || e.message || e.toString(); computation.errors.push({ type: 'crash', - details: e.reason, + details: {error: errorText}, }); + const logError = { + creatureId, + computeError: errorText, + }; + if (e.stack){ + logError.location = e.stack.split('\n')[1]; + } + console.error(logError); } finally { writeErrors(creatureId, computation.errors); } diff --git a/app/imports/api/properties/Branches.js b/app/imports/api/properties/Branches.js index 758d88fa..952c7715 100644 --- a/app/imports/api/properties/Branches.js +++ b/app/imports/api/properties/Branches.js @@ -18,6 +18,8 @@ let BranchSchema = createPropertySchema({ 'eachTarget', // Pick one child at random 'random', + // Pick one child based on a given index + 'index', // if it has option children, asks to select one // Otherwise presents its own text with yes/no //'choice', diff --git a/app/imports/api/properties/DamageMultipliers.js b/app/imports/api/properties/DamageMultipliers.js index d40f08be..c88c4cbe 100644 --- a/app/imports/api/properties/DamageMultipliers.js +++ b/app/imports/api/properties/DamageMultipliers.js @@ -1,5 +1,6 @@ import SimpleSchema from 'simpl-schema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; /* * DamageMultipliers are multipliers that affect how much damage is taken from @@ -20,6 +21,7 @@ let DamageMultiplierSchema = new SimpleSchema({ 'damageTypes.$': { type: String, max: STORAGE_LIMITS.calculation, + regEx: VARIABLE_NAME_REGEX, }, // The value of the damage multiplier value: { diff --git a/app/imports/api/properties/Damages.js b/app/imports/api/properties/Damages.js index 12e55623..85cafee0 100644 --- a/app/imports/api/properties/Damages.js +++ b/app/imports/api/properties/Damages.js @@ -1,6 +1,7 @@ import SimpleSchema from 'simpl-schema'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; const DamageSchema = createPropertySchema({ // The roll that determines how much to damage the attribute @@ -24,6 +25,7 @@ const DamageSchema = createPropertySchema({ type: String, max: STORAGE_LIMITS.calculation, defaultValue: 'slashing', + regEx: VARIABLE_NAME_REGEX, }, }); diff --git a/app/imports/api/properties/Rolls.js b/app/imports/api/properties/Rolls.js index 5acc0ce7..03e819a4 100644 --- a/app/imports/api/properties/Rolls.js +++ b/app/imports/api/properties/Rolls.js @@ -41,6 +41,11 @@ let RollSchema = createPropertySchema({ parseLevel: 'compile', optional: true, }, + // Prevent the roll from showing up in the log + silent: { + type: Boolean, + optional: true, + }, }); let ComputedOnlyRollSchema = createPropertySchema({ diff --git a/app/imports/api/simpleSchemaConfig.js b/app/imports/api/simpleSchemaConfig.js index aff36261..6b960a40 100644 --- a/app/imports/api/simpleSchemaConfig.js +++ b/app/imports/api/simpleSchemaConfig.js @@ -1,4 +1,9 @@ import SimpleSchema from 'simpl-schema'; +import { set } from 'lodash'; + +set(Meteor.settings, + 'packages.collection2.disableCollectionNamesInValidation', + true); SimpleSchema.extendOptions([ 'parseLevel', diff --git a/app/imports/migrations/methods/index.js b/app/imports/migrations/methods/index.js index 6d5dc8c5..ed6bbb4e 100644 --- a/app/imports/migrations/methods/index.js +++ b/app/imports/migrations/methods/index.js @@ -1,2 +1,3 @@ import './migrateTo.js'; +import './validateDatabase.js'; import './getVersion.js'; diff --git a/app/imports/migrations/methods/migrateTo.js b/app/imports/migrations/methods/migrateTo.js index c5248c9d..f61fb9b3 100644 --- a/app/imports/migrations/methods/migrateTo.js +++ b/app/imports/migrations/methods/migrateTo.js @@ -16,8 +16,8 @@ const migrateTo = new ValidatedMethod({ }).validator(), mixins: [RateLimiterMixin], rateLimit: { - numRequests: 5, - timeInterval: 5000, + numRequests: 1, + timeInterval: 10000, }, run({version}) { if (Meteor.isClient) return; diff --git a/app/imports/migrations/methods/validateDatabase.js b/app/imports/migrations/methods/validateDatabase.js new file mode 100644 index 00000000..c555f3c5 --- /dev/null +++ b/app/imports/migrations/methods/validateDatabase.js @@ -0,0 +1,35 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js'; + +const validateDatabase = new ValidatedMethod({ + name: 'validateDatabase', + validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 10000, + }, + run() { + assertAdmin(this.userId); + // Very computationally expensive data diagnostics + // Only run in an offline instance you control + return; + if (Meteor.isClient) return; + + Meteor.Collection.getAll().forEach(collection => { + if (!collection.instance._c2?._simpleSchemas) return; + collection.instance.find({}).forEach(doc => { + const schema = collection.instance.simpleSchema(doc); + let cleanDoc = schema.clean(doc); + try { + schema.validate(cleanDoc, {modifier: false}); + } catch (e){ + console.log(collection.name, doc._id, e.message || e.reason || e.toString()); + } + }); + }); + }, +}); + +export default validateDatabase; diff --git a/app/imports/migrations/server/dbv1/cleanAt1.js b/app/imports/migrations/server/dbv1/cleanAt1.js new file mode 100644 index 00000000..d58e3c74 --- /dev/null +++ b/app/imports/migrations/server/dbv1/cleanAt1.js @@ -0,0 +1,39 @@ +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import { get, set } from 'lodash'; +import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey.js'; +import { calculationUp } from '/imports/migrations/server/dbv1/dbv1.js'; + +export default function cleanAt1(archive){ + archive.properties = archive.properties.map(prop => { + let cleanProp = prop; + try { + if (prop.type === 'attack') prop.type = 'action'; + // Get the schema + const schema = CreatureProperties.simpleSchema(prop); + // Clean all the text fields with inline calcs + schema.inlineCalculationFields().forEach(key => { + applyFnToKey(prop, key, (prop, key) => { + let field = get(prop, key); + if (typeof field === 'string' || typeof field === 'number'){ + field = calculationUp(field); + set(prop, key, {text: `${field}`}); + } + }); + }); + schema.computedFields().forEach(key => { + applyFnToKey(prop, key, (prop, key) => { + let field = get(prop, key) || get(prop, key + 'Calculation'); + if (typeof field === 'string' || typeof field === 'number'){ + field = calculationUp(field); + set(prop, key, {calculation: `${field}`}); + } + }); + }); + cleanProp = schema.clean(prop); + schema.validate(cleanProp); + } catch (e){ + console.warn({propId: prop._id, error: e.message || e.reason || e.toString()}); + } + return cleanProp; + }); +} diff --git a/app/imports/migrations/server/dbv1/dbv1.js b/app/imports/migrations/server/dbv1/dbv1.js index d299fc58..b265cd9d 100644 --- a/app/imports/migrations/server/dbv1/dbv1.js +++ b/app/imports/migrations/server/dbv1/dbv1.js @@ -215,16 +215,17 @@ function getInlineComputationTransforms(key){ ]; } -function calculationUp(val){ +export function calculationUp(val){ if (typeof val !== 'string') return val; + if (!val.replace) console.log({val, replace: val.replace}); return val.replace(/#(\w+).(\w+)Result/g, '#$1.$2') - .replace('.value', '.total') - .replace('.currentValue', '.value'); + .replace(/\.value/g, '.total') + .replace(/\.currentValue/g, '.value'); } function calculationDown(val){ if (typeof val !== 'string') return val; - return val.replace('.value', '.currentValue').replace('.total', '.value'); + return val.replace(/\.value/g, '.currentValue').replace(/\.total/g, '.value'); } function nanToNull(val){ diff --git a/app/imports/migrations/server/migrateArchive.js b/app/imports/migrations/server/migrateArchive.js index 42f04c26..0fb557ed 100644 --- a/app/imports/migrations/server/migrateArchive.js +++ b/app/imports/migrations/server/migrateArchive.js @@ -1,4 +1,4 @@ -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import cleanAt1 from '/imports/migrations/server/dbv1/cleanAt1.js'; /* eslint no-fallthrough: "off" -- Using switch fallthrough to run all migration steps after the current version of the file. */ @@ -17,12 +17,3 @@ function migrateLegacyArchive(archive){ // TODO: throw 'Not implemented'; } - -function cleanAt1(archive){ - archive.properties.map(prop => { - const schema = CreatureProperties.simpleSchema(prop); - const cleanProp = schema.clean(prop); - schema.validate(cleanProp); - return cleanProp; - }); -} diff --git a/app/imports/ui/components/global/SmartCombobox.vue b/app/imports/ui/components/global/SmartCombobox.vue index 102931d0..07931531 100644 --- a/app/imports/ui/components/global/SmartCombobox.vue +++ b/app/imports/ui/components/global/SmartCombobox.vue @@ -7,6 +7,7 @@ :menu-props="{auto: true, lazy: true}" :search-input.sync="searchInput" :disabled="isDisabled" + :multiple="multiple" outlined @change="customChange" @focus="focused = true" @@ -24,12 +25,28 @@ export default { mixins: [SmartInput], + props: { + multiple: Boolean, + }, data(){ return { searchInput: '', }}, + computed: { + // Multiple combobox gets a long default debounce time while single + // value gets a shorter one + debounceTime() { + if (Number.isFinite(this.debounce)){ + return this.debounce; + } else if (Number.isFinite(this.context.debounceTime)){ + return this.context.debounceTime; + } else { + return this.multiple ? 1000 : 100; + } + }, + }, methods: { customChange(val){ - this.change(val); + this.input(val); this.searchInput = ''; }, } diff --git a/app/imports/ui/components/global/SmartInputMixin.js b/app/imports/ui/components/global/SmartInputMixin.js index 5df4f5b2..5e44cc7b 100644 --- a/app/imports/ui/components/global/SmartInputMixin.js +++ b/app/imports/ui/components/global/SmartInputMixin.js @@ -16,6 +16,7 @@ export default { data(){ return { error: false, ackErrors: null, + rulesErrors: null, focused: false, loading: false, dirty: false, @@ -30,6 +31,7 @@ export default { type: Number, default: undefined, }, + rules: Array, }, watch: { focused(newFocus){ @@ -42,7 +44,11 @@ export default { // Start the loading bar on defocus if the input is dirty // It might be a lie, we aren't doing the work yet, but it feels laggy // to defocus an element and then it starts working after a delay - if (!newFocus && this.dirty){ + if ( + !newFocus && + this.dirty && + !(this.rulesErrors && this.rulesErrors.length) + ){ if (this.hasChangeListener) this.loading = true; } }, @@ -54,7 +60,10 @@ export default { } }, value(newValue){ - if (!this.focused){ + if ( + !this.focused && + !(this.rulesErrors && this.rulesErrors.length) + ){ this.safeValue = newValue; } }, @@ -69,6 +78,22 @@ export default { this.$emit('input', val); this.inputValue = val; this.dirty = true; + + // Apply the rules if there are any + this.rulesErrors = null; + if (this.rules && this.rules.length){ + this.rules.forEach(rule => { + const result = rule(val); + if (typeof result === 'string'){ + if (!this.rulesErrors) this.rulesErrors = []; + this.rulesErrors.push(result); + } + }); + } + if (this.rulesErrors){ + return; + } + this.debouncedChange(val); }, acknowledgeChange(error){ @@ -81,6 +106,8 @@ export default { this.ackErrors = error; } else if (error.reason){ this.ackErrors = error.reason; + } else if (error.message){ + this.ackErrors = error.message; } else { this.ackErrors = 'Something went wrong' console.error(error); @@ -106,6 +133,9 @@ export default { computed: { errors(){ let errors = this.ackErrors ? [this.ackErrors] : []; + if (Array.isArray(this.rulesErrors)){ + errors.push(...this.rulesErrors) + } if (Array.isArray(this.errorMessages)){ errors.push(...this.errorMessages); } else if (typeof this.errorMessages === 'string' && this.errorMessages){ diff --git a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue index 9d8e2e3f..9862591f 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -22,6 +22,12 @@ + +
-
- -
-
{ @@ -511,10 +521,6 @@ damageProperty.call({_id, operation: 'increment' ,value: -value}); } }, - numKeys(obj){ - if (!obj) return 0; - return Object.keys(obj).length; - }, softRemove(_id){ softRemoveProperty.call({_id}, error => { if (error) console.error(error); @@ -531,7 +537,7 @@ if (!spellId) return; doCastSpell.call({spellId, slotId}, error => { if (!error) return; - snackbar({text: error.reason}); + snackbar({text: error.reason || error.message || error.toString()}); console.error(error); }); }, diff --git a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue index 81106f65..64f684ff 100644 --- a/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue +++ b/app/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue @@ -220,36 +220,21 @@ export default { }, change({path, value, ack}){ if (path && path[0] === 'equipped'){ - equipItem.call({_id: this.currentId, equipped: value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + equipItem.call({_id: this.currentId, equipped: value}, ack); return; } - updateCreatureProperty.call({_id: this.currentId, path, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + updateCreatureProperty.call({_id: this.currentId, path, value}, ack); }, damage({operation, value, ack}){ - damageProperty.call({_id: this.currentId, operation, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + damageProperty.call({_id: this.currentId, operation, value}, ack); }, push({path, value, ack}){ - pushToProperty.call({_id: this.currentId, path, value}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + pushToProperty.call({_id: this.currentId, path, value}, ack); }, pull({path, ack}){ let itemId = get(this.model, path)._id; path.pop(); - pullFromProperty.call({_id: this.currentId, path, itemId}, (error) =>{ - if (error) console.warn(error); - ack && ack(error && error.reason || error); - }); + pullFromProperty.call({_id: this.currentId, path, itemId}, ack); }, remove(){ const _id = this.currentId; diff --git a/app/imports/ui/log/LogContent.vue b/app/imports/ui/log/LogContent.vue index 658a6ec4..8c109c23 100644 --- a/app/imports/ui/log/LogContent.vue +++ b/app/imports/ui/log/LogContent.vue @@ -5,7 +5,10 @@ :key="index" class="content-line" > -

+

{{ content.name }}

+
@@ -36,7 +43,7 @@ export default {