diff --git a/app/imports/api/creature/log/LogContentSchema.js b/app/imports/api/creature/log/LogContentSchema.js index 05d9ce87..8288157e 100644 --- a/app/imports/api/creature/log/LogContentSchema.js +++ b/app/imports/api/creature/log/LogContentSchema.js @@ -22,11 +22,16 @@ let LogContentSchema = new SimpleSchema({ type: Boolean, optional: true, }, + // This log entry was silenced + silenced: { + type: Boolean, + optional: true, + }, context: { type: Object, optional: true, }, - 'context.errors':{ + 'context.errors': { type: Array, defaultValue: [], maxCount: STORAGE_LIMITS.errorCount, diff --git a/app/imports/api/engine/actions/Actions.ts b/app/imports/api/engine/actions/Actions.ts index 3cbf8828..df61103f 100644 --- a/app/imports/api/engine/actions/Actions.ts +++ b/app/imports/api/engine/actions/Actions.ts @@ -8,7 +8,7 @@ import rollDice from '/imports/parser/rollDice'; const Actions = new Mongo.Collection('actions'); -interface Action { +export interface Action { creatureId: string; rootPropId: string; targetIds?: string[]; @@ -27,6 +27,7 @@ interface ActionWithId extends Action { type Task = { propId: string; targetIds: string[]; + step?: number, } type TaskResult = { @@ -34,17 +35,11 @@ type TaskResult = { targetIds: string[]; scope: any; mutations: Mutation[]; - step?: number; - deferred?: boolean; - deferredState?: any; } class PartialTaskResult { scope: any; mutations: Mutation[]; - step?: number; - deferred?: boolean; - deferredState?: any; constructor() { this.scope = {}; this.mutations = []; @@ -66,17 +61,19 @@ type Mutation = { // Which creatures the mutation is applied to targetIds: string[]; // What changes in the database - updates?: { - propId: string; - set?: any; - inc?: any; - type: string, - }[]; + updates?: Update[]; // Logged when this is applied contents?: LogContent[]; } -type LogContent = { +export type Update = { + propId: string; + type: string, + set?: any; + inc?: any; +} + +export type LogContent = { name?: string; value?: string; inline?: boolean; @@ -123,6 +120,10 @@ const ActionSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, + 'taskQueue.$.step': { + type: Number, + optional: true, + }, 'taskQueue.$.targetIds': { type: Array, defaultValue: [], @@ -202,11 +203,20 @@ const ActionSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, + // Required, because CreatureProperties.update requires a selector of { type } + 'results.$.mutations.$.updates.$.type': { + type: String, + }, 'results.$.mutations.$.updates.$.set': { type: Object, optional: true, blackbox: true, }, + 'results.$.mutations.$.updates.$.inc': { + type: Object, + optional: true, + blackbox: true, + }, 'results.$.mutations.$.contents': { type: Array, optional: true, @@ -262,9 +272,13 @@ export async function runAction(actionId: string, userInput?) { return writePromise; } -async function applyNextTask(action, userInput?) { +// TODO create a function to get the effective value of a property, +// simulating all the result updates in the action so far + +async function applyNextTask(action: Action, userInput?) { // Get the next task - const task = action.taskQueue[0]; + const task = action.taskQueue.shift(); + if (!task) throw 'Next task does not exist'; // Get the property from the action's task properties or the creature's properties let prop; const taskProp = action.taskProperties[task.propId]; @@ -278,26 +292,15 @@ async function applyNextTask(action, userInput?) { if (prop.deactivatedByToggle) return; // Apply the property - const result: TaskResult | undefined = await applyPropertyByType[prop.type]?.(prop, task, action, userInput); - - if (result) { - // store the task's details and save the result - result.scope[`#${prop.type}`] = prop; - result.propId = task.propId; - result.targetIds = task.targetIds; - if (result.deferred) { - delete result.deferred; - result.step = (result.step || 0) + 1; - action.deferredResults[task.propId] = result; - } else { - // There was a result and it wasn't deferred, we can remove this task from the queue - action.taskQueue.shift(); - 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'; - } + const result: PartialTaskResult = await applyPropertyByType[prop.type]?.(prop, task, action, userInput); + // store the task's details and save the result + result.scope[`#${prop.type}`] = prop; + action.results.push({ + propId: task.propId, + targetIds: task.targetIds, + scope: result.scope, + mutations: result.mutations, + }); } function writeChangedAction(original: ActionWithId, changed: ActionWithId) { @@ -360,15 +363,21 @@ function createResult(): PartialTaskResult { } // Combine all the action results into the scope at present -export function getEffectiveActionScope(action) { +export function getEffectiveActionScope(action: Action) { const scope = getVariables(action.creatureId); + // First combine the applied results for (const result of action.results) { Object.assign(scope, result.scope); } + // Then the deferred results + // Warning: order is not guaranteed here + for (const id in action.deferredResults) { + const result = action.deferredResults[id]; + Object.assign(scope, result.scope); + } return scope; } - type DamageProp = { _id?: string; operation: 'increment' | 'set'; @@ -547,18 +556,30 @@ const applyPropertyByType = { } break; case 'choice': { - // If there is no input to consume, return no result, but mark the action as requiring input - if (!userInput) { + // Step 0, halt the action to get user input + if (!task.step) { + // Mark the action as needing user input so that it halts action.userInputNeeded = pick(prop, ['_id', 'type', 'branchType']); - return; + // Put this task back in the queue, but at step 1 + action.taskQueue.push({ + ...task, + step: 1, + }); + return result; } - 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; + // Step 1 consume the user input + else if (task.step === 1) { + if (!userInput) { + throw 'User input was required for this step' + } + 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); + } + return result; } } @@ -566,9 +587,7 @@ const applyPropertyByType = { }, async adjustment(prop, task: Task, action: Action): Promise { - - let result = action.deferredResults[task.propId]; - if (!result) result = createResult(); + const result = createResult(); const queueChildren = async function (targetIds) { await pushChildren(action, prop, targetIds); @@ -576,9 +595,10 @@ const applyPropertyByType = { } const damageTargets = prop.target === 'self' ? [action.creatureId] : task.targetIds; + task.targetIds = damageTargets; // Step 0, get the operation and value and push the damage pseudo prop to the queue - if (!result.step) { + if (!task.step) { if (!prop.amount) { queueChildren(task.targetIds); @@ -587,7 +607,6 @@ const applyPropertyByType = { // Evaluate the amount recalculateCalculation(prop.amount, action, 'reduce'); - const value = +prop.amount.value; if (!isFinite(value)) { queueChildren(task.targetIds); @@ -596,7 +615,6 @@ const applyPropertyByType = { if (damageTargets?.length) { for (const targetId of damageTargets) { - queueChildren([targetId]); const statId = getVariables(targetId)?.[prop.stat]?._propId; if (!statId) continue; @@ -610,22 +628,37 @@ const applyPropertyByType = { }, [targetId]); continue; } + // Do the damage pushDamagePropertyTasks(action, { type: 'damageProp', value, operation: prop.operation, targetPropId: stat._id, }, stat, [targetId], result); + // Do the next step of this property + action.taskQueue.push({ + ...task, + step: 1, + }); } } - result.deferred = true; - result.deferredState = { value }; } // Step 1, Log the results - else if (result.step === 1) { - const value = result.deferredState.value; + else if (task.step === 1) { + const scope = getEffectiveActionScope(action); + let value; + if (prop.operation === 'increment') { + if (prop.value >= 0) { + value = scope['~damage']?.value; + } else { + value = -scope['~healing']?.value; + } + } else { + value = scope['~set']?.value; + } if (damageTargets?.length) { for (const targetId of damageTargets) { + await queueChildren([targetId]); result.appendLog({ name: 'Attribute damage', value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + @@ -635,6 +668,7 @@ const applyPropertyByType = { }, [targetId]); } } else { + await queueChildren(task.targetIds); result.appendLog({ name: 'Attribute damage', value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + @@ -662,7 +696,6 @@ const applyPropertyByType = { } else { value = scope['~set']?.value; } - let damage, newValue, increment; if (task.targetIds.length) { for (const targetId of task.targetIds) { diff --git a/app/imports/api/engine/actions/actions.test.ts b/app/imports/api/engine/actions/actions.test.ts index 9d9d2968..e4d4aa2b 100644 --- a/app/imports/api/engine/actions/actions.test.ts +++ b/app/imports/api/engine/actions/actions.test.ts @@ -4,7 +4,7 @@ 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, runAction } from '/imports/api/engine/actions/Actions'; +import Actions, { Action, Update, LogContent, createAction, runAction } from '/imports/api/engine/actions/Actions'; import computeCreature from '/imports/api/engine/computeCreature'; let creatureId; @@ -23,31 +23,60 @@ describe('Interrupt action system', function () { computeCreature(creatureId); }); it('writes notes to the log', async function () { - assert.equal( - await testRunActionById(note1Id), - 'Note 1 summary. 1 + 1 = 2' + const action = await runActionById(note1Id); + assert.deepEqual( + allLogContent(action), + [{ value: 'Note 1 summary. 1 + 1 = 2' }] ); }); it('Applies the children of if branches', async function () { - assert.equal( - await testRunActionById(ifTruthyBranchId), - 'child of if branch' + let action = await runActionById(ifTruthyBranchId); + assert.deepEqual( + allLogContent(action), + [{ value: 'child of if branch' }] ); - assert.isUndefined( - await testRunActionById(ifFalsyBranchId) + action = await runActionById(ifFalsyBranchId); + assert.deepEqual( + allLogContent(action), + [] ); }); it('Applies the children of index branches', async function () { - assert.equal( - await testRunActionById(indexBranchId), - 'child 2 of index branch' + const action = await runActionById(indexBranchId); + assert.deepEqual( + allLogContent(action), + [{ value: '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); + assert.deepEqual( + allLogContent(action), + [] + ); + }); + it('Applies adjustments', async function () { + let action = await runActionById(adjustmentSetId) + assert.deepEqual( + allUpdates(action), + [{ + propId: adjustedStatId, + type: 'attribute', + set: { damage: 5, value: 3 }, + }], + 'Applying set adjustments should return the correct updates' + ); + action = await runActionById(adjustmentIncrementId) + assert.deepEqual( + allUpdates(action), + [{ + propId: adjustedStatId, + type: 'attribute', + inc: { damage: 2, value: -2 }, // damage goes up by 2, value down by 2 + }], + 'Applying increment adjustments should return the correct updates' + ); }); }); @@ -56,15 +85,35 @@ async function runActionById(propId) { const actionId = await createAction(prop); await runAction(actionId); const action = await Actions.findOneAsync(actionId); + if (!action) throw 'Action is expected to exist' return action; } -async function testRunActionById(propId) { - const action = await runActionById(propId); - return action?.results?.[action.results.length - 1]?.mutations?.[0]?.contents?.[0]?.value; +function allUpdates(action: Action) { + const updates: Update[] = []; + action.results.forEach(result => { + result.mutations.forEach(mutation => { + mutation.updates?.forEach(update => { + updates.push(update); + }); + }); + }); + return updates; } -let note1Id, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId; +function allLogContent(action: Action) { + const contents: LogContent[] = []; + action.results.forEach(result => { + result.mutations.forEach(mutation => { + mutation.contents?.forEach(logContent => { + contents.push(logContent); + }); + }); + }); + return contents; +} + +let note1Id, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId, adjustedStatId, adjustmentIncrementId, adjustmentSetId; const propForest = [ // Apply a simple note @@ -114,6 +163,34 @@ const propForest = [ { type: 'note', summary: { text: 'child 3 of choice branch' } }, ], }, + // Apply adjustments + { + _id: adjustedStatId = Random.id(), + type: 'attribute', + attributeType: 'stat', + variableName: 'adjustedStat', + baseValue: { calculation: '8' }, + }, { + _id: adjustmentSetId = Random.id(), + type: 'adjustment', + stat: 'adjustedStat', + operation: 'set', + amount: { calculation: '3' }, + target: 'self', + children: [ + { type: 'note', summary: { text: 'adjustment set applied' } }, + ], + }, { + _id: adjustmentIncrementId = Random.id(), + type: 'adjustment', + stat: 'adjustedStat', + operation: 'increment', + amount: { calculation: '2' }, + target: 'self', + children: [ + { type: 'note', summary: { text: 'adjustment increment applied' } }, + ], + }, ]; function insertActionTestProps() {