Failed attempt at using method calls to manage awaited method

This commit is contained in:
Thaum Rystra
2023-11-14 13:55:17 +02:00
parent 5a2df36e8b
commit 6162f2fe90
10 changed files with 221 additions and 178 deletions

View File

@@ -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({});
},
});

View File

@@ -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}`)

View File

@@ -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));

View File

@@ -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 },
});
}
},
});

View File

@@ -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,
});
}

View File

@@ -0,0 +1,79 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title>
Action
</v-toolbar-title>
</template>
<div>
content
</div>
<v-btn
slot="actions"
text
@click="cancel"
>
Cancel
</v-btn>
<v-spacer slot="actions" />
<v-btn
slot="actions"
text
@click="apply(true)"
>
Step
</v-btn>
<v-btn
slot="actions"
text
@click="apply()"
>
Apply all
</v-btn>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import doAction from '/imports/api/engine/actions/doAction';
import { provideUserInput } from '/imports/api/engine/actions/getUserInput.js';
export default {
components: {
DialogBase,
},
props: {
propId: {
type: String,
default: undefined,
},
},
data() {
return {
invocationId: undefined,
answers: {},
loading: false,
}
},
meteor: {
},
methods: {
apply(step = false) {
if (this.invocationId) {
provideUserInput(this.invocationId, 0, { index: 1 });
} else {
this.invocationId = Random.id();
doAction.call({
invocationId: this.invocationId,
actionId: this.propId,
targetIds: [],
});
}
},
cancel() {
this.$store.dispatch('popDialogStack');
},
}
};
</script>

View File

@@ -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,

View File

@@ -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 => {

View File

@@ -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 }),

View File

@@ -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: {