diff --git a/app/imports/api/creature/log/CreatureLogs.js b/app/imports/api/creature/log/CreatureLogs.js index e4f03301..633d1ff3 100644 --- a/app/imports/api/creature/log/CreatureLogs.js +++ b/app/imports/api/creature/log/CreatureLogs.js @@ -9,7 +9,6 @@ import { parse, prettifyParseError } from '/imports/parser/parser'; import resolve from '/imports/parser/resolve'; import toString from '/imports/parser/toString'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; -import { assertUserInTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions.js'; const PER_CREATURE_LOG_LIMIT = 100; @@ -39,17 +38,20 @@ let CreatureLogSchema = new SimpleSchema({ }, index: 1, }, + // The acting creature initiating the logged events creatureId: { type: String, - regEx: SimpleSchema.RegEx.Id, index: 1, }, - tabletopId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - index: 1, + // creatures targeted by any of the logged events + targetIds: { + type: Array, optional: true, }, + 'targetIds.$': { + type: String, + index: 1, + }, creatureName: { type: String, optional: true, @@ -137,7 +139,7 @@ const insertCreatureLog = new ValidatedMethod({ }, }); -export function insertCreatureLogWork({ log, creature, tabletopId, method }) { +export function insertCreatureLogWork({ log, creature, method }) { // Build the new log if (typeof log === 'string') { log = { content: [{ value: log }] }; @@ -151,7 +153,6 @@ export function insertCreatureLogWork({ log, creature, tabletopId, method }) { } }); log.date = new Date(); - if (tabletopId) log.tabletopId = tabletopId; if (creature && creature.tabletop) log.tabletopId = creature.tabletop; // Insert it let id = CreatureLogs.insert(log); @@ -186,20 +187,15 @@ const logRoll = new ValidatedMethod({ roll: { type: String, }, - tabletopId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - optional: true, - }, creatureId: { type: String, regEx: SimpleSchema.RegEx.Id, optional: true, }, }).validator(), - async run({ roll, tabletopId, creatureId }) { - if (!creatureId && !tabletopId) throw new Meteor.Error('no-id', - 'A creature id or tabletop id must be given' + async run({ roll, creatureId }) { + if (!creatureId) throw new Meteor.Error('no-id', + 'A creature id must be given' ); let creature; if (creatureId) { @@ -215,9 +211,6 @@ const logRoll = new ValidatedMethod({ }); assertEditPermission(creature, this.userId); } - if (tabletopId) { - assertUserInTabletop(tabletopId, this.userId); - } const variables = CreatureVariables.findOne({ _creatureId: creatureId }) || {}; let logContent = [] let parsedResult = undefined; @@ -259,7 +252,7 @@ const logRoll = new ValidatedMethod({ date: new Date(), }; - let id = insertCreatureLogWork({ log, creature, tabletopId, method: this }); + let id = insertCreatureLogWork({ log, creature, method: this }); return id; }, diff --git a/app/imports/api/creature/log/LogContentSchema.ts b/app/imports/api/creature/log/LogContentSchema.ts index af98b288..2599bb1a 100644 --- a/app/imports/api/creature/log/LogContentSchema.ts +++ b/app/imports/api/creature/log/LogContentSchema.ts @@ -62,6 +62,13 @@ let LogContentSchema = new SimpleSchema({ type: Boolean, optional: true, }, + targetIds: { + type: Array, + optional: true, + }, + 'targetIds.$': { + type: String, + } }); export default LogContentSchema; diff --git a/app/imports/api/engine/action/EngineActions.ts b/app/imports/api/engine/action/EngineActions.ts index 4d60063c..656eb7bc 100644 --- a/app/imports/api/engine/action/EngineActions.ts +++ b/app/imports/api/engine/action/EngineActions.ts @@ -1,7 +1,6 @@ import SimpleSchema from 'simpl-schema'; import TaskResult from './tasks/TaskResult'; import LogContentSchema from '/imports/api/creature/log/LogContentSchema'; -import { Mongo } from 'meteor/mongo'; const EngineActions = new Mongo.Collection('actions'); @@ -34,11 +33,6 @@ const ActionSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, - userInputNeeded: { - type: Object, - optional: true, - blackbox: true, - }, // Applied properties results: { diff --git a/app/imports/api/engine/action/functions/applyAction.ts b/app/imports/api/engine/action/functions/applyAction.ts index a7b1451f..240f4bcd 100644 --- a/app/imports/api/engine/action/functions/applyAction.ts +++ b/app/imports/api/engine/action/functions/applyAction.ts @@ -3,16 +3,24 @@ import { getSingleProperty } from '/imports/api/engine/loadCreatures'; import applyTask from '/imports/api/engine/action/tasks/applyTask' import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; import saveInputChoices from './userInput/saveInputChoices'; +import Task from '/imports/api/engine/action/tasks/Task'; // TODO create a function to get the effective value of a property, // simulating all the result updates in the action so far -// Apply an action -// This is run once as a simulation on the client awaiting all the various inputs or step through -// clicks from the user, then it is run as part of the runAction method, where it is expected to -// complete instantly on the client, and sent to the server as a method call +/** + * Apply an action + * This is run once as a simulation on the client awaiting all the various inputs or step through + * clicks from the user, then it is run as part of the runAction method, where it is expected to + * complete instantly on the client, and sent to the server as a method call + * @param action The action to apply + * @param userInput The input provider + * @param { Object } options + * @param { Task } options.task If provided, the action will start with this task instead of + * applying the root property of the action + */ export default async function applyAction(action: EngineAction, userInput: InputProvider, options?: { - simulate?: boolean, stepThrough?: boolean + simulate?: boolean, stepThrough?: boolean, task?: Task, }) { const { simulate, stepThrough } = options || {}; if (!simulate && stepThrough) throw 'Cannot step through unless simulating'; @@ -31,12 +39,15 @@ export default async function applyAction(action: EngineAction, userInput: Input action._stepThrough = stepThrough; action._isSimulation = simulate; action.taskCount = 0; - console.log(JSON.stringify(action, null, 2)); - const prop = await getSingleProperty(action.creatureId, action.rootPropId); - if (!prop) throw new Meteor.Error('Not found', 'Root action property could not be found'); - await applyTask(action, { - prop, - targetIds: action.targetIds || [], - }, userInput); - return { action, userInput }; + let task = options?.task; + if (!task) { + const prop = await getSingleProperty(action.creatureId, action.rootPropId); + if (!prop) throw new Meteor.Error('Not found', 'Root action property could not be found'); + task = options?.task || { + prop, + targetIds: action.targetIds || [], + } + } + await applyTask(action, task, userInput); + return action; } diff --git a/app/imports/api/engine/action/functions/mutationToLogUpdates.ts b/app/imports/api/engine/action/functions/mutationToLogUpdates.ts new file mode 100644 index 00000000..1a9fdfa0 --- /dev/null +++ b/app/imports/api/engine/action/functions/mutationToLogUpdates.ts @@ -0,0 +1,13 @@ +import { Mutation } from '/imports/api/engine/action/tasks/TaskResult'; + +export default function mutationToLogUpdates(mutation: Mutation) { + if (!mutation.contents) return []; + const contents: any[] = []; + for (const content of mutation.contents) { + contents.push({ + ...content, + targetIds: mutation.targetIds, + }); + } + return contents; +} diff --git a/app/imports/api/engine/action/functions/mutationToPropUpdates.ts b/app/imports/api/engine/action/functions/mutationToPropUpdates.ts new file mode 100644 index 00000000..c2afe5b3 --- /dev/null +++ b/app/imports/api/engine/action/functions/mutationToPropUpdates.ts @@ -0,0 +1,43 @@ +import { Mutation } from '/imports/api/engine/action/tasks/TaskResult'; +import { newOperation } from '/imports/api/engine/shared/bulkWrite'; + +export default function mutationToPropUpdates(mutation: Mutation) { + const bulkWriteOps: any[] = []; + // Updates to creature properties + if (mutation.updates) { + const propUpdatesById: Record = {}; + for (const update of mutation.updates) { + if (!propUpdatesById[update.propId]) { + propUpdatesById[update.propId] = newOperation(update.propId); + } + if (update.set) { + propUpdatesById[update.propId].updateOne.update.$set = { + ...propUpdatesById[update.propId].updateOne.update.$set, + ...update.set, + }; + } + if (update.inc) { + propUpdatesById[update.propId].updateOne.update.$inc = { + ...propUpdatesById[update.propId].updateOne.update.$inc, + ...update.inc, + }; + } + } + for (const id in propUpdatesById) { + bulkWriteOps.push(propUpdatesById[id]); + } + } + // Insert creature properties + if (mutation.inserts) for (const insertOne of mutation.inserts) { + bulkWriteOps.push({ + insertOne + }); + } + // Remove creature properties + if (mutation.removals) for (const removeOne of mutation.removals) { + bulkWriteOps.push({ + removeOne, + }); + } + return bulkWriteOps; +} diff --git a/app/imports/api/engine/action/functions/writeActionResults.ts b/app/imports/api/engine/action/functions/writeActionResults.ts new file mode 100644 index 00000000..2c862f92 --- /dev/null +++ b/app/imports/api/engine/action/functions/writeActionResults.ts @@ -0,0 +1,34 @@ +import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions'; +import mutationToPropUpdates from './mutationToPropUpdates'; +import mutationToLogUpdates from '/imports/api/engine/action/functions/mutationToLogUpdates'; +import { union } from 'lodash'; +import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; +import bulkWrite from '/imports/api/engine/shared/bulkWrite'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; + +export default async function writeActionResults(action: EngineAction) { + if (!action._id) throw new Meteor.Error('type-error', 'Action does not have an _id'); + EngineActions.remove(action._id); + const creaturePropUpdates: any[] = []; + const logContents: any[] = []; + // Collect all the updates and log content + action.results.forEach(result => { + result.mutations.forEach(mutation => { + creaturePropUpdates.push(...mutationToPropUpdates(mutation)); + logContents.push(...mutationToLogUpdates(mutation)); + }); + }); + const allTargetIds = union(...logContents.map(c => c.targetIds)); + + // Write the log + const logPromise = CreatureLogs.insertAsync({ + content: logContents, + creatureId: action.creatureId, + targetIds: allTargetIds, + }); + + // Write the bulk updates + const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties); + + return Promise.all([logPromise, bulkWritePromise]); +} diff --git a/app/imports/api/engine/action/functions/writeChangedAction.ts b/app/imports/api/engine/action/functions/writeChangedAction.ts deleted file mode 100644 index c7ce9191..00000000 --- a/app/imports/api/engine/action/functions/writeChangedAction.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { isEmpty } from 'lodash' -import EngineActions, { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions'; - -export default async function writeChangedAction(original: EngineAction, changed: EngineAction) { - const $set = {}; - for (const key of ActionSchema.objectKeys()) { - if (!EJSON.equals(original[key], changed[key])) { - $set[key] = changed[key]; - } - } - if (!isEmpty($set) && original._id) { - return EngineActions.updateAsync(original._id, { $set }); - } -} diff --git a/app/imports/api/engine/action/methods/insertAction.ts b/app/imports/api/engine/action/methods/insertAction.ts index ac0f0881..ebafe622 100644 --- a/app/imports/api/engine/action/methods/insertAction.ts +++ b/app/imports/api/engine/action/methods/insertAction.ts @@ -9,13 +9,13 @@ export const insertAction = new ValidatedMethod({ validate: new SimpleSchema({ action: ActionSchema }).validator({ clean: true }), - run: async function ({ action }: { action: EngineAction }) { + run: function ({ action }: { action: EngineAction }) { assertEditPermission(getCreature(action.creatureId), this.userId); // First remove all other actions on this creature // only do one action at a time, don't wait for this to finish - EngineActions.removeAsync({ creatureId: action.creatureId }); + EngineActions.remove({ creatureId: action.creatureId }); // Force a random id even if one was provided, we may use it later as the seed for PRNG delete action._id; - return EngineActions.insertAsync(action); + return EngineActions.insert(action); }, }); diff --git a/app/imports/api/engine/action/methods/runAction.ts b/app/imports/api/engine/action/methods/runAction.ts index 52e9dc81..ad7f91eb 100644 --- a/app/imports/api/engine/action/methods/runAction.ts +++ b/app/imports/api/engine/action/methods/runAction.ts @@ -4,7 +4,7 @@ import EngineActions from '/imports/api/engine/action/EngineActions'; import { assertEditPermission } from '/imports/api/sharing/sharingPermissions'; import { getCreature } from '/imports/api/engine/loadCreatures'; import applyAction from '/imports/api/engine/action/functions/applyAction'; -import writeChangedAction from '../functions/writeChangedAction'; +import writeActionResults from '../functions/writeActionResults'; import getReplayChoicesInputProvider from '/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider'; export const runAction = new ValidatedMethod({ @@ -22,7 +22,7 @@ export const runAction = new ValidatedMethod({ blackbox: true, }, }).validator(), - run: async function ({ actionId, decisions }: { actionId: string, decisions: any[] }) { + run: async function ({ actionId, decisions = [] }: { actionId: string, decisions?: any[] }) { // Get the action const action = await EngineActions.findOneAsync(actionId); if (!action) throw new Meteor.Error('not-found', 'Action not found'); @@ -30,9 +30,6 @@ export const runAction = new ValidatedMethod({ // Permissions assertEditPermission(getCreature(action.creatureId), this.userId); - // Keep a copy of the original so that a diff can be done later to store what changed - const originalAction = EJSON.clone(action); - // Replay the user's decisions as user input const userInput = getReplayChoicesInputProvider(actionId, decisions); @@ -40,7 +37,7 @@ export const runAction = new ValidatedMethod({ applyAction(action, userInput); // Persist changes - const writePromise = writeChangedAction(originalAction, action); + const writePromise = writeActionResults(action); return writePromise; }, }); diff --git a/app/imports/api/engine/action/tasks/applyTask.ts b/app/imports/api/engine/action/tasks/applyTask.ts index ff3ee0fa..38030d6e 100644 --- a/app/imports/api/engine/action/tasks/applyTask.ts +++ b/app/imports/api/engine/action/tasks/applyTask.ts @@ -17,14 +17,16 @@ export default async function applyTask( action: EngineAction, task: PropTask | ItemAsAmmoTask, inputProvider: InputProvider ): Promise +export default async function applyTask( + action: EngineAction, task: Task, inputProvider: InputProvider +): Promise + export default async function applyTask( action: EngineAction, task: Task, inputProvider: InputProvider ): Promise { // Pause and wait for the user if the action is being stepped through - console.log('applying task', action, inputProvider) if (action._isSimulation && action._stepThrough && inputProvider.nextStep) { - console.log('waiting for next step resolution', task) await inputProvider.nextStep(task); } diff --git a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js index 638a481b..6dbb766a 100644 --- a/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js +++ b/app/imports/api/engine/computation/writeComputation/writeAlteredProperties.js @@ -1,7 +1,6 @@ -import { Meteor } from 'meteor/meteor' -import { EJSON } from 'meteor/ejson'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; import propertySchemasIndex from '/imports/api/properties/computedOnlyPropertySchemasIndex'; +import bulkWrite, { addSetOp, addUnsetOp, newOperation } from '/imports/api/engine/shared/bulkWrite'; export default function writeAlteredProperties(computation) { let bulkWriteOperations = []; @@ -33,7 +32,7 @@ export default function writeAlteredProperties(computation) { bulkWriteOperations.push(op); } }); - bulkWriteProperties(bulkWriteOperations); + bulkWrite(bulkWriteOperations, CreatureProperties); //if (bulkWriteOperations.length) console.log(`Wrote ${bulkWriteOperations.length} props`); } @@ -42,7 +41,7 @@ function addChangedKeysToOp(op, keys, original, changed) { // and compile an operation that sets all those keys for (let key of keys) { if (!EJSON.equals(original[key], changed[key])) { - if (!op) op = newOperation(original._id, changed.type); + if (!op) op = newOperation(original._id); let value = changed[key]; if (value === undefined) { // Unset values that become undefined @@ -55,70 +54,3 @@ function addChangedKeysToOp(op, keys, original, changed) { } return op; } - -function newOperation(_id, type) { - let newOp = { - updateOne: { - filter: { _id }, - update: {}, - } - }; - if (Meteor.isClient) { - newOp.type = type; - } - return newOp; -} - -function addSetOp(op, key, value) { - if (op.updateOne.update.$set) { - op.updateOne.update.$set[key] = value; - } else { - op.updateOne.update.$set = { [key]: value }; - } -} - -function addUnsetOp(op, key) { - if (op.updateOne.update.$unset) { - op.updateOne.update.$unset[key] = 1; - } else { - op.updateOne.update.$unset = { [key]: 1 }; - } -} - -// If we re-enable client-side sheet recalculation, this needs to be run on -// both client and server to preserve latency compensation. Bulkwrite breaks -// latency compensation and causes flickering -function writePropertiesSequentially(bulkWriteOps) { - bulkWriteOps.forEach(op => { - let updateOneOrMany = op.updateOne || op.updateMany; - CreatureProperties.update(updateOneOrMany.filter, updateOneOrMany.update, { - // The bulk code is bypassing validation, so do the same here - // selector: {type: op.type} // include this if bypass is off - bypassCollection2: true, - }); - }); - //if (bulkWriteOps.length) console.log(`Wrote ${bulkWriteOps.length} props`); -} - -// This is more efficient on the database, but significantly less efficient -// in the UI because of incompatibility with latency compensation. If the -// duplicate redraws can be fixed, this is a strictly better way of processing -// writes -function bulkWriteProperties(bulkWriteOps) { - if (!bulkWriteOps.length) return; - // bulkWrite is only available on the server - if (Meteor.isServer) { - CreatureProperties.rawCollection().bulkWrite( - bulkWriteOps, - { ordered: false }, - function (e) { - if (e) { - console.error('Bulk write failed: '); - console.error(e); - } - } - ); - } else { - writePropertiesSequentially(bulkWriteOps); - } -} diff --git a/app/imports/api/engine/shared/bulkWrite.ts b/app/imports/api/engine/shared/bulkWrite.ts new file mode 100644 index 00000000..a3128327 --- /dev/null +++ b/app/imports/api/engine/shared/bulkWrite.ts @@ -0,0 +1,55 @@ +// This is more efficient on the database, but significantly less efficient +// in the UI because of incompatibility with latency compensation. If the +// duplicate redraws can be fixed, this is a strictly better way of processing +// writes +export default function bulkWrite(bulkWriteOps, collection): void | Promise { + if (!bulkWriteOps.length) return; + // bulkWrite is only available on the server + if (!Meteor.isServer) { + return writePropertiesSequentially(bulkWriteOps, collection); + } + return collection.rawCollection().bulkWrite( + bulkWriteOps, + { ordered: false } + ); +} + +// If we re-enable client-side sheet recalculation, this needs to be run on +// both client and server to preserve latency compensation. Bulkwrite breaks +// latency compensation and causes flickering +function writePropertiesSequentially(bulkWriteOps: any[], collection: Mongo.Collection) { + bulkWriteOps.forEach(op => { + const updateOneOrMany = op.updateOne || op.updateMany; + collection.update(updateOneOrMany.filter, updateOneOrMany.update, { + // The bulk code is bypassing validation, so do the same here + // @ts-expect-error Collection 2 has no typescript support + bypassCollection2: true, + }); + }); +} + +export function newOperation(_id) { + const newOp = { + updateOne: { + filter: { _id }, + update: {}, + } + }; + return newOp; +} + +export function addSetOp(op, key, value) { + if (op.updateOne.update.$set) { + op.updateOne.update.$set[key] = value; + } else { + op.updateOne.update.$set = { [key]: value }; + } +} + +export function addUnsetOp(op, key) { + if (op.updateOne.update.$unset) { + op.updateOne.update.$unset[key] = 1; + } else { + op.updateOne.update.$unset = { [key]: 1 }; + } +} diff --git a/app/imports/client/ui/creature/actions/doAction.ts b/app/imports/client/ui/creature/actions/doAction.ts index cd501f62..19e6fc03 100644 --- a/app/imports/client/ui/creature/actions/doAction.ts +++ b/app/imports/client/ui/creature/actions/doAction.ts @@ -1,7 +1,31 @@ +import { Store } from 'vuex'; import { insertAction } from '/imports/api/engine/action/methods/insertAction'; +import Task from '/imports/api/engine/action/tasks/Task'; +import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions'; +import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; +import applyAction from '/imports/api/engine/action/functions/applyAction'; +import { runAction } from '/imports/api/engine/action/methods/runAction'; -export default async function doAction(prop: any, $store, elementId) { - const actionId = await insertAction.call({ +/** + * Apply an action on the client that first creates the action on both the client and server, then + * simulates the action, opening the action dialog if necessary to get input from the user, saving + * the decisions the user makes, then applying the action as a method call to the server with the + * saved decisions, which will persist the action results. + * + * @param prop The property initializing the action, if no task is applied the property will be + * applied as the starting point of the action + * @param $store The Vuex store instance that has the dialog stack + * @param elementId The element to animate the dialog from if a dialog needs to open + * @param task The task to apply instead of applying the property itself + */ +export default async function doAction( + prop: { _id: string, root: { id: string } }, + $store: Store, + elementId: string, + task?: Task, +) { + // Create the action + const actionId = insertAction.call({ action: { creatureId: prop.root.id, rootPropId: prop._id, @@ -9,11 +33,49 @@ export default async function doAction(prop: any, $store, elementId) { taskCount: 0, } }); - $store.commit('pushDialogStack', { - component: 'action-dialog', - elementId, - data: { - actionId, - }, - }); + + // Get the inserted and cleaned action instance + const action = EngineActions.findOne(actionId); + + if (!action) throw new Meteor.Error('not-found', 'The action could not be found'); + + // Applying the action is deterministic, so we apply it, if it asks for user input, we escape and + // create a dialog that will re-apply the action, but with the ability to actually get input + // Either way, call the action method afterwards + try { + const finishedAction = await applyAction( + action, errorOnInputRequest, { simulate: true, task } + ); + return callActionMethod(finishedAction); + } catch (e) { + if (e !== 'input-requested') throw e; + return new Promise(resolve => { + $store.commit('pushDialogStack', { + component: 'action-dialog', + elementId, + data: { + actionId, + }, + async callback(action, decisions) { + resolve(await callActionMethod(action, decisions)); + }, + }); + }) + } +} + +const callActionMethod = (action: EngineAction, decisions?: any[]) => { + if (!action._id) throw new Meteor.Error('type-error', 'Action must have and _id'); + return runAction.call({ actionId: action._id, decisions }); +} + +const throwInputRequestedError = () => { + throw 'input-requested'; +} + +const errorOnInputRequest: InputProvider = { + nextStep: throwInputRequestedError, + rollDice: throwInputRequestedError, + choose: throwInputRequestedError, + advantage: throwInputRequestedError, } diff --git a/app/imports/client/ui/creature/actions/doClientAction.ts b/app/imports/client/ui/creature/actions/doClientAction.ts new file mode 100644 index 00000000..4b00d1fa --- /dev/null +++ b/app/imports/client/ui/creature/actions/doClientAction.ts @@ -0,0 +1,12 @@ +/** + * Apply an action on the client that first creates the action on both the client and server, then + * simulates the action, opening the action dialog if necessary to get input from the user, saving + * the decisions the user makes, then applying the action as a method call to the server with the + * saved decisions, which will persist the action results. + */ + +import Task from '/imports/api/engine/action/tasks/Task'; + +export default function doClientAction(propIdOrTask: string | Task) { + +}