diff --git a/app/imports/api/creature/actions/ActiveActions.js b/app/imports/api/creature/actions/ActiveActions.js deleted file mode 100644 index bbfe4731..00000000 --- a/app/imports/api/creature/actions/ActiveActions.js +++ /dev/null @@ -1,108 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; - -// Actions are creature actions that have been partially executed and not yet resolved -// They require some user input to progress -let ActiveActions = new Mongo.Collection('activeActions'); - -let ActiveActionSchema = new SimpleSchema({ - _id: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - // Which creature is taking the action - creatureId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - // The user who began taking the action - userId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - // Requests for user input - questions: { - type: Object, - blackbox: true, - optional: true, - }, - // User responses - answers: { - type: Object, - blackbox: true, - optional: true, - }, -}); - -ActiveActions.attachSchema(ActiveActionSchema); - -export default ActiveActions; - -export const answerAction = new ValidatedMethod({ - name: 'activeActions.answer', - validate: null /*new SimpleSchema({ - activeActionId: SimpleSchema.RegEx.Id, - answers: { - type: Object, - blackbox: true, - }, - }).validator()*/, - applyOptions: { - throwStubExceptions: false, - }, - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 5, - timeInterval: 5000, - }, - run({ activeActionId, answers }) { - return ActiveActions.update({}, { - $set: { answers }, - $unset: { questions: 1 }, - }); - const action = ActiveActions.findOne(activeActionId); - // Permissions - if (action.userId !== this.userId) { - throw new Meteor.Error('Permission denied', 'You do not own this action'); - } - return ActiveActions.update(activeActionId, { - $set: { answers }, - $unset: { questions: 1 }, - }); - }, -}); - -export const removeAction = new ValidatedMethod({ - name: 'activeActions.remove', - validate: new SimpleSchema({ - activeActionId: SimpleSchema.RegEx.Id, - }).validator(), - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 5, - timeInterval: 5000, - }, - run({ activeActionId }) { - const action = ActiveActions.findOne(activeActionId); - // Permissions - if (action.userId !== this.userId) { - throw new Meteor.Error('Permission denied', 'You do not own this action'); - } - return ActiveActions.remove(activeActionId); - }, -}); - -// TODO remove this -export const removeAllActions = new ValidatedMethod({ - name: 'activeActions.removeAll', - validate: null, - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 5, - timeInterval: 5000, - }, - run() { - return ActiveActions.remove({}); - }, -}); diff --git a/app/imports/api/engine/actions/ActionContext.js b/app/imports/api/engine/actions/ActionContext.js index 81eb250e..7de72dc7 100644 --- a/app/imports/api/engine/actions/ActionContext.js +++ b/app/imports/api/engine/actions/ActionContext.js @@ -5,11 +5,12 @@ import { import { groupBy, remove } from 'lodash'; export default class ActionContext { - constructor(creatureId, targetIds = [], method, activeActionId) { + constructor(creatureId, targetIds = [], method, invocationId) { // Get the creature this.creature = getCreature(creatureId) - // Store an active action ID for pausing/resuming this action - this.activeActionId = activeActionId + // Store the details for pausing for user interaction + this.invocationId = invocationId; + this.userInputStep = 0; if (!this.creature) { throw new Meteor.Error('No Creature', `No creature could be found with id: ${creatureId}`) diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index 58385f1e..9bda5d10 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -37,7 +37,7 @@ export default function applyDamage(node, actionContext) { const logName = prop.damageType === 'healing' ? 'Healing' : 'Damage'; // roll the dice only and store that string - recalculateCalculation(prop.amount, actionContext, undefined, 'compile'); + recalculateCalculation(prop.amount, actionContext, 'compile'); const { result: rolled } = resolve('roll', prop.amount.valueNode, scope, context); if (rolled.parseType !== 'constant') { logValue.push(toString(rolled)); diff --git a/app/imports/api/engine/actions/doAction.js b/app/imports/api/engine/actions/doAction.js index 5cbd3cc4..74db6ef4 100644 --- a/app/imports/api/engine/actions/doAction.js +++ b/app/imports/api/engine/actions/doAction.js @@ -10,7 +10,6 @@ import Creatures from '/imports/api/creature/creatures/Creatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; import applyProperty from './applyProperty.js'; import ActionContext from '/imports/api/engine/actions/ActionContext.js'; -import ActiveActions from '/imports/api/creature/actions/ActiveActions'; const doAction = new ValidatedMethod({ name: 'creatureProperties.doAction', @@ -31,24 +30,27 @@ const doAction = new ValidatedMethod({ blackbox: true, optional: true, }, + invocationId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + optional: true, + } }).validator(), + applyOptions: { + throwStubExceptions: false, + }, mixins: [RateLimiterMixin], rateLimit: { numRequests: 10, timeInterval: 5000, }, - async run({ actionId, targetIds = [], scope }) { + async run({ actionId, targetIds = [], scope, invocationId }) { + console.log('do Action running'); // Get action context let action = CreatureProperties.findOne(actionId); const creatureId = action.ancestors[0].id; - // TODO remove this - // For testing, remove all other active actions before inserting this one - ActiveActions.remove({}); - const activeActionId = await ActiveActions.insertAsync({ - creatureId, - userId: this.userId, - }); - const actionContext = new ActionContext(creatureId, targetIds, this, activeActionId); + + const actionContext = new ActionContext(creatureId, targetIds, this, invocationId); // Check permissions assertEditPermission(actionContext.creature, this.userId); @@ -67,11 +69,13 @@ const doAction = new ValidatedMethod({ await doActionWork({ properties, ancestors, actionContext, methodScope: scope }); // Recompute all involved creatures - Creatures.update({ - _id: { $in: [creatureId, ...targetIds] } - }, { - $set: { dirty: true }, - }); + if (Meteor.isServer) { + Creatures.updateAsync({ + _id: { $in: [creatureId, ...targetIds] } + }, { + $set: { dirty: true }, + }); + } }, }); diff --git a/app/imports/api/engine/actions/getUserInput.js b/app/imports/api/engine/actions/getUserInput.js index 0ee4bca5..c9a7134c 100644 --- a/app/imports/api/engine/actions/getUserInput.js +++ b/app/imports/api/engine/actions/getUserInput.js @@ -1,28 +1,90 @@ -import ActiveActions from '/imports/api/creature/actions/ActiveActions'; +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { set } from 'lodash'; + +// Reminder: throwStubExceptions: true is the default, and only +// possible when run() is not async +// For async run() stub exceptions never stop the client from sending +// the call to the server + +// Dict of invocationId: {steps: {earlyAnswers, resolve, reject}} +// either resolve functions waiting for the user's input or early answers that were provided +// before the resolves could be set up +let userInputRequests = {}; +let provideUserInput; + +if (Meteor.isClient) { + provideUserInput = function (invocationId, step, answers, callback) { + Meteor.call('answerUserInputRequest', { invocationId, step, answers }, callback); + // Do the same work on the client without using a stub + answerInputRequestWork({ invocationId, step, answers }); + } +} + +export { userInputRequests, provideUserInput }; export default async function getUserInput(questions, actionContext) { - const activeActionId = actionContext.activeActionId; - // Set the questions on the active action - ActiveActions.update(activeActionId, { - $set: { questions }, - $unset: { answers: 1 }, - }); - // Wait for answers + // get the invocation details from the action context + const invocationId = actionContext.invocationId; + const step = actionContext.userInputStep; + actionContext.userInputStep += 1; // increment userInput step every time + + // If the answers are already waiting, just return them + if (userInputRequests[invocationId]?.[step]?.earlyAnswers) { + return userInputRequests[invocationId][step].earlyAnswers; + } + // On the client, store the questions to be answered + if (Meteor.isClient) { + set(userInputRequests, `${invocationId}[${step}]`, { questions }); + } + // Create a place for the answers to go when they are provided return new Promise((resolve, reject) => { - const observerHandle = ActiveActions.find({ - _id: activeActionId - }).observeChanges({ - changed(id, fields) { - // Only watch for answers - if (!fields.answers) return; - // Stop watching - observerHandle.stop(); - // Give answers - resolve(fields.answers); - }, - removed() { - reject('Active action was deleted') - }, - }); + set(userInputRequests, `${invocationId}[${step}]`, { resolve, reject }); + }); +} + +function answerInputRequestWork({ invocationId, step, answers }) { + console.log('running answerUserInputRequest'); + const invocation = userInputRequests[invocationId]; + if (!invocation) { + // Call order on the server is guaranteed, so the invocation must have been created + // Before we can update it + throw new Meteor.Error('Not found', 'The method this answer is updating does not exist'); + } + if (invocation[step]?.resolve) { + // If there is a resolve waiting for this response, provide it + invocation[step].resolve(answers); + } else { + // Otherwise just store the response as early answers + invocation[step] = { + earlyAnswers: answers + }; + } +} + +if (Meteor.isServer) { + // This function is not defined on the client so that it has no stub function + // This allows it to be called while still simulating an awaited async method + // See https://guide.meteor.com/2.8-migration.html#the-limitations + new ValidatedMethod({ + name: 'answerUserInputRequest', + validate: new SimpleSchema({ + invocationId: SimpleSchema.RegEx.Id, + step: SimpleSchema.Integer, + answers: { + type: Object, + blackbox: true, + }, + }).validator(), + applyOptions: { + throwStubExceptions: false, + }, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 20, + timeInterval: 5000, + }, + run: answerInputRequestWork, }); } diff --git a/app/imports/client/ui/creature/actions/ActionDialog.vue b/app/imports/client/ui/creature/actions/ActionDialog.vue index e69de29b..ea0e30d6 100644 --- a/app/imports/client/ui/creature/actions/ActionDialog.vue +++ b/app/imports/client/ui/creature/actions/ActionDialog.vue @@ -0,0 +1,79 @@ + + + diff --git a/app/imports/client/ui/dialogStack/DialogComponentIndex.js b/app/imports/client/ui/dialogStack/DialogComponentIndex.js index 471d3b6a..acfe8c22 100644 --- a/app/imports/client/ui/dialogStack/DialogComponentIndex.js +++ b/app/imports/client/ui/dialogStack/DialogComponentIndex.js @@ -1,4 +1,5 @@ // Load commonly used dialogs immediately +import ActionDialog from '/imports/client/ui/creature/actions/ActionDialog.vue'; import InsertPropertyDialog from '/imports/client/ui/properties/InsertPropertyDialog.vue'; import CharacterCreationDialog from '/imports/client/ui/creature/character/CharacterCreationDialog.vue'; import CastSpellWithSlotDialog from '/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue'; @@ -33,6 +34,7 @@ const ShareDialog = () => import('/imports/client/ui/sharing/ShareDialog.vue'); const UsernameDialog = () => import('/imports/client/ui/user/UsernameDialog.vue'); export default { + ActionDialog, InsertPropertyDialog, ArchiveDialog, CastSpellWithSlotDialog, diff --git a/app/imports/client/ui/properties/viewers/ActionViewer.vue b/app/imports/client/ui/properties/viewers/ActionViewer.vue index de20f2fe..feec1e52 100644 --- a/app/imports/client/ui/properties/viewers/ActionViewer.vue +++ b/app/imports/client/ui/properties/viewers/ActionViewer.vue @@ -187,7 +187,16 @@ export default { }, }, methods: { + doAction() { + this.$store.commit('pushDialogStack', { + component: 'action-dialog', + elementId: 'do-action-button', + data: { + propId: this.model._id, + }, + }); + return; if (this.model.type === 'action') { this.doActionLoading = true; doAction.call({ actionId: this.model._id }, error => { diff --git a/app/imports/parser/parseTree/roll.js b/app/imports/parser/parseTree/roll.js index c4e028c4..f2d7c491 100644 --- a/app/imports/parser/parseTree/roll.js +++ b/app/imports/parser/parseTree/roll.js @@ -5,51 +5,51 @@ import rollDice from '/imports/parser/rollDice.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; const rollNode = { - create({left, right}) { + create({ left, right }) { return { parseType: 'roll', left, right, }; }, - compile(node, scope, context){ - const {result: left} = resolve('compile', node.left, scope, context); - const {result: right} = resolve('compile', node.right, scope, context); + compile(node, scope, context) { + const { result: left } = resolve('compile', node.left, scope, context); + const { result: right } = resolve('compile', node.right, scope, context); return { - result: rollNode.create({left, right}), + result: rollNode.create({ left, right }), context, }; }, - toString(node){ + toString(node) { if ( node.left.valueType === 'number' && node.left.value === 1 - ){ + ) { return `d${toString(node.right)}`; } else { return `${toString(node.left)}d${toString(node.right)}`; } }, - roll(node, scope, context){ - const {result: left} = resolve('reduce', node.left, scope, context); - const {result: right} = resolve('reduce', node.right, scope, context); - if (left.valueType !== 'number' && !Number.isInteger(left.value)){ + roll(node, scope, context) { + const { result: left } = resolve('reduce', node.left, scope, context); + const { result: right } = resolve('reduce', node.right, scope, context); + if (left.valueType !== 'number' && !Number.isInteger(left.value)) { return errorResult('Number of dice is not an integer', node, context); } - if (right.valueType !== 'number' && !Number.isInteger(right.value)){ + if (right.valueType !== 'number' && !Number.isInteger(right.value)) { return errorResult('Dice size is not an integer', node, context); } let number = left.value; - if (context.options.doubleRolls){ + if (context.options.doubleRolls) { number *= 2; } - if (number > STORAGE_LIMITS.diceRollValuesCount){ + if (number > STORAGE_LIMITS.diceRollValuesCount) { const message = `Can't roll more than ${STORAGE_LIMITS.diceRollValuesCount} dice at once`; return errorResult(message, node, context); } let diceSize = right.value; let values = rollDice(number, diceSize); - if (context){ - context.rolls.push({number, diceSize, values}); + if (context) { + context.rolls.push({ number, diceSize, values }); } return { result: rollArray.create({ @@ -60,18 +60,18 @@ const rollNode = { context }; }, - reduce(node, scope, context){ - const {result} = rollNode.roll(node, scope, context); + reduce(node, scope, context) { + const { result } = rollNode.roll(node, scope, context); return resolve('reduce', result, scope, context); }, - traverse(node, fn){ + traverse(node, fn) { fn(node); traverse(node.left, fn); traverse(node.right, fn); }, - map(node, fn){ + map(node, fn) { const resultingNode = fn(node); - if (resultingNode === node){ + if (resultingNode === node) { node.left = map(node.left, fn); node.right = map(node.right, fn); } @@ -79,7 +79,7 @@ const rollNode = { }, } -function errorResult(message, node, context){ +function errorResult(message, node, context) { context.error(message); return { result: error.create({ node, error: message }), diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index b4ff4dc2..b41c9dcd 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -6,7 +6,6 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import { assertViewPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import computeCreature from '/imports/api/engine/computeCreature.js'; import VERSION from '/imports/constants/VERSION.js'; -import ActiveActions from '/imports/api/creature/actions/ActiveActions'; import { loadCreature } from '/imports/api/engine/loadCreatures.js'; let schema = new SimpleSchema({ @@ -55,11 +54,6 @@ Meteor.publish('singleCharacter', function (creatureId) { limit: 20, sort: { date: -1 }, }), - ActiveActions.find({ - creatureId, - }, { - limit: 10, - }), // Also publish the owner's username Meteor.users.find(permissionCreature.owner, { fields: {