From 581f99d46731acc97fd1f3ebd9f9c9e6c9c4e377 Mon Sep 17 00:00:00 2001 From: ThaumRystra <9525416+ThaumRystra@users.noreply.github.com> Date: Thu, 16 Nov 2023 22:21:48 +0200 Subject: [PATCH] Tested and fixed branches and notes in new action interrupt system --- app/imports/api/engine/actions/Actions.ts | 285 ++++++++++++++---- .../api/engine/actions/actions.test.ts | 117 +++++-- .../shared/recalculateCalculation.js | 16 +- 3 files changed, 339 insertions(+), 79 deletions(-) diff --git a/app/imports/api/engine/actions/Actions.ts b/app/imports/api/engine/actions/Actions.ts index fe7095db..a9d4561c 100644 --- a/app/imports/api/engine/actions/Actions.ts +++ b/app/imports/api/engine/actions/Actions.ts @@ -1,8 +1,10 @@ import SimpleSchema from 'simpl-schema'; -import { forEach, isEmpty } from 'lodash'; +import { forEach, isEmpty, pick } from 'lodash'; import LogContentSchema from '/imports/api/creature/log/LogContentSchema'; -import { getPropertyChildren, getSingleProperty } from '/imports/api/engine/loadCreatures'; +import { getPropertyChildren, getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures'; import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations'; +import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'; +import rollDice from '/imports/parser/rollDice'; const Actions = new Mongo.Collection('actions'); @@ -10,7 +12,7 @@ interface Action { creatureId: string; rootPropId: string; targetIds?: string[]; - userInputNeeded?: boolean; + userInputNeeded?: any; stepThrough?: boolean; taskQueue: Task[]; results: TaskResult[]; @@ -32,9 +34,24 @@ type TaskResult = { mutations: Mutation[]; } -type PartialTaskResult = { +class PartialTaskResult { scope: any; mutations: Mutation[]; + constructor() { + this.scope = {}; + this.mutations = []; + } + // Appends the log content to the latest mutation + appendLog(content: LogContent, targetIds: string[]) { + if (!this.mutations.length) { + this.mutations.push({ targetIds, contents: [] }); + } + const latestMutation = this.mutations[this.mutations.length - 1] + if (!latestMutation.contents) { + latestMutation.contents = []; + } + latestMutation.contents.push(content); + } } type Mutation = { @@ -74,8 +91,9 @@ const ActionSchema = new SimpleSchema({ regEx: SimpleSchema.RegEx.Id, }, userInputNeeded: { - type: Boolean, - defaultValue: false, + type: Object, + optional: true, + blackbox: true, }, stepThrough: { type: Boolean, @@ -115,7 +133,8 @@ const ActionSchema = new SimpleSchema({ // 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, + type: String, + regEx: SimpleSchema.RegEx.Id, }, 'results.$.targetIds': { type: Array, @@ -139,16 +158,34 @@ const ActionSchema = new SimpleSchema({ 'results.$.mutations.$': { type: Object, }, - 'results.$.mutations.$.propId': { + 'results.$.mutations.$.targetIds': { + type: Array, + }, + 'results.$.mutations.$.targetIds.$': { type: String, regEx: SimpleSchema.RegEx.Id, }, - 'results.$.mutations.$.set': { + 'results.$.mutations.$.updates': { + type: Array, + optional: true, + }, + 'results.$.mutations.$.updates.$': { + type: Object, + }, + 'results.$.mutations.$.updates.$.propId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'results.$.mutations.$.updates.$.set': { type: Object, optional: true, blackbox: true, }, - 'results.$.mutations.$.logContent': { + 'results.$.mutations.$.contents': { + type: Array, + optional: true, + }, + 'results.$.mutations.$.contents.$': { type: LogContentSchema, }, }); @@ -173,22 +210,52 @@ export function createAction(prop) { } // Run an already created action -export async function runAction(actionId: string, userInput) { +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); + let count = 0; 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) + // If there isn't a next task, stop + if (!action.taskQueue.length) break; + await applyNextTask(action, userInput); + count += 1; + if (count > 100) { + break; + } + } while (!action.userInputNeeded && !action.stepThrough) // Persist changes to the action - return writeChangedAction(originalAction, action); + const writePromise = writeChangedAction(originalAction, action); + if (count > 100) { + throw new Meteor.Error('Too many properties', 'Only 100 properties may fire at a time'); + } + return writePromise; +} + +async function applyNextTask(action, userInput?) { + // Get the next task + const task = action.taskQueue[0]; + // 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 + const result: TaskResult | undefined = await applyPropertyByType[prop.type]?.(prop, task, action, userInput); + + if (result) { + // There was a result, we can remove this task from the queue + action.taskQueue.shift(); + // store the task's details and save the result + result.scope[`#${prop.type}`] = prop; + result.propId = task.propId; + result.targetIds = task.targetIds; + action.results.push(result); + } else if (!action.userInputNeeded) { + // Prevent accidental infinite loops if we don't remove the task, but also don't break for input + throw 'The only time result can be undefined is if we are waiting for user input'; + } } function writeChangedAction(original: ActionWithId, changed: ActionWithId) { @@ -203,21 +270,6 @@ function writeChangedAction(original: ActionWithId, changed: ActionWithId) { } } -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 - const { result }: { result: TaskResult } = 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 @@ -241,43 +293,45 @@ function pushPropAndTriggers(action: Action, prop, 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 + * Push all the children of a prop and all trigger of those children to the task queue * @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 */ -async function pushChildrenAndTriggers(action: Action, prop, targetIds) { +async function pushChildren(action: Action, prop, targetIds) { const children = await getPropertyChildren(action.creatureId, prop._id); - // Push the child tasks and related triggers to the stack forEach(children, childProp => { - pushPropAndTriggers(action, childProp, targetIds) + pushPropAndTriggers(action, childProp, targetIds); }); +} - // After the children run, it must run 'afterChildren' triggers - // Make sure they're on the bottom of the stack +function pushAfterChildrenTriggers(action: Action, prop, targetIds) { forEach(prop.triggerIds?.afterChildren, triggerId => { action.taskQueue.push({ propId: triggerId, targetIds }); }); } -function createResult(prop): PartialTaskResult { +function createResult(): PartialTaskResult { // Add the property to the action's local scope - return { - scope: { - [`#${prop.type}`]: { _propId: prop._id } - }, - mutations: [], - }; + return new PartialTaskResult(); +} + +// Combine all the action results into the scope at present +export function getEffectiveActionScope(action) { + const scope = getVariables(action.creatureId); + for (const result of action.results) { + Object.assign(scope, result.scope); + } + return scope; } // Return result object // No side effects except pushing to taskQueue const applyPropertyByType = { - async note(prop, task: Task, action) { - const result = createResult(prop); + async note(prop, task: Task, action: Action) { + const result = createResult(); let contents: LogContent[] | undefined = undefined; const logContent = { name: prop.name, value: undefined }; @@ -302,9 +356,134 @@ const applyPropertyByType = { }); } - await pushChildrenAndTriggers(action, prop, task.targetIds); + await pushChildren(action, prop, task.targetIds); + await pushAfterChildrenTriggers(action, prop, task.targetIds); return result; - } + }, + + async branch(prop, task: Task, action: Action, userInput) { + // const scope = getEffectiveActionScope(action); + const result = createResult(); + const targets = task.targetIds; + + switch (prop.branchType) { + case 'if': { + recalculateCalculation(prop.condition, action, 'reduce'); + if (prop.condition?.value) { + await pushChildren(action, prop, targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'index': { + const children = await getPropertyChildren(action.creatureId, prop._id); + if (children.length) { + recalculateCalculation(prop.condition, action, 'reduce'); + if (!isFinite(prop.condition?.value)) { + result.appendLog({ + name: 'Branch Error', + value: 'Index did not resolve into a valid number' + }, targets); + break; + } + let index = Math.floor(prop.condition?.value); + if (index < 1) index = 1; + if (index > children.length) index = children.length; + pushPropAndTriggers(action, children[index - 1], targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'hit': { + const scope = getEffectiveActionScope(action); + if (scope['~attackHit']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On hit**' + }, targets); + } + await pushChildren(action, prop, targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'miss': { + const scope = getEffectiveActionScope(action); + if (scope['~attackMiss']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On miss**' + }, targets); + } + await pushChildren(action, prop, targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'failedSave': { + const scope = getEffectiveActionScope(action); + if (scope['~saveFailed']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On failed save**' + }, targets); + } + await pushChildren(action, prop, targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'successfulSave': { + const scope = getEffectiveActionScope(action); + if (scope['~saveSucceeded']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On save**' + }, targets); + } + await pushChildren(action, prop, targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'random': { + const children = await getPropertyChildren(action.creatureId, prop._id); + if (children.length) { + const index = rollDice(1, children.length)[0] - 1; + pushPropAndTriggers(action, children[index], targets); + } + pushAfterChildrenTriggers(action, prop, targets); + break; + } + case 'eachTarget': + if (targets.length) { + for (const targetId in targets) { + await pushChildren(action, prop, [targetId]); + pushAfterChildrenTriggers(action, prop, [targetId]); + } + } else { + await pushChildren(action, prop, targets); + pushAfterChildrenTriggers(action, prop, targets); + } + break; + case 'choice': { + // If there is no input to consume, return no result, but mark the action as requiring input + if (!userInput) { + action.userInputNeeded = pick(prop, ['_id', 'type', 'branchType']); + return; + } + const children = await getPropertyChildren(action.creatureId, prop._id); + let index = userInput.choice; + if (!isFinite(index) || index < 0) index = 0; + if (index > children.length - 1) index = children.length - 1; + pushPropAndTriggers(action, children[index], targets); + pushAfterChildrenTriggers(action, prop, targets); + break; + } + } + + return result; + }, } diff --git a/app/imports/api/engine/actions/actions.test.ts b/app/imports/api/engine/actions/actions.test.ts index 5169b2c4..9d9d2968 100644 --- a/app/imports/api/engine/actions/actions.test.ts +++ b/app/imports/api/engine/actions/actions.test.ts @@ -4,37 +4,116 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import { propsFromForest } from '/imports/api/properties/tests/propTestBuilder.testFn'; import Creatures from '/imports/api/creature/creatures/Creatures'; import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; -import Actions, { createAction } from '/imports/api/engine/actions/Actions'; +import Actions, { createAction, runAction } from '/imports/api/engine/actions/Actions'; +import computeCreature from '/imports/api/engine/computeCreature'; -describe('Interrupt action system', async function () { - CreatureProperties.remove({}); - Creatures.remove({}); - CreatureVariables.remove({}); - const creatureId = await Creatures.insertAsync({ - name: 'action test creature', - owner: Random.id(), +let creatureId; + +describe('Interrupt action system', function () { + before(async function () { + CreatureProperties.remove({}); + Creatures.remove({}); + CreatureVariables.remove({}); + creatureId = await Creatures.insertAsync({ + name: 'action test creature', + owner: Random.id(), + dirty: true, + }); + await insertActionTestProps(); + computeCreature(creatureId); }); - await insertActionTestProps(); - - it('creates an action', async function () { - const note1 = await CreatureProperties.findOneAsync(note1Id); - const actionId = await createAction(note1); - const action = await Actions.findOneAsync(actionId); - console.log(action); + it('writes notes to the log', async function () { + assert.equal( + await testRunActionById(note1Id), + 'Note 1 summary. 1 + 1 = 2' + ); + }); + it('Applies the children of if branches', async function () { + assert.equal( + await testRunActionById(ifTruthyBranchId), + 'child of if branch' + ); + assert.isUndefined( + await testRunActionById(ifFalsyBranchId) + ); + }); + it('Applies the children of index branches', async function () { + assert.equal( + await testRunActionById(indexBranchId), + 'child 2 of index branch' + ); + }); + it('Halts execution of choice branches', async function () { + const action = await runActionById(choiceBranchId); + if (!action) throw 'Action is expected to exist'; + assert.isUndefined(action.results[0]); + assert.exists(action.userInputNeeded); }); }); -const creatureId = Random.id(); -const note1Id = Random.id(); +async function runActionById(propId) { + const prop = await CreatureProperties.findOneAsync(propId); + const actionId = await createAction(prop); + await runAction(actionId); + const action = await Actions.findOneAsync(actionId); + return action; +} + +async function testRunActionById(propId) { + const action = await runActionById(propId); + return action?.results?.[action.results.length - 1]?.mutations?.[0]?.contents?.[0]?.value; +} + +let note1Id, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId; const propForest = [ + // Apply a simple note { - _id: note1Id, + _id: note1Id = Random.id(), type: 'note', summary: { text: 'Note 1 summary. 1 + 1 = {1 + 1}' }, - } + }, + // Apply an if branch with a truthy condition + { + _id: ifTruthyBranchId = Random.id(), + type: 'branch', + branchType: 'if', + condition: { calculation: '1 + 1' }, + children: [{ type: 'note', summary: { text: 'child of if branch' } }], + }, + // Apply an if branch with a falsy condition + { + _id: ifFalsyBranchId = Random.id(), + type: 'branch', + branchType: 'if', + condition: { calculation: '1 - 1' }, + children: [{ type: 'note', summary: { text: 'child of if branch' } }], + }, + // Apply an index branch + { + _id: indexBranchId = Random.id(), + type: 'branch', + branchType: 'index', + condition: { calculation: '1 + 1' }, + children: [ + { type: 'note', summary: { text: 'child 1 of index branch' } }, + { type: 'note', summary: { text: 'child 2 of index branch' } }, + { type: 'note', summary: { text: 'child 3 of index branch' } }, + ], + }, + // Apply a choice branch + { + _id: choiceBranchId = Random.id(), + type: 'branch', + branchType: 'choice', + children: [ + { type: 'note', summary: { text: 'child 1 of choice branch' } }, + { type: 'note', summary: { text: 'child 2 of choice branch' } }, + { type: 'note', summary: { text: 'child 3 of choice branch' } }, + ], + }, ]; function insertActionTestProps() { diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js index 2993e0f1..4f08c8e9 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js +++ b/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js @@ -7,14 +7,16 @@ import { } from '/imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js'; import { getSingleProperty } from '/imports/api/engine/loadCreatures'; import resolve from '/imports/parser/resolve.js'; +import { getEffectiveActionScope } from '/imports/api/engine/actions/Actions'; // Redo the work of imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js // But in the action scope -export default function recalculateCalculation(calcObj, actionContext, parseLevel = 'reduce', context) { +export default function recalculateCalculation(calcObj, action, parseLevel = 'reduce', context) { if (!calcObj?.parseNode) return; calcObj._parseLevel = parseLevel; + const scope = getEffectiveActionScope(action); // Re-resolve the parse node - resolveCalculationNode(calcObj, calcObj.parseNode, actionContext.scope, context); + resolveCalculationNode(calcObj, calcObj.parseNode, scope, context); // store the unaffected value if (calcObj.effectIds || calcObj.proficiencyIds) { calcObj.unaffected = toPrimitiveOrString(calcObj.valueNode); @@ -22,20 +24,20 @@ export default function recalculateCalculation(calcObj, actionContext, parseLeve // Apply all the effects and proficiencies aggregateCalculationEffects( calcObj, - id => getSingleProperty(actionContext.creature._id, id) + id => getSingleProperty(action.creatureId, id) ); aggregateCalculationProficiencies( calcObj, - id => getSingleProperty(actionContext.creature._id, id), - actionContext.scope['proficiencyBonus']?.value || 0 + id => getSingleProperty(action.creatureId, id), + scope['proficiencyBonus']?.value || 0 ); // Resolve the modified valueNode - resolveCalculationNode(calcObj, calcObj.valueNode, actionContext.scope, context); + resolveCalculationNode(calcObj, calcObj.valueNode, scope, context); // Store the primitive value calcObj.value = toPrimitiveOrString(calcObj.valueNode); - logErrors(calcObj.errors, actionContext); + logErrors(calcObj.errors, action); } export function rollAndReduceCalculation(calcObj, actionContext, context) {