From 1ec29365cbb4184181fc10ee920ff500ddc2d610 Mon Sep 17 00:00:00 2001 From: Stefan Zermatten Date: Tue, 8 Nov 2022 23:01:09 +0200 Subject: [PATCH] Added custom sheet events Made rest buttons optional --- .../api/creature/creatures/Creatures.js | 5 + .../creatures/methods/restCreature.js | 147 ++++++++++------- .../applyPropertyByType/applyAction.js | 100 ++++++------ app/imports/api/properties/Actions.js | 21 ++- app/imports/api/properties/Attributes.js | 4 +- app/imports/ui/components/ResetSelector.vue | 46 ++++++ app/imports/ui/creature/CreatureForm.vue | 5 + .../character/characterSheetTabs/StatsTab.vue | 22 ++- .../components/actions/EventButton.vue | 80 +++++++++ .../components/toggles/ToggleCard.vue | 14 +- .../ui/properties/forms/ActionForm.vue | 153 +++++++++--------- .../ui/properties/forms/AttributeForm.vue | 9 +- app/imports/ui/properties/forms/SpellForm.vue | 18 +-- .../shared/lists/createListOfProperties.js | 11 +- .../forms/shared/propertyFormIndex.js | 58 +++---- .../viewers/shared/propertyViewerIndex.js | 58 +++---- app/package.json | 5 +- 17 files changed, 481 insertions(+), 275 deletions(-) create mode 100644 app/imports/ui/components/ResetSelector.vue create mode 100644 app/imports/ui/properties/components/actions/EventButton.vue diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js index f13932ee..b94fe7e4 100644 --- a/app/imports/api/creature/creatures/Creatures.js +++ b/app/imports/api/creature/creatures/Creatures.js @@ -18,6 +18,11 @@ let CreatureSettingsSchema = new SimpleSchema({ type: Boolean, optional: true, }, + //hide rest buttons + hideRestButtons: { + type: Boolean, + optional: true, + }, // Swap around the modifier and stat swapStatAndModifier: { type: Boolean, diff --git a/app/imports/api/creature/creatures/methods/restCreature.js b/app/imports/api/creature/creatures/methods/restCreature.js index 49d228aa..b943a584 100644 --- a/app/imports/api/creature/creatures/methods/restCreature.js +++ b/app/imports/api/creature/creatures/methods/restCreature.js @@ -49,7 +49,7 @@ const restCreature = new ValidatedMethod({ applyTriggers(afterTriggers, null, actionContext); // Insert log - actionContext.writeLog(); + actionContext.writeLog(); }, }); @@ -57,88 +57,123 @@ function doRestWork(restType, actionContext) { const creatureId = actionContext.creature._id; // Long rests reset short rest properties as well let resetFilter; - if (restType === 'shortRest'){ + if (restType === 'shortRest') { resetFilter = 'shortRest' } else { - resetFilter = {$in: ['shortRest', 'longRest']} + resetFilter = { $in: ['shortRest', 'longRest'] } } + resetProperties(creatureId, resetFilter, actionContext); + + // Reset half hit dice on a long rest, starting with the highest dice + if (restType === 'longRest') { + resetHitDice(creatureId, actionContext); + } +} + +export function resetProperties(creatureId, resetFilter, actionContext) { // Only apply to active properties - let filter = { + const filter = { 'ancestors.id': creatureId, reset: resetFilter, removed: { $ne: true }, inactive: { $ne: true }, }; // update all attribute's damage - filter.type = 'attribute'; - CreatureProperties.update(filter, { + const attributeFilter = { + ...filter, + type: 'attribute', + damage: { $ne: 0 }, + } + CreatureProperties.find(attributeFilter, { + fields: { name: 1, damage: 1 } + }).forEach(prop => { + actionContext.addLog({ + name: prop.name, + value: prop.damage >= 0 ? `Restored ${prop.damage}` : `Removed ${-prop.damage}` + }); + }); + CreatureProperties.update(attributeFilter, { $set: { damage: 0, dirty: true, } }, { - selector: {type: 'attribute'}, + selector: { type: 'attribute' }, multi: true, }); // Update all action-like properties' usesUsed - filter.type = {$in: [ - 'action', - 'attack', - 'spell' - ]}; - CreatureProperties.update(filter, { + const actionFilter = { + ...filter, + type: { + $in: ['action', 'spell'] + }, + usesUsed: { $ne: 0 }, + }; + CreatureProperties.find(actionFilter, { + fields: { name: 1, usesUsed: 1 } + }).forEach(prop => { + actionContext.addLog({ + name: prop.name, + value: prop.usesUsed >= 0 ? `Restored ${prop.usesUsed} uses` : `Removed ${-prop.usesUsed} uses` + }); + }); + CreatureProperties.update(actionFilter, { $set: { usesUsed: 0, dirty: true, } }, { - selector: {type: 'action'}, + selector: { type: 'action' }, multi: true, }); - // Reset half hit dice on a long rest, starting with the highest dice - if (restType === 'longRest'){ - let hitDice = CreatureProperties.find({ - 'ancestors.id': creatureId, - type: 'attribute', - attributeType: 'hitDice', - removed: {$ne: true}, - inactive: {$ne: true}, - }, { - fields: { - hitDiceSize: 1, - damage: 1, - total: 1, +} + +function resetHitDice(creatureId, actionContext) { + let hitDice = CreatureProperties.find({ + 'ancestors.id': creatureId, + type: 'attribute', + attributeType: 'hitDice', + removed: { $ne: true }, + inactive: { $ne: true }, + }, { + fields: { + hitDiceSize: 1, + damage: 1, + total: 1, + } + }).fetch(); + // Use a collator to do sorting in natural order + let collator = new Intl.Collator('en', { + numeric: true, sensitivity: 'base' + }); + // Get the hit dice in decending order of hitDiceSize + let compare = (a, b) => collator.compare(b.hitDiceSize, a.hitDiceSize) + hitDice.sort(compare); + // Get the total number of hit dice that can be recovered this rest + let totalHd = hitDice.reduce((sum, hd) => sum + (hd.total || 0), 0); + let resetMultiplier = actionContext.creature.settings.hitDiceResetMultiplier || 0.5; + let recoverableHd = Math.max(Math.floor(totalHd * resetMultiplier), 1); + // recover each hit dice in turn until the recoverable amount is used up + let amountToRecover, resultingDamage; + hitDice.forEach(hd => { + if (!recoverableHd) return; + amountToRecover = Math.min(recoverableHd, hd.damage || 0); + if (!amountToRecover) return; + recoverableHd -= amountToRecover; + resultingDamage = hd.damage - amountToRecover; + actionContext.addLog({ + name: hd.name, + value: amountToRecover >= 0 ? `Restored ${amountToRecover} hit dice` : `Removed ${-amountToRecover} hit dice` + }); + CreatureProperties.update(hd._id, { + $set: { + damage: resultingDamage, + dirty: true, } - }).fetch(); - // Use a collator to do sorting in natural order - let collator = new Intl.Collator('en', { - numeric: true, sensitivity: 'base' + }, { + selector: { type: 'attribute' }, }); - // Get the hit dice in decending order of hitDiceSize - let compare = (a, b) => collator.compare(b.hitDiceSize, a.hitDiceSize) - hitDice.sort(compare); - // Get the total number of hit dice that can be recovered this rest - let totalHd = hitDice.reduce((sum, hd) => sum + (hd.total || 0), 0); - let resetMultiplier = actionContext.creature.settings.hitDiceResetMultiplier || 0.5; - let recoverableHd = Math.max(Math.floor(totalHd*resetMultiplier), 1); - // recover each hit dice in turn until the recoverable amount is used up - let amountToRecover, resultingDamage; - hitDice.forEach(hd => { - if (!recoverableHd) return; - amountToRecover = Math.min(recoverableHd, hd.damage || 0); - if (!amountToRecover) return; - recoverableHd -= amountToRecover; - resultingDamage = hd.damage - amountToRecover; - CreatureProperties.update(hd._id, { - $set: { - damage: resultingDamage, - dirty: true, - } - }, { - selector: {type: 'attribute'}, - }); - }); - } + }); } export default restCreature; diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js index 8d6bd185..becf0775 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyAction.js @@ -7,6 +7,7 @@ import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/met import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js'; import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; +import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature.js'; export default function applyAction(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); @@ -16,7 +17,7 @@ export default function applyAction(node, actionContext) { // Log the name and summary let content = { name: prop.name }; - if (prop.summary?.text){ + if (prop.summary?.text) { recalculateInlineCalculations(prop.summary, actionContext); content.value = prop.summary.value; } @@ -29,24 +30,27 @@ export default function applyAction(node, actionContext) { const attack = prop.attackRoll || prop.attackRollBonus; // Attack if there is an attack roll - if (attack && attack.calculation){ - if (targets.length){ + if (attack && attack.calculation) { + if (targets.length) { targets.forEach(target => { - applyAttackToTarget({attack, target, actionContext}); + applyAttackToTarget({ attack, target, actionContext }); // Apply the children, but only to the current target actionContext.targets = [target]; applyChildren(node, actionContext); }); } else { - applyAttackWithoutTarget({attack, actionContext}); + applyAttackWithoutTarget({ attack, actionContext }); applyChildren(node, actionContext); } } else { applyChildren(node, actionContext); } + if (prop.actionType === 'event' && prop.variableName) { + resetProperties(actionContext.creature._id, prop.variableName, actionContext); + } } -function applyAttackWithoutTarget({attack, actionContext}){ +function applyAttackWithoutTarget({ attack, actionContext }) { delete actionContext.scope['$attackHit']; delete actionContext.scope['$attackMiss']; delete actionContext.scope['$criticalHit']; @@ -62,16 +66,16 @@ function applyAttackWithoutTarget({attack, actionContext}){ criticalMiss, } = rollAttack(attack, scope); let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit'; - if (scope['$attackAdvantage'] === 1){ + if (scope['$attackAdvantage'] === 1) { name += ' (Advantage)'; - } else if(scope['$attackAdvantage'] === -1){ + } else if (scope['$attackAdvantage'] === -1) { name += ' (Disadvantage)'; } - if (!criticalMiss){ - scope['$attackHit'] = {value: true} + if (!criticalMiss) { + scope['$attackHit'] = { value: true } } - if (!criticalHit){ - scope['$attackMiss'] = {value: true}; + if (!criticalHit) { + scope['$attackMiss'] = { value: true }; } actionContext.addLog({ @@ -81,7 +85,7 @@ function applyAttackWithoutTarget({attack, actionContext}){ }); } -function applyAttackToTarget({attack, target, actionContext}){ +function applyAttackToTarget({ attack, target, actionContext }) { const scope = actionContext.scope; delete scope['$attackHit']; delete scope['$attackMiss']; @@ -99,15 +103,15 @@ function applyAttackToTarget({attack, target, actionContext}){ criticalMiss, } = rollAttack(attack, scope); - if (target.variables.armor){ + if (target.variables.armor) { const armor = target.variables.armor.value; let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : - result > armor ? 'Hit!' : 'Miss!'; - if (scope['$attackAdvantage'] === 1){ + result > armor ? 'Hit!' : 'Miss!'; + if (scope['$attackAdvantage'] === 1) { name += ' (Advantage)'; - } else if(scope['$attackAdvantage'] === -1){ + } else if (scope['$attackAdvantage'] === -1) { name += ' (Disadvantage)'; } @@ -116,15 +120,15 @@ function applyAttackToTarget({attack, target, actionContext}){ value: `${resultPrefix}\n**${result}**`, inline: true, }); - if (criticalMiss || result < armor){ - scope['$attackMiss'] = {value: true}; + if (criticalMiss || result < armor) { + scope['$attackMiss'] = { value: true }; } else { - scope['$attackHit'] = {value: true}; + scope['$attackHit'] = { value: true }; } } else { actionContext.addLog({ name: 'Error', - value:'Target has no `armor`', + value: 'Target has no `armor`', }); actionContext.addLog({ name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit', @@ -134,10 +138,10 @@ function applyAttackToTarget({attack, target, actionContext}){ } } -function rollAttack(attack, scope){ +function rollAttack(attack, scope) { const rollModifierText = numberToSignedString(attack.value, true); let value, resultPrefix; - if (scope['$attackAdvantage'] === 1){ + if (scope['$attackAdvantage'] === 1) { const [a, b] = rollDice(2, 20); if (a >= b) { value = a; @@ -146,7 +150,7 @@ function rollAttack(attack, scope){ value = b; resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; } - } else if (scope['$attackAdvantage'] === -1){ + } else if (scope['$attackAdvantage'] === -1) { const [a, b] = rollDice(2, 20); if (a <= b) { value = a; @@ -159,25 +163,25 @@ function rollAttack(attack, scope){ value = rollDice(1, 20)[0]; resultPrefix = `1d20 [${value}] ${rollModifierText}` } - scope['$attackRoll'] = {value}; + scope['$attackRoll'] = { value }; const result = value + attack.value; - const {criticalHit, criticalMiss} = applyCrits(value, scope); - return {resultPrefix, result, value, criticalHit, criticalMiss}; + const { criticalHit, criticalMiss } = applyCrits(value, scope); + return { resultPrefix, result, value, criticalHit, criticalMiss }; } -function applyCrits(value, scope){ +function applyCrits(value, scope) { let criticalHitTarget = scope.criticalHitTarget?.value || 20; let criticalHit = value >= criticalHitTarget; let criticalMiss; - if (criticalHit){ - scope['$criticalHit'] = {value: true}; + if (criticalHit) { + scope['$criticalHit'] = { value: true }; } else { criticalMiss = value === 1; - if (criticalMiss){ - scope['$criticalMiss'] = {value: true}; + if (criticalMiss) { + scope['$criticalMiss'] = { value: true }; } } - return {criticalHit, criticalMiss}; + return { criticalHit, criticalMiss }; } function applyChildren(node, actionContext) { @@ -185,9 +189,9 @@ function applyChildren(node, actionContext) { node.children.forEach(child => applyProperty(child, actionContext)); } -function spendResources(prop, actionContext){ +function spendResources(prop, actionContext) { // Check Uses - if (prop.usesLeft <= 0){ + if (prop.usesLeft <= 0) { if (!prop.silent) actionContext.addLog({ name: 'Error', value: `${prop.name || 'action'} does not have enough uses left`, @@ -195,7 +199,7 @@ function spendResources(prop, actionContext){ return true; } // Resources - if (prop.insufficientResources){ + if (prop.insufficientResources) { if (!prop.silent) actionContext.addLog({ name: 'Error', value: 'This creature doesn\'t have sufficient resources to perform this action', @@ -209,14 +213,14 @@ function spendResources(prop, actionContext){ try { prop.resources.itemsConsumed.forEach(itemConsumed => { recalculateCalculation(itemConsumed.quantity, actionContext); - if (!itemConsumed.itemId){ + if (!itemConsumed.itemId) { throw 'No ammo was selected for this prop'; } let item = CreatureProperties.findOne(itemConsumed.itemId); - if (!item || item.ancestors[0].id !== prop.ancestors[0].id){ + if (!item || item.ancestors[0].id !== prop.ancestors[0].id) { throw 'The prop\'s ammo was not found on the creature'; } - if (!item.equipped){ + if (!item.equipped) { throw 'The selected ammo is not equipped'; } if ( @@ -229,16 +233,16 @@ function spendResources(prop, actionContext){ value: itemConsumed.quantity.value, }); let logName = item.name; - if (itemConsumed.quantity.value > 1 || itemConsumed.quantity.value < -1){ + if (itemConsumed.quantity.value > 1 || itemConsumed.quantity.value < -1) { logName = item.plural || logName; } - if (itemConsumed.quantity.value > 0){ + if (itemConsumed.quantity.value > 0) { spendLog.push(logName + ': ' + itemConsumed.quantity.value); - } else if (itemConsumed.quantity.value < 0){ + } else if (itemConsumed.quantity.value < 0) { gainLog.push(logName + ': ' + -itemConsumed.quantity.value); } }); - } catch (e){ + } catch (e) { actionContext.addLog({ name: 'Error', value: e, @@ -251,9 +255,9 @@ function spendResources(prop, actionContext){ itemQuantityAdjustments.forEach(adjustQuantityWork); // Use uses - if (prop.usesLeft){ + if (prop.usesLeft) { CreatureProperties.update(prop._id, { - $inc: {usesUsed: 1} + $inc: { usesUsed: 1 } }, { selector: prop }); @@ -270,7 +274,7 @@ function spendResources(prop, actionContext){ if (!attConsumed.quantity?.value) return; let stat = actionContext.scope[attConsumed.variableName]; - if (!stat){ + if (!stat) { spendLog.push(stat.name + ': ' + ' not found'); return; } @@ -280,9 +284,9 @@ function spendResources(prop, actionContext){ value: attConsumed.quantity.value, actionContext, }); - if (attConsumed.quantity.value > 0){ + if (attConsumed.quantity.value > 0) { spendLog.push(stat.name + ': ' + attConsumed.quantity.value); - } else if (attConsumed.quantity.value < 0){ + } else if (attConsumed.quantity.value < 0) { gainLog.push(stat.name + ': ' + -attConsumed.quantity.value); } }); diff --git a/app/imports/api/properties/Actions.js b/app/imports/api/properties/Actions.js index 1ff4d6fd..99910081 100644 --- a/app/imports/api/properties/Actions.js +++ b/app/imports/api/properties/Actions.js @@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js'; import { storedIconsSchema } from '/imports/api/icons/Icons.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js'; /* * Actions are things a character can do @@ -24,9 +25,17 @@ let ActionSchema = createPropertySchema({ // long actions take longer than 1 round to cast actionType: { type: String, - allowedValues: ['action', 'bonus', 'attack', 'reaction', 'free', 'long'], + allowedValues: ['action', 'bonus', 'attack', 'reaction', 'free', 'long', 'event'], defaultValue: 'action', }, + // If the action type is an event, what is the variable name of that event? + variableName: { + type: String, + optional: true, + regEx: VARIABLE_NAME_REGEX, + min: 2, + max: STORAGE_LIMITS.variableName, + }, // Who is the action directed at target: { type: String, @@ -56,8 +65,10 @@ let ActionSchema = createPropertySchema({ // How this action's uses are reset automatically reset: { type: String, - allowedValues: ['longRest', 'shortRest'], optional: true, + regEx: VARIABLE_NAME_REGEX, + min: 2, + max: STORAGE_LIMITS.variableName, }, // Resources resources: { @@ -74,7 +85,7 @@ let ActionSchema = createPropertySchema({ 'resources.itemsConsumed.$._id': { type: String, regEx: SimpleSchema.RegEx.Id, - autoValue(){ + autoValue() { if (!this.isSet) return Random.id(); } }, @@ -101,7 +112,7 @@ let ActionSchema = createPropertySchema({ 'resources.attributesConsumed.$._id': { type: String, regEx: SimpleSchema.RegEx.Id, - autoValue(){ + autoValue() { if (!this.isSet) return Random.id(); } }, @@ -218,4 +229,4 @@ const ComputedActionSchema = new SimpleSchema() .extend(ActionSchema) .extend(ComputedOnlyActionSchema); -export { ActionSchema, ComputedOnlyActionSchema, ComputedActionSchema}; +export { ActionSchema, ComputedOnlyActionSchema, ComputedActionSchema }; diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.js index 1052e2e5..28f79243 100644 --- a/app/imports/api/properties/Attributes.js +++ b/app/imports/api/properties/Attributes.js @@ -129,7 +129,9 @@ let AttributeSchema = createPropertySchema({ reset: { type: String, optional: true, - allowedValues: ['shortRest', 'longRest'], + regEx: VARIABLE_NAME_REGEX, + min: 2, + max: STORAGE_LIMITS.variableName, }, }); diff --git a/app/imports/ui/components/ResetSelector.vue b/app/imports/ui/components/ResetSelector.vue new file mode 100644 index 00000000..a9248ec8 --- /dev/null +++ b/app/imports/ui/components/ResetSelector.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/imports/ui/creature/CreatureForm.vue b/app/imports/ui/creature/CreatureForm.vue index d2ea8eeb..1fdc4073 100644 --- a/app/imports/ui/creature/CreatureForm.vue +++ b/app/imports/ui/creature/CreatureForm.vue @@ -39,6 +39,11 @@ :input-value="model.settings.hideUnusedStats" @change="value => $emit('change', {path: ['settings','hideUnusedStats'], value: !!value})" /> + -
+
+
@@ -352,7 +363,9 @@ import RestButton from '/imports/ui/creature/RestButton.vue'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import ToggleCard from '/imports/ui/properties/components/toggles/ToggleCard.vue'; import doCastSpell from '/imports/api/engine/actions/doCastSpell.js'; +import EventButton from '/imports/ui/properties/components/actions/EventButton.vue'; import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; +import { uniqBy } from 'lodash'; const getProperties = function (creature, filter, options = { sort: { order: 1 } @@ -401,6 +414,7 @@ export default { SpellSlotListTile, ActionCard, ToggleCard, + EventButton, }, props: { creatureId: { @@ -473,8 +487,12 @@ export default { languages() { return getSkillOfType(this.creature, 'language'); }, + events() { + const events = getProperties(this.creature, { type: 'action', actionType: 'event' }); + return uniqBy(events.fetch(), e => e.variableName); + }, actions() { - return getProperties(this.creature, { type: 'action' }); + return getProperties(this.creature, { type: 'action', actionType: { $ne: 'event' } }); }, appliedBuffs() { return getProperties(this.creature, { type: 'buff' }); diff --git a/app/imports/ui/properties/components/actions/EventButton.vue b/app/imports/ui/properties/components/actions/EventButton.vue new file mode 100644 index 00000000..29bdb0c6 --- /dev/null +++ b/app/imports/ui/properties/components/actions/EventButton.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/app/imports/ui/properties/components/toggles/ToggleCard.vue b/app/imports/ui/properties/components/toggles/ToggleCard.vue index 3659f0aa..207783d8 100644 --- a/app/imports/ui/properties/components/toggles/ToggleCard.vue +++ b/app/imports/ui/properties/components/toggles/ToggleCard.vue @@ -1,7 +1,9 @@