diff --git a/app/imports/api/engine/actions/Actions.ts b/app/imports/api/engine/actions/Actions.ts new file mode 100644 index 00000000..cf34740a --- /dev/null +++ b/app/imports/api/engine/actions/Actions.ts @@ -0,0 +1,290 @@ +import SimpleSchema from 'simpl-schema'; +import { forEachRight, forEach, isEmpty } from 'lodash'; +import LogContentSchema from '/imports/api/creature/log/LogContentSchema'; +import { getPropertyChildren, getSingleProperty } from '/imports/api/engine/loadCreatures'; +import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations'; + +const Actions = new Mongo.Collection('actions'); + +type Action = { + _id: string; + creatureId: string; + rootPropId: string; + targetIds: string[]; + userInputNeeded: boolean; + stepThrough: boolean; + taskQueue: Task[]; + results: TaskResult[]; +} + +type Task = { + propId: string; + targetIds: string[]; +} + +type TaskResult = { + propId: string; + targetIds: string[]; + scope: any; + mutations: Mutation[]; +} + +type PartialTaskResult = { + scope: any; + mutations: Mutation[]; +} + +type Mutation = { + // Which creatures the mutation is applied to + targetIds: string[]; + // What changes in the database + updates?: { + propId: string; + set: any; + }[]; + // Logged when this is applied + contents?: LogContent[]; +} + +type LogContent = { + name?: string; + value?: string; + inline?: boolean; + context?: any; +} + +const ActionSchema = new SimpleSchema({ + creatureId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + rootPropId: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + targetIds: { + type: Array, + defaultValue: [], + }, + 'targetIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + userInputNeeded: { + type: Boolean, + defaultValue: false, + }, + stepThrough: { + type: Boolean, + defaultValue: false, + }, + + // A stack of tasks to apply + // Each task has a propId to apply and a targetId list + taskQueue: { + type: Array, + }, + 'taskQueue.$': { + type: Object, + }, + 'taskQueue.$.propId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'taskQueue.$.targetIds': { + type: Array, + defaultValue: [], + }, + 'taskQueue.$.targetIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + + // Applied properties + results: { + type: Array, + defaultValue: [], + }, + 'results.$': { + type: Object, + }, + // The property and target ids popped off the task stack + // Pushing these to the top of the stack and deleting the results from this point onwards + // Should re-run the action identically from this point + 'results.$.propId': { + type: Object, + }, + 'result.$.targetIds': { + type: Array, + defaultValue: [], + }, + 'result.$.targetIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + // Changes in local scope made by this result + 'results.$.scope': { + type: Object, + optional: true, + blackbox: true, + }, + // database changes + 'results.$.mutations': { + type: Array, + optional: true, + }, + 'results.$.mutations.$': { + type: Object, + }, + 'results.$.mutations.$.propId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'results.$.mutations.$.set': { + type: Object, + optional: true, + blackbox: true, + }, + 'results.$.mutations.$.logContent': { + type: LogContentSchema, + }, +}); + +Actions.attachSchema(ActionSchema); + +// Run an already created action +export async function runAction(actionId: string, userInput) { + const action = await Actions.findOneAsync(actionId); + if (!action) throw new Meteor.Error('Not found', 'The action does not exist'); + const originalAction = EJSON.clone(action); + do { + // Get the next task + const task = action.taskQueue.shift(); + // If there isn't one, stop + if (!task) break; + + // Apply the prop + await applyProperty(task, action, userInput); + } while (!action.userInputNeeded || !action.stepThrough) + + // Persist changes to the action + return writeChangedAction(originalAction, action); +} + +function writeChangedAction(original: Action, changed: Action) { + const $set = {}; + for (const key of ActionSchema.objectKeys()) { + if (!EJSON.equals(original[key], changed[key])) { + $set[key] = changed[key]; + } + } + if (!isEmpty($set)) { + return Actions.updateAsync(original._id, { $set }); + } +} + +async function applyProperty(task, action, userInput) { + // Ensure the prop exists + const prop = await getSingleProperty(action.creatureId, task.propId); + if (!prop) throw new Meteor.Error('Not found', 'Property could not be found'); + if (prop.deactivatedByToggle) return; + + // Apply the property, applyPropertyByType must have no side effects except to action. + const { result } = await applyPropertyByType[prop.type]?.(prop, task, action, userInput); + + // store the task's details and save the result + result.propId = task.propId; + result.targetIds = task.targetIds; + action.results.push(result); +} + +/** + * Push a prop and its before/after triggers to the task stack + * Triggers will share the same targetIds as the prop task + * @param action The action to add the task to + * @param prop The property to make a task of + * @param targetIds The targetIds the prop and triggers will apply to + */ +function pushPropAndTriggers(action: Action, prop, targetIds) { + // Push the before triggers to the queue + forEach(prop.triggerIds?.before, triggerId => { + action.taskQueue.push({ propId: triggerId, targetIds }); + }); + + // Push the prop task to the queue + action.taskQueue.push({ propId: prop._id, targetIds }); + + // Push the after triggers to the queue + forEach(prop.triggerIds?.after, triggerId => { + action.taskQueue.push({ propId: triggerId, targetIds }); + }); +} + +/** + * Push all the children of a prop and all related triggers to the task stack + * Triggers will share the same targetIds as the prop task + * @param action The action to add the task to + * @param prop The property to make a task of + * @param targetIds The targetIds the prop and triggers will apply to + */ +function pushChildrenAndTriggers(action: Action, prop, targetIds) { + const children = getPropertyChildren(action.creatureId, prop._id); + + // Push the child tasks and related triggers to the stack + forEach(children, childProp => { + pushPropAndTriggers(action, childProp, targetIds) + }); + + // After the children run, it must run 'afterChildren' triggers + // Make sure they're on the bottom of the stack + forEach(prop.triggerIds?.afterChildren, triggerId => { + action.taskQueue.push({ propId: triggerId, targetIds }); + }); +} + +function createResult(prop): PartialTaskResult { + // Add the property to the action's local scope + return { + scope: { + [`#${prop.type}`]: { _propId: prop._id } + }, + mutations: [], + }; +} + +// Return result object +// No side effects except pushing to taskQueue +const applyPropertyByType = { + + async note(prop, task: Task, action) { + const result = createResult(prop); + + let contents: LogContent[] | undefined = undefined; + const logContent = { name: prop.name, value: undefined }; + if (prop.summary?.text) { + recalculateInlineCalculations(prop.summary, action); + logContent.value = prop.summary.value; + } + + if (logContent.name || logContent.value) { + contents = [logContent]; + } + // Log description + if (prop.description?.text) { + recalculateInlineCalculations(prop.description, action); + if (!contents) contents = []; + contents.push({ value: prop.description.value }); + } + if (contents) { + result.mutations.push({ + contents, + targetIds: task.targetIds, + }); + } + + pushChildrenAndTriggers(action, prop, task.targetIds); + + return result; + } + +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js index 1a2b1cb7..07e3afd3 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js @@ -1,12 +1,12 @@ import embedInlineCalculations from '/imports/api/engine/computation/utility/embedInlineCalculations.js'; import recalculateCalculation from './recalculateCalculation.js' -export default function recalculateInlineCalculations(inlineCalcObj, actionContext){ +export default function recalculateInlineCalculations(inlineCalcObj, action) { // Skip if there are no calculations if (!inlineCalcObj?.inlineCalculations?.length) return; // Recalculate each calculation with the current scope inlineCalcObj.inlineCalculations.forEach(calc => { - recalculateCalculation(calc, actionContext); + recalculateCalculation(calc, action); }); // Embed the new calculated values embedInlineCalculations(inlineCalcObj); diff --git a/app/imports/api/engine/loadCreatures.js b/app/imports/api/engine/loadCreatures.js index 3979ba24..3586d934 100644 --- a/app/imports/api/engine/loadCreatures.js +++ b/app/imports/api/engine/loadCreatures.js @@ -189,6 +189,30 @@ export function getPropertyDecendants(creatureId, propertyId) { } } +export function getPropertyChildren(creatureId, propertyId) { + const property = getSingleProperty(creatureId, propertyId); + if (!property) return []; + // This propertyId will always appear in the parent of the children + if (loadedCreatures.has(creatureId)) { + const creature = loadedCreatures.get(creatureId); + const props = []; + for (const prop of creature.properties.values()) { + if (prop.parent?.id === propertyId) { + props.push(prop); + } + } + const cloneProps = EJSON.clone(props); + return cloneProps.sort((a, b) => a.order - b.order); + } else { + return CreatureProperties.find({ + 'parent.id': propertyId, + removed: { $ne: true }, + }, { + sort: { order: 1 }, + }).fetch(); + } +} + class LoadedCreature { constructor(sub, creatureId) { // This may be called from a subscription, but we don't want the observers