From 545050cfa3d1f77de886b4e6f64e8939623de61d Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Fri, 4 Mar 2022 16:09:00 +0200 Subject: [PATCH 01/16] Fixed attack disadvantage being treated as advantage --- .../api/engine/actions/applyPropertyByType/applyAction.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index a92255c1..7b3b3a2e 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js @@ -127,7 +127,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 +136,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; From 2442ae4fa023c7c568abb64815e0b2a5ad599a9c Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 16:23:21 +0200 Subject: [PATCH 02/16] Overhauled damage multipliers UX Form and viewer revamp custom damage types Variables: `bludgeoning.resistance` --- .../api/creature/creatures/Creatures.js | 1 + .../buildComputation/linkTypeDependencies.js | 4 +- .../computeByType/computeVariable.js | 42 ++-- .../aggregate/aggregateDamageMultiplier.js | 28 ++- .../computeImplicitVariable.js | 15 ++ .../computeVariableAsAttribute.js | 2 +- .../computeVariable/getAggregatorResult.js | 7 +- app/imports/api/engine/computeCreature.js | 3 +- .../api/properties/DamageMultipliers.js | 2 + app/imports/api/properties/Damages.js | 2 + .../ui/components/global/SmartCombobox.vue | 23 ++- .../ui/components/global/SmartInputMixin.js | 32 ++- .../character/characterSheetTabs/StatsTab.vue | 31 +-- .../DamageMultiplierCard.vue | 140 ++++++++------ .../ui/properties/forms/DamageForm.vue | 13 +- .../properties/forms/DamageMultiplierForm.vue | 183 ++++++++++-------- .../viewers/DamageMultiplierViewer.vue | 50 ++++- 17 files changed, 388 insertions(+), 190 deletions(-) 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/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/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/computeCreature.js b/app/imports/api/engine/computeCreature.js index fc94edd2..611597c8 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.js @@ -14,8 +14,9 @@ export default function computeCreature(creatureId){ } catch (e){ computation.errors.push({ type: 'crash', - details: e.reason, + details: e.reason || e.message || e.toString(), }); + console.error(e); } finally { writeErrors(creatureId, computation.errors); } 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/ui/components/global/SmartCombobox.vue b/app/imports/ui/components/global/SmartCombobox.vue index 102931d0..4679fbde 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,32 @@ export default { mixins: [SmartInput], + props: { + multiple: Boolean, + }, data(){ return { searchInput: '', }}, + computed: { + // This component gets a longer default debounce time because it's all + // clicking no typing + debounceTime() { + if (Number.isFinite(this.debounce)){ + return this.debounce; + } else if (Number.isFinite(this.context.debounceTime)){ + return this.context.debounceTime; + } else { + return 1000; + } + }, + }, methods: { customChange(val){ - this.change(val); + if (this.multiple){ + this.input(val); + } else { + this.change(val); + } this.searchInput = ''; }, } diff --git a/app/imports/ui/components/global/SmartInputMixin.js b/app/imports/ui/components/global/SmartInputMixin.js index 5df4f5b2..66ebb058 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){ @@ -106,6 +131,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..59d3f5d4 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -22,6 +22,11 @@ + +
-
- -
-
{ @@ -511,10 +520,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); diff --git a/app/imports/ui/properties/components/damageMultipliers/DamageMultiplierCard.vue b/app/imports/ui/properties/components/damageMultipliers/DamageMultiplierCard.vue index c50048ca..c40b18de 100644 --- a/app/imports/ui/properties/components/damageMultipliers/DamageMultiplierCard.vue +++ b/app/imports/ui/properties/components/damageMultipliers/DamageMultiplierCard.vue @@ -1,72 +1,96 @@ diff --git a/app/imports/ui/properties/forms/DamageForm.vue b/app/imports/ui/properties/forms/DamageForm.vue index b56a913d..7acf3e36 100644 --- a/app/imports/ui/properties/forms/DamageForm.vue +++ b/app/imports/ui/properties/forms/DamageForm.vue @@ -23,6 +23,7 @@ label="Damage Type" style="flex-basis: 200px;" hint="Use the Healing type to restore hit points" + :rules="damageTypeRules" :items="DAMAGE_TYPES" :value="model.damageType" :error-messages="errors.damageType" @@ -46,7 +47,8 @@ multiple chips deletable-chips - hint="Used to let slots find this property in a library, should otherwise be left blank" + hint="" + :items="['magical', 'silvered']" :value="model.tags" :error-messages="errors.tags" @change="change('tags', ...arguments)" @@ -57,6 +59,7 @@ diff --git a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue index 70b746bf..79a31b77 100644 --- a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue +++ b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue @@ -2,13 +2,57 @@
+ > + + {{ damageType }} + + + + + {{ damageType }} + + + + + {{ damageType }} + +
From a19e7d05141838d8d54852d31896b3f2bbfd9770 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 16:24:42 +0200 Subject: [PATCH 03/16] Prevented errors from crashing archive restoration --- app/imports/migrations/server/migrateArchive.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/imports/migrations/server/migrateArchive.js b/app/imports/migrations/server/migrateArchive.js index 42f04c26..52403790 100644 --- a/app/imports/migrations/server/migrateArchive.js +++ b/app/imports/migrations/server/migrateArchive.js @@ -22,7 +22,12 @@ function cleanAt1(archive){ archive.properties.map(prop => { const schema = CreatureProperties.simpleSchema(prop); const cleanProp = schema.clean(prop); - schema.validate(cleanProp); + try { + schema.validate(cleanProp); + } catch (e){ + console.warn('Prop did not pass schema validation'); + console.warn(e); + } return cleanProp; }); } From 7dcd0aeff26ade6a01594045c5fb8a95850b331c Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 16:30:53 +0200 Subject: [PATCH 04/16] Fixed single-select combobox not showing rules errors --- app/imports/ui/components/global/SmartCombobox.vue | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/imports/ui/components/global/SmartCombobox.vue b/app/imports/ui/components/global/SmartCombobox.vue index 4679fbde..07931531 100644 --- a/app/imports/ui/components/global/SmartCombobox.vue +++ b/app/imports/ui/components/global/SmartCombobox.vue @@ -32,25 +32,21 @@ searchInput: '', }}, computed: { - // This component gets a longer default debounce time because it's all - // clicking no typing + // 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 1000; + return this.multiple ? 1000 : 100; } }, }, methods: { customChange(val){ - if (this.multiple){ - this.input(val); - } else { - this.change(val); - } + this.input(val); this.searchInput = ''; }, } From a8ebf6a1de5f8bffd48c0e1bdd6b9e0197db4436 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 17:48:52 +0200 Subject: [PATCH 05/16] Tags now wrap in damage multiplier viewer --- .../ui/properties/viewers/DamageMultiplierViewer.vue | 12 ++++++------ .../ui/properties/viewers/shared/PropertyField.vue | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue index 79a31b77..acb7981b 100644 --- a/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue +++ b/app/imports/ui/properties/viewers/DamageMultiplierViewer.vue @@ -7,12 +7,12 @@ />
({cols: 12, sm: 6, md: 4}), From 782f2cdc73c96c3b758575ad0e79622593b343f9 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 17:52:15 +0200 Subject: [PATCH 06/16] Added default tags to properties #type, damageType, skillType, attributeType, reset --- .../engine/computation/CreatureComputation.js | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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); From 10242b596fa8e462d49e286577874da2a2fdc719 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 17:59:50 +0200 Subject: [PATCH 07/16] Updated test cases to account for new damage multipliers --- .../tests/computeDamageMultipliers.testFn.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 = [ From 73ca6dc36452979646d0c9aa56f9a25c47daa514 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Sat, 5 Mar 2022 18:40:18 +0200 Subject: [PATCH 08/16] Damage multipliers are now applied to damage dealt --- .../applyPropertyByType/applyDamage.js | 62 ++++++++++++++++++- .../viewers/shared/PropertyField.vue | 3 +- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index 5e2153fc..acf42475 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'; @@ -62,13 +63,16 @@ export default function applyDamage(node, { 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)){ 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 +82,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 +126,51 @@ export default function applyDamage(node, { }); return applyChildren(); } + +function applyDamageMultipliers({target, damage, damageProp, logValue}){ + const damageType = damageProp?.damageType; + if (!damageType) return; + + const multiplier = target?.variables?.[damageType]; + if (!multiplier) return; + + 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/ui/properties/viewers/shared/PropertyField.vue b/app/imports/ui/properties/viewers/shared/PropertyField.vue index 4ec09bba..fa48a003 100644 --- a/app/imports/ui/properties/viewers/shared/PropertyField.vue +++ b/app/imports/ui/properties/viewers/shared/PropertyField.vue @@ -19,7 +19,7 @@ {{ name }}
Date: Sat, 5 Mar 2022 18:56:34 +0200 Subject: [PATCH 09/16] Hid damage multiplier card if there are no multipliers --- .../ui/creature/character/characterSheetTabs/StatsTab.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue index 59d3f5d4..332b5268 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -23,6 +23,7 @@
From e7f718c785ad99198f6ce219e5d7bb7555d6efc4 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 8 Mar 2022 13:15:48 +0200 Subject: [PATCH 10/16] Prevented updates from running on the server if they fail client validation --- app/imports/api/simpleSchemaConfig.js | 5 + .../ui/components/global/SmartInputMixin.js | 2 + .../CreaturePropertyDialog.vue | 25 +- .../collection2/.npm/package/.gitignore | 1 + app/packages/collection2/.npm/package/README | 7 + .../.npm/package/npm-shrinkwrap.json | 20 + app/packages/collection2/.versions | 50 ++ app/packages/collection2/collection2.js | 739 ++++++++++++++++++ app/packages/collection2/lib.js | 31 + app/packages/collection2/package.js | 33 + 10 files changed, 893 insertions(+), 20 deletions(-) create mode 100644 app/packages/collection2/.npm/package/.gitignore create mode 100644 app/packages/collection2/.npm/package/README create mode 100644 app/packages/collection2/.npm/package/npm-shrinkwrap.json create mode 100644 app/packages/collection2/.versions create mode 100644 app/packages/collection2/collection2.js create mode 100644 app/packages/collection2/lib.js create mode 100644 app/packages/collection2/package.js 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/ui/components/global/SmartInputMixin.js b/app/imports/ui/components/global/SmartInputMixin.js index 66ebb058..5e44cc7b 100644 --- a/app/imports/ui/components/global/SmartInputMixin.js +++ b/app/imports/ui/components/global/SmartInputMixin.js @@ -106,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); 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/packages/collection2/.npm/package/.gitignore b/app/packages/collection2/.npm/package/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/app/packages/collection2/.npm/package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/app/packages/collection2/.npm/package/README b/app/packages/collection2/.npm/package/README new file mode 100644 index 00000000..3d492553 --- /dev/null +++ b/app/packages/collection2/.npm/package/README @@ -0,0 +1,7 @@ +This directory and the files immediately inside it are automatically generated +when you change this package's NPM dependencies. Commit the files in this +directory (npm-shrinkwrap.json, .gitignore, and this README) to source control +so that others run the same versions of sub-dependencies. + +You should NOT check in the node_modules directory that Meteor automatically +creates; if you are using git, the .gitignore file tells git to ignore it. diff --git a/app/packages/collection2/.npm/package/npm-shrinkwrap.json b/app/packages/collection2/.npm/package/npm-shrinkwrap.json new file mode 100644 index 00000000..7c7e4cf6 --- /dev/null +++ b/app/packages/collection2/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "dependencies": { + "lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, + "lodash.isobject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", + "integrity": "sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=" + } + } +} diff --git a/app/packages/collection2/.versions b/app/packages/collection2/.versions new file mode 100644 index 00000000..3b51faec --- /dev/null +++ b/app/packages/collection2/.versions @@ -0,0 +1,50 @@ +aldeed:collection2@3.5.0 +allow-deny@1.1.0 +babel-compiler@7.7.0 +babel-runtime@1.5.0 +base64@1.0.12 +binary-heap@1.0.11 +boilerplate-generator@1.7.1 +callback-hook@1.3.1 +check@1.3.1 +ddp@1.4.0 +ddp-client@2.5.0 +ddp-common@1.4.0 +ddp-server@2.4.0 +diff-sequence@1.1.1 +dynamic-import@0.7.1 +ecmascript@0.15.3 +ecmascript-runtime@0.7.0 +ecmascript-runtime-client@0.11.1 +ecmascript-runtime-server@0.10.1 +ejson@1.1.1 +fetch@0.1.1 +geojson-utils@1.0.10 +id-map@1.1.1 +inter-process-messaging@0.1.1 +logging@1.2.0 +meteor@1.9.3 +minimongo@1.7.0 +modern-browsers@0.1.5 +modules@0.16.0 +modules-runtime@0.12.0 +mongo@1.12.0 +mongo-decimal@0.1.2 +mongo-dev-server@1.1.0 +mongo-id@1.0.8 +npm-mongo@3.9.1 +ordered-dict@1.1.0 +promise@0.12.0 +raix:eventemitter@1.0.0 +random@1.2.0 +react-fast-refresh@0.1.1 +reload@1.3.1 +retry@1.1.0 +routepolicy@1.1.1 +socket-stream-client@0.4.0 +tmeasday:check-npm-versions@1.0.2 +tracker@1.2.0 +typescript@4.3.5 +underscore@1.0.10 +webapp@1.11.1 +webapp-hashing@1.1.0 diff --git a/app/packages/collection2/collection2.js b/app/packages/collection2/collection2.js new file mode 100644 index 00000000..422dbf95 --- /dev/null +++ b/app/packages/collection2/collection2.js @@ -0,0 +1,739 @@ +import { EventEmitter } from 'meteor/raix:eventemitter'; +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; +import { EJSON } from 'meteor/ejson'; +import isEmpty from 'lodash.isempty'; +import isEqual from 'lodash.isequal'; +import isObject from 'lodash.isobject'; +import { flattenSelector } from './lib'; + +checkNpmVersions({ 'simpl-schema': '>=0.0.0' }, 'aldeed:collection2'); + +const SimpleSchema = require('simpl-schema').default; + +// Exported only for listening to events +const Collection2 = new EventEmitter(); + +Collection2.cleanOptions = { + filter: true, + autoConvert: true, + removeEmptyStrings: true, + trimStrings: true, + removeNullsFromArrays: false, +}; + +/** + * Mongo.Collection.prototype.attachSchema + * @param {SimpleSchema|Object} ss - SimpleSchema instance or a schema definition object + * from which to create a new SimpleSchema instance + * @param {Object} [options] + * @param {Boolean} [options.transform=false] Set to `true` if your document must be passed + * through the collection's transform to properly validate. + * @param {Boolean} [options.replace=false] Set to `true` to replace any existing schema instead of combining + * @return {undefined} + * + * Use this method to attach a schema to a collection created by another package, + * such as Meteor.users. It is most likely unsafe to call this method more than + * once for a single collection, or to call this for a collection that had a + * schema object passed to its constructor. + */ +Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { + options = options || {}; + + // Allow passing just the schema object + if (!SimpleSchema.isSimpleSchema(ss)) { + ss = new SimpleSchema(ss); + } + + function attachTo(obj) { + // we need an array to hold multiple schemas + // position 0 is reserved for the "base" schema + obj._c2 = obj._c2 || {}; + obj._c2._simpleSchemas = obj._c2._simpleSchemas || [ null ]; + + if (typeof options.selector === 'object') { + // Selector Schemas + + // Extend selector schema with base schema + const baseSchema = obj._c2._simpleSchemas[0]; + if (baseSchema) { + ss = extendSchema(baseSchema.schema, ss); + } + + // Index of existing schema with identical selector + let schemaIndex; + + // Loop through existing schemas with selectors, + for (schemaIndex = obj._c2._simpleSchemas.length - 1; 0 < schemaIndex; schemaIndex--) { + const schema = obj._c2._simpleSchemas[schemaIndex]; + if (schema && isEqual(schema.selector, options.selector)) break; + } + + if (schemaIndex <= 0) { + // We didn't find the schema in our array - push it into the array + obj._c2._simpleSchemas.push({ + schema: ss, + selector: options.selector, + }); + } else { + // We found a schema with an identical selector in our array, + if (options.replace === true) { + // Replace existing selector schema with new selector schema + obj._c2._simpleSchemas[schemaIndex].schema = ss; + } else { + // Extend existing selector schema with new selector schema. + obj._c2._simpleSchemas[schemaIndex].schema = extendSchema(obj._c2._simpleSchemas[schemaIndex].schema, ss); + } + } + } else { + // Base Schema + if (options.replace === true) { + // Replace base schema and delete all other schemas + obj._c2._simpleSchemas = [{ + schema: ss, + selector: options.selector, + }]; + } else { + // Set base schema if not yet set + if (!obj._c2._simpleSchemas[0]) { + return obj._c2._simpleSchemas[0] = { schema: ss, selector: undefined }; + } + // Extend base schema and therefore extend all schemas + obj._c2._simpleSchemas.forEach((schema, index) => { + if (obj._c2._simpleSchemas[index]) { + obj._c2._simpleSchemas[index].schema = extendSchema(obj._c2._simpleSchemas[index].schema, ss); + } + }); + } + } + } + + attachTo(this); + // Attach the schema to the underlying LocalCollection, too + if (this._collection instanceof LocalCollection) { + this._collection._c2 = this._collection._c2 || {}; + attachTo(this._collection); + } + + defineDeny(this, options); + keepInsecure(this); + + Collection2.emit('schema.attached', this, ss, options); +}; + +[Mongo.Collection, LocalCollection].forEach((obj) => { + /** + * simpleSchema + * @description function detect the correct schema by given params. If it + * detect multi-schema presence in the collection, then it made an attempt to find a + * `selector` in args + * @param {Object} doc - It could be on update/upsert or document + * itself on insert/remove + * @param {Object} [options] - It could be on update/upsert etc + * @param {Object} [query] - it could be on update/upsert + * @return {Object} Schema + */ + obj.prototype.simpleSchema = function (doc, options, query) { + if (!this._c2) return null; + if (this._c2._simpleSchema) return this._c2._simpleSchema; + + const schemas = this._c2._simpleSchemas; + if (schemas && schemas.length > 0) { + + let schema, selector, target; + // Position 0 reserved for base schema + for (var i = 1; i < schemas.length; i++) { + schema = schemas[i]; + selector = Object.keys(schema.selector)[0]; + + // We will set this to undefined because in theory you might want to select + // on a null value. + target = undefined; + // here we are looking for selector in different places + // $set should have more priority here + if (doc.$set && typeof doc.$set[selector] !== 'undefined') { + target = doc.$set[selector]; + } else if (typeof doc[selector] !== 'undefined') { + target = doc[selector]; + } else if (options && options.selector) { + target = options.selector[selector]; + } else if (query && query[selector]) { // on upsert/update operations + target = query[selector]; + } + + // we need to compare given selector with doc property or option to + // find right schema + if (target !== undefined && target === schema.selector[selector]) { + return schema.schema; + } + } + if (schemas[0]) { + return schemas[0].schema; + } else { + throw new Error('No default schema'); + } + } + + return null; + }; +}); + +// Wrap DB write operation methods +['insert', 'update'].forEach((methodName) => { + const _super = Mongo.Collection.prototype[methodName]; + Mongo.Collection.prototype[methodName] = function(...args) { + let options = (methodName === 'insert') ? args[1] : args[2]; + + // Support missing options arg + if (!options || typeof options === 'function') { + options = {}; + } + + if (this._c2 && options.bypassCollection2 !== true) { + let userId = null; + try { // https://github.com/aldeed/meteor-collection2/issues/175 + userId = Meteor.userId(); + } catch (err) {} + + args = doValidate( + this, + methodName, + args, + Meteor.isServer || this._connection === null, // getAutoValues + userId, + Meteor.isServer // isFromTrustedCode + ); + if (!args) { + // doValidate already called the callback or threw the error so we're done. + // But insert should always return an ID to match core behavior. + return methodName === 'insert' ? this._makeNewID() : undefined; + } + } else { + // We still need to adjust args because insert does not take options + if (methodName === 'insert' && typeof args[1] !== 'function') args.splice(1, 1); + } + + return _super.apply(this, args); + }; +}); + +/* + * Private + */ + +function doValidate(collection, type, args, getAutoValues, userId, isFromTrustedCode) { + let doc, callback, error, options, isUpsert, selector, last, hasCallback; + + if (!args.length) { + throw new Error(type + ' requires an argument'); + } + + // Gather arguments and cache the selector + if (type === 'insert') { + doc = args[0]; + options = args[1]; + callback = args[2]; + + // The real insert doesn't take options + if (typeof options === 'function') { + args = [doc, options]; + } else if (typeof callback === 'function') { + args = [doc, callback]; + } else { + args = [doc]; + } + } else if (type === 'update') { + selector = args[0]; + doc = args[1]; + options = args[2]; + callback = args[3]; + } else { + throw new Error('invalid type argument'); + } + + const validatedObjectWasInitiallyEmpty = isEmpty(doc); + + // Support missing options arg + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + + last = args.length - 1; + + hasCallback = (typeof args[last] === 'function'); + + // If update was called with upsert:true, flag as an upsert + isUpsert = (type === 'update' && options.upsert === true); + + // we need to pass `doc` and `options` to `simpleSchema` method, that's why + // schema declaration moved here + let schema = collection.simpleSchema(doc, options, selector); + const isLocalCollection = (collection._connection === null); + + // On the server and for local collections, we allow passing `getAutoValues: false` to disable autoValue functions + if ((Meteor.isServer || isLocalCollection) && options.getAutoValues === false) { + getAutoValues = false; + } + + // Process pick/omit options if they are present + const picks = Array.isArray(options.pick) ? options.pick : null; + const omits = Array.isArray(options.omit) ? options.omit : null; + + if (picks && omits) { + // Pick and omit cannot both be present in the options + throw new Error('pick and omit options are mutually exclusive'); + } else if (picks) { + schema = schema.pick(...picks); + } else if (omits) { + schema = schema.omit(...omits); + } + + // Determine validation context + let validationContext = options.validationContext; + if (validationContext) { + if (typeof validationContext === 'string') { + validationContext = schema.namedContext(validationContext); + } + } else { + validationContext = schema.namedContext(); + } + + // Add a default callback function if we're on the client and no callback was given + /* + if (Meteor.isClient && !callback) { + // Client can't block, so it can't report errors by exception, + // only by callback. If they forget the callback, give them a + // default one that logs the error, so they aren't totally + // baffled if their writes don't work because their database is + // down. + callback = function(err) { + if (err) { + Meteor._debug(type + " failed: " + (err.reason || err.stack)); + } + }; + } + */ + + // If client validation is fine or is skipped but then something + // is found to be invalid on the server, we get that error back + // as a special Meteor.Error that we need to parse. + if (Meteor.isClient && hasCallback) { + callback = args[last] = wrapCallbackForParsingServerErrors(validationContext, callback); + } + + const schemaAllowsId = schema.allowsKey('_id'); + if (type === 'insert' && !doc._id && schemaAllowsId) { + doc._id = collection._makeNewID(); + } + + // Get the docId for passing in the autoValue/custom context + let docId; + if (type === 'insert') { + docId = doc._id; // might be undefined + } else if (type === 'update' && selector) { + docId = typeof selector === 'string' || selector instanceof Mongo.ObjectID ? selector : selector._id; + } + + // If _id has already been added, remove it temporarily if it's + // not explicitly defined in the schema. + let cachedId; + if (doc._id && !schemaAllowsId) { + cachedId = doc._id; + delete doc._id; + } + + const autoValueContext = { + isInsert: (type === 'insert'), + isUpdate: (type === 'update' && options.upsert !== true), + isUpsert, + userId, + isFromTrustedCode, + docId, + isLocalCollection + }; + + const extendAutoValueContext = { + ...((schema._cleanOptions || {}).extendAutoValueContext || {}), + ...autoValueContext, + ...options.extendAutoValueContext, + }; + + const cleanOptionsForThisOperation = {}; + ['autoConvert', 'filter', 'removeEmptyStrings', 'removeNullsFromArrays', 'trimStrings'].forEach(prop => { + if (typeof options[prop] === 'boolean') { + cleanOptionsForThisOperation[prop] = options[prop]; + } + }); + + // Preliminary cleaning on both client and server. On the server and for local + // collections, automatic values will also be set at this point. + schema.clean(doc, { + mutate: true, // Clean the doc/modifier in place + isModifier: (type !== 'insert'), + // Start with some Collection2 defaults, which will usually be overwritten + ...Collection2.cleanOptions, + // The extend with the schema-level defaults (from SimpleSchema constructor options) + ...(schema._cleanOptions || {}), + // Finally, options for this specific operation should take precedence + ...cleanOptionsForThisOperation, + extendAutoValueContext, // This was extended separately above + getAutoValues, // Force this override + }); + + // We clone before validating because in some cases we need to adjust the + // object a bit before validating it. If we adjusted `doc` itself, our + // changes would persist into the database. + let docToValidate = {}; + for (var prop in doc) { + // We omit prototype properties when cloning because they will not be valid + // and mongo omits them when saving to the database anyway. + if (Object.prototype.hasOwnProperty.call(doc, prop)) { + docToValidate[prop] = doc[prop]; + } + } + + // On the server, upserts are possible; SimpleSchema handles upserts pretty + // well by default, but it will not know about the fields in the selector, + // which are also stored in the database if an insert is performed. So we + // will allow these fields to be considered for validation by adding them + // to the $set in the modifier, while stripping out query selectors as these + // don't make it into the upserted document and break validation. + // This is no doubt prone to errors, but there probably isn't any better way + // right now. + if (Meteor.isServer && isUpsert && isObject(selector)) { + const set = docToValidate.$set || {}; + docToValidate.$set = flattenSelector(selector); + + if (!schemaAllowsId) delete docToValidate.$set._id; + Object.assign(docToValidate.$set, set); + } + // Set automatic values for validation on the client. + // On the server, we already updated doc with auto values, but on the client, + // we will add them to docToValidate for validation purposes only. + // This is because we want all actual values generated on the server. + if (Meteor.isClient && !isLocalCollection) { + schema.clean(docToValidate, { + autoConvert: false, + extendAutoValueContext, + filter: false, + getAutoValues: true, + isModifier: (type !== 'insert'), + mutate: true, // Clean the doc/modifier in place + removeEmptyStrings: false, + removeNullsFromArrays: false, + trimStrings: false, + }); + } + + // XXX Maybe move this into SimpleSchema + if (!validatedObjectWasInitiallyEmpty && isEmpty(docToValidate)) { + throw new Error('After filtering out keys not in the schema, your ' + + (type === 'update' ? 'modifier' : 'object') + + ' is now empty'); + } + + // Validate doc + let isValid; + if (options.validate === false) { + isValid = true; + } else { + isValid = validationContext.validate(docToValidate, { + modifier: (type === 'update' || type === 'upsert'), + upsert: isUpsert, + extendedCustomContext: { + isInsert: (type === 'insert'), + isUpdate: (type === 'update' && options.upsert !== true), + isUpsert, + userId, + isFromTrustedCode, + docId, + isLocalCollection, + ...(options.extendedCustomContext || {}), + }, + }); + } + + if (isValid) { + // Add the ID back + if (cachedId) { + doc._id = cachedId; + } + + // Update the args to reflect the cleaned doc + // XXX not sure this is necessary since we mutate + if (type === 'insert') { + args[0] = doc; + } else { + args[1] = doc; + } + + // If callback, set invalidKey when we get a mongo unique error + if (Meteor.isServer && hasCallback) { + args[last] = wrapCallbackForParsingMongoValidationErrors(validationContext, args[last]); + } + + return args; + } else { + error = getErrorObject(validationContext, Meteor.settings?.packages?.collection2?.disableCollectionNamesInValidation ? '' : `in ${collection._name} ${type}`); + if (callback) { + // insert/update/upsert pass `false` when there's an error, so we do that + callback(error, false); + } else { + throw error; + } + } +} + +function getErrorObject(context, appendToMessage = '') { + let message; + const invalidKeys = (typeof context.validationErrors === 'function') ? context.validationErrors() : context.invalidKeys(); + if (invalidKeys.length) { + const firstErrorKey = invalidKeys[0].name; + const firstErrorMessage = context.keyErrorMessage(firstErrorKey); + + // If the error is in a nested key, add the full key to the error message + // to be more helpful. + if (firstErrorKey.indexOf('.') === -1) { + message = firstErrorMessage; + } else { + message = `${firstErrorMessage} (${firstErrorKey})`; + } + } else { + message = 'Failed validation'; + } + message = `${message} ${appendToMessage}`.trim(); + const error = new Error(message); + error.invalidKeys = invalidKeys; + error.validationContext = context; + // If on the server, we add a sanitized error, too, in case we're + // called from a method. + if (Meteor.isServer) { + error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); + } + return error; +} + +function addUniqueError(context, errorMessage) { + const name = errorMessage.split('c2_')[1].split(' ')[0]; + const val = errorMessage.split('dup key:')[1].split('"')[1]; + + const addValidationErrorsPropName = (typeof context.addValidationErrors === 'function') ? 'addValidationErrors' : 'addInvalidKeys'; + context[addValidationErrorsPropName]([{ + name: name, + type: 'notUnique', + value: val + }]); +} + +function wrapCallbackForParsingMongoValidationErrors(validationContext, cb) { + return function wrappedCallbackForParsingMongoValidationErrors(...args) { + const error = args[0]; + if (error && + ((error.name === 'MongoError' && error.code === 11001) || error.message.indexOf('MongoError: E11000') !== -1) && + error.message.indexOf('c2_') !== -1) { + addUniqueError(validationContext, error.message); + args[0] = getErrorObject(validationContext); + } + return cb.apply(this, args); + }; +} + +function wrapCallbackForParsingServerErrors(validationContext, cb) { + const addValidationErrorsPropName = (typeof validationContext.addValidationErrors === 'function') ? 'addValidationErrors' : 'addInvalidKeys'; + return function wrappedCallbackForParsingServerErrors(...args) { + const error = args[0]; + // Handle our own validation errors + if (error instanceof Meteor.Error && + error.error === 400 && + error.reason === 'INVALID' && + typeof error.details === 'string') { + const invalidKeysFromServer = EJSON.parse(error.details); + validationContext[addValidationErrorsPropName](invalidKeysFromServer); + args[0] = getErrorObject(validationContext); + } + // Handle Mongo unique index errors, which are forwarded to the client as 409 errors + else if (error instanceof Meteor.Error && + error.error === 409 && + error.reason && + error.reason.indexOf('E11000') !== -1 && + error.reason.indexOf('c2_') !== -1) { + addUniqueError(validationContext, error.reason); + args[0] = getErrorObject(validationContext); + } + return cb.apply(this, args); + }; +} + +let alreadyInsecure = {}; +function keepInsecure(c) { + // If insecure package is in use, we need to add allow rules that return + // true. Otherwise, it would seemingly turn off insecure mode. + if (Package && Package.insecure && !alreadyInsecure[c._name]) { + c.allow({ + insert: function() { + return true; + }, + update: function() { + return true; + }, + remove: function () { + return true; + }, + fetch: [], + transform: null + }); + alreadyInsecure[c._name] = true; + } + // If insecure package is NOT in use, then adding the two deny functions + // does not have any effect on the main app's security paradigm. The + // user will still be required to add at least one allow function of her + // own for each operation for this collection. And the user may still add + // additional deny functions, but does not have to. +} + +let alreadyDefined = {}; +function defineDeny(c, options) { + if (!alreadyDefined[c._name]) { + + const isLocalCollection = (c._connection === null); + + // First define deny functions to extend doc with the results of clean + // and auto-values. This must be done with "transform: null" or we would be + // extending a clone of doc and therefore have no effect. + c.deny({ + insert: function(userId, doc) { + // Referenced doc is cleaned in place + c.simpleSchema(doc).clean(doc, { + mutate: true, + isModifier: false, + // We don't do these here because they are done on the client if desired + filter: false, + autoConvert: false, + removeEmptyStrings: false, + trimStrings: false, + extendAutoValueContext: { + isInsert: true, + isUpdate: false, + isUpsert: false, + userId: userId, + isFromTrustedCode: false, + docId: doc._id, + isLocalCollection: isLocalCollection + } + }); + + return false; + }, + update: function(userId, doc, fields, modifier) { + // Referenced modifier is cleaned in place + c.simpleSchema(modifier).clean(modifier, { + mutate: true, + isModifier: true, + // We don't do these here because they are done on the client if desired + filter: false, + autoConvert: false, + removeEmptyStrings: false, + trimStrings: false, + extendAutoValueContext: { + isInsert: false, + isUpdate: true, + isUpsert: false, + userId: userId, + isFromTrustedCode: false, + docId: doc && doc._id, + isLocalCollection: isLocalCollection + } + }); + + return false; + }, + fetch: ['_id'], + transform: null + }); + + // Second define deny functions to validate again on the server + // for client-initiated inserts and updates. These should be + // called after the clean/auto-value functions since we're adding + // them after. These must *not* have "transform: null" if options.transform is true because + // we need to pass the doc through any transforms to be sure + // that custom types are properly recognized for type validation. + c.deny({ + insert: function(userId, doc) { + // We pass the false options because we will have done them on client if desired + doValidate( + c, + 'insert', + [ + doc, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function(error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + false, // getAutoValues + userId, + false // isFromTrustedCode + ); + + return false; + }, + update: function(userId, doc, fields, modifier) { + // NOTE: This will never be an upsert because client-side upserts + // are not allowed once you define allow/deny functions. + // We pass the false options because we will have done them on client if desired + doValidate( + c, + 'update', + [ + {_id: doc && doc._id}, + modifier, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function(error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + false, // getAutoValues + userId, + false // isFromTrustedCode + ); + + return false; + }, + fetch: ['_id'], + ...(options.transform === true ? {} : {transform: null}), + }); + + // note that we've already done this collection so that we don't do it again + // if attachSchema is called again + alreadyDefined[c._name] = true; + } +} + +function extendSchema(s1, s2) { + if (s2.version >= 2) { + const ss = new SimpleSchema(s1); + ss.extend(s2); + return ss; + } else { + return new SimpleSchema([ s1, s2 ]); + } +} + +export default Collection2; diff --git a/app/packages/collection2/lib.js b/app/packages/collection2/lib.js new file mode 100644 index 00000000..057195d2 --- /dev/null +++ b/app/packages/collection2/lib.js @@ -0,0 +1,31 @@ +export function flattenSelector(selector) { + // If selector uses $and format, convert to plain object selector + if (Array.isArray(selector.$and)) { + selector.$and.forEach(sel => { + Object.assign(selector, flattenSelector(sel)); + }); + + delete selector.$and + } + + const obj = {} + + Object.entries(selector).forEach(([key, value]) => { + // Ignoring logical selectors (https://docs.mongodb.com/manual/reference/operator/query/#logical) + if (!key.startsWith("$")) { + if (typeof value === 'object' && value !== null) { + if (value.$eq !== undefined) { + obj[key] = value.$eq + } else if (Array.isArray(value.$in) && value.$in.length === 1) { + obj[key] = value.$in[0] + } else if (Object.keys(value).every(v => !(typeof v === "string" && v.startsWith("$")))) { + obj[key] = value + } + } else { + obj[key] = value + } + } + }) + + return obj +} diff --git a/app/packages/collection2/package.js b/app/packages/collection2/package.js new file mode 100644 index 00000000..4177ad10 --- /dev/null +++ b/app/packages/collection2/package.js @@ -0,0 +1,33 @@ +/* global Package */ + +Package.describe({ + name: "aldeed:collection2", + summary: "Automatic validation of Meteor Mongo insert and update operations on the client and server", + version: "3.5.0", + documentation: "../../README.md", + git: "https://github.com/aldeed/meteor-collection2.git" +}); + +Npm.depends({ + 'lodash.isempty': '4.4.0', + 'lodash.isequal': '4.5.0', + 'lodash.isobject': '3.0.2', +}); + +Package.onUse(function(api) { + api.versionsFrom(['1.12.1', '2.3']); + api.use('mongo'); + api.imply('mongo'); + api.use('minimongo'); + api.use('ejson'); + api.use('raix:eventemitter@1.0.0'); + api.use('ecmascript'); + api.use('tmeasday:check-npm-versions@1.0.2'); + + // Allow us to detect 'insecure'. + api.use('insecure@1.0.7', {weak: true}); + + api.mainModule('collection2.js'); + + api.export('Collection2'); +}); From 12fc9b1be3d221a48fa03f9c577f300d16745514 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 8 Mar 2022 13:17:39 +0200 Subject: [PATCH 11/16] Added summary field back to spell form --- app/imports/ui/properties/forms/SpellForm.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/imports/ui/properties/forms/SpellForm.vue b/app/imports/ui/properties/forms/SpellForm.vue index a99e4d11..7fafb173 100644 --- a/app/imports/ui/properties/forms/SpellForm.vue +++ b/app/imports/ui/properties/forms/SpellForm.vue @@ -222,6 +222,14 @@ + Date: Tue, 8 Mar 2022 14:12:11 +0200 Subject: [PATCH 12/16] Improved handling of poorly migrated archive creatures --- .../migrations/server/dbv1/cleanAt1.js | 39 +++++++++++++++++++ app/imports/migrations/server/dbv1/dbv1.js | 9 +++-- .../migrations/server/migrateArchive.js | 16 +------- 3 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 app/imports/migrations/server/dbv1/cleanAt1.js 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 52403790..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,17 +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); - try { - schema.validate(cleanProp); - } catch (e){ - console.warn('Prop did not pass schema validation'); - console.warn(e); - } - return cleanProp; - }); -} From c68667be9c33d28412dca7a406592fa8639a8e32 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 8 Mar 2022 15:04:51 +0200 Subject: [PATCH 13/16] Added data validation diagnostics for offline use --- app/imports/migrations/methods/index.js | 1 + app/imports/migrations/methods/migrateTo.js | 4 +-- .../migrations/methods/validateDatabase.js | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 app/imports/migrations/methods/validateDatabase.js 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; From 788cbb182d85a9337e2265f97d66e59c2f6fbff0 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Wed, 9 Mar 2022 01:31:09 +0200 Subject: [PATCH 14/16] Action system improvements - Actions/spells now display their summary, not their description - All save branches and attack branches run when there are no targets - Improved action logging - Index branch lets you customise a choice of children to run --- .../applyPropertyByType/applyAction.js | 30 +++++----- .../applyPropertyByType/applyBranch.js | 38 ++++++++++-- .../actions/applyPropertyByType/applyBuff.js | 24 ++++++++ .../applyPropertyByType/applyDamage.js | 18 +++--- .../actions/applyPropertyByType/applyRoll.js | 58 +++++++++++++++---- .../applyPropertyByType/applySavingThrow.js | 27 ++++++--- .../computeVariable/computeVariableAsSkill.js | 5 +- app/imports/api/properties/Branches.js | 2 + app/imports/api/properties/Rolls.js | 5 ++ .../character/characterSheetTabs/StatsTab.vue | 2 +- app/imports/ui/log/LogContent.vue | 11 +++- .../ui/properties/forms/BranchForm.vue | 13 ++++- app/imports/ui/properties/forms/RollForm.vue | 14 +++++ .../treeNodeViews/BranchTreeNode.vue | 1 + .../ui/properties/viewers/BranchViewer.vue | 6 ++ 15 files changed, 201 insertions(+), 53 deletions(-) diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index 7b3b3a2e..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({ @@ -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 acf42475..0978d07a 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -36,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 @@ -62,11 +59,10 @@ export default function applyDamage(node, { } else { prop.amount.value = toString(reduced); } - 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(); } @@ -129,10 +125,10 @@ export default function applyDamage(node, { function applyDamageMultipliers({target, damage, damageProp, logValue}){ const damageType = damageProp?.damageType; - if (!damageType) return; + if (!damageType) return damage; const multiplier = target?.variables?.[damageType]; - if (!multiplier) return; + if (!multiplier) return damage; const damageTypeText = damageType == 'healing' ? 'healing': `${damageType} damage`; @@ -157,8 +153,8 @@ function applyDamageMultipliers({target, damage, damageProp, logValue}){ logValue.push(`Vulnerable to ${damageTypeText}`); damage = Math.floor(damage * 2); } - return damage; } + return damage; } function multiplierAppliesTo(damageProp){ 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/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js b/app/imports/api/engine/computation/computeComputation/computeByType/computeVariable/computeVariableAsSkill.js index da0ed54b..92c11ca9 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/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/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/ui/creature/character/characterSheetTabs/StatsTab.vue b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue index 332b5268..9862591f 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -537,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/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 {