From eb98d0b711733e8f5995113220f42b102705bf7a Mon Sep 17 00:00:00 2001 From: ThaumRystra Date: Fri, 6 Sep 2024 17:12:04 +0200 Subject: [PATCH] Tested action branch properties --- .vscode/settings.json | 1 + .../applyProperties/applyActionProperty.ts | 4 +- .../applyBranchProperty.test.ts | 376 ++++++++++++++++++ .../applyProperties/applyBranchProperty.ts | 5 +- .../applySavingThrowProperty.ts | 23 +- app/imports/api/engine/computeCreature.js | 5 +- .../tests/propTestBuilder.testFn.js | 2 +- 7 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 app/imports/api/engine/action/applyProperties/applyBranchProperty.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a719366..8568520d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "alea", + "armor", "autorun", "blackbox", "Crits", diff --git a/app/imports/api/engine/action/applyProperties/applyActionProperty.ts b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts index e33030be..d4c7c480 100644 --- a/app/imports/api/engine/action/applyProperties/applyActionProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts @@ -129,9 +129,9 @@ async function applyAttackToTarget( }); if (criticalMiss || result < targetArmor) { - scope['~attackMiss'] = { value: true }; + taskResult.pushScope['~attackMiss'] = { value: true }; } else { - scope['~attackHit'] = { value: true }; + taskResult.pushScope['~attackHit'] = { value: true }; } } else { contents.push({ diff --git a/app/imports/api/engine/action/applyProperties/applyBranchProperty.test.ts b/app/imports/api/engine/action/applyProperties/applyBranchProperty.test.ts new file mode 100644 index 00000000..db59fc1e --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyBranchProperty.test.ts @@ -0,0 +1,376 @@ +import { assert } from 'chai'; +import { + allMutations, + createTestCreature, + getRandomIds, + removeAllCreaturesAndProps, + runActionById +} from '/imports/api/engine/action/functions/actionEngineTest.testFn'; + +const [ + creatureId, targetCreatureId, ifTrueBranchId, ifFalseBranchId, indexBranchId, attackHitId, attackMissId, saveSucceedId, saveFailId, randomBranchId, targetCreature2Id, eachTargetBranchId, choiceBranchId, +] = getRandomIds(100); + +const actionTestCreature = { + _id: creatureId, + props: [ + // If branch + { + _id: ifTrueBranchId, + type: 'branch', + branchType: 'if', + condition: { calculation: 'true' }, + children: [ + { + type: 'note', + summary: { text: 'this should run' }, + }, + ], + }, + { + _id: ifFalseBranchId, + type: 'branch', + branchType: 'if', + condition: { calculation: 'false' }, + children: [ + { + type: 'note', + summary: { text: 'this should not run' }, + }, + ], + }, + // index branch + { + _id: indexBranchId, + type: 'branch', + branchType: 'index', + condition: { calculation: '1 + 1' }, + children: [ + { + type: 'note', + summary: { text: 'FAIL: index child 1 should not run' }, + }, + { + type: 'note', + summary: { text: 'Child 2 should run' }, + }, + { + type: 'note', + summary: { text: 'FAIL: index child 3 should not run' }, + }, + ], + }, + // Hit and miss branches + { + _id: attackHitId, + type: 'action', + attackRoll: { calculation: '1' }, + children: [ + { + type: 'branch', + branchType: 'hit', + children: [{ + type: 'note', + summary: { text: 'attack hit branch' } + }], + }, + { + type: 'branch', + branchType: 'miss', + children: [{ + type: 'note', + summary: { text: 'attack miss branch' } + }], + }, + ] + }, + { + _id: attackMissId, + type: 'action', + attackRoll: { calculation: '-1' }, + children: [ + { + type: 'branch', + branchType: 'hit', + children: [{ + type: 'note', + summary: { text: 'attack hit branch' } + }], + }, + { + type: 'branch', + branchType: 'miss', + children: [{ + type: 'note', + summary: { text: 'attack miss branch' } + }], + }, + ] + }, + + // Save and fail save branch + { + _id: saveSucceedId, + type: 'savingThrow', + dc: { calculation: '10' }, + target: 'target', + stat: 'strengthSave', + children: [ + { + type: 'branch', + branchType: 'successfulSave', + children: [{ + type: 'note', + summary: { text: 'made save branch' } + }], + }, + { + type: 'branch', + branchType: 'failedSave', + children: [{ + type: 'note', + summary: { text: 'failed save branch' } + }], + }, + ] + }, + { + _id: saveFailId, + type: 'savingThrow', + dc: { calculation: '15' }, + target: 'target', + stat: 'strengthSave', + children: [ + { + type: 'branch', + branchType: 'successfulSave', + children: [{ + type: 'note', + summary: { text: 'made save branch' } + }], + }, + { + type: 'branch', + branchType: 'failedSave', + children: [{ + type: 'note', + summary: { text: 'failed save branch' } + }], + }, + ] + }, + + // Random branch + { + _id: randomBranchId, + type: 'branch', + branchType: 'random', + children: [ + { + type: 'note', + summary: { text: 'FAIL: random child 1 should not run' }, + }, + { + type: 'note', + summary: { text: 'Random child 2 should run' }, + }, + { + type: 'note', + summary: { text: 'FAIL: random child 3 should not run' }, + }, + ], + }, + + // Each target branch + { + _id: eachTargetBranchId, + type: 'branch', + branchType: 'eachTarget', + children: [ + { + type: 'note', + summary: { text: 'some note' } + } + ] + }, + + // Choice branch + { + _id: choiceBranchId, + type: 'branch', + branchType: 'choice', + children: [ + { + type: 'note', + summary: { text: 'Choice child 1 should run' }, + }, + { + type: 'note', + summary: { text: 'Fail: choice child 2 should not run' }, + }, + { + type: 'note', + summary: { text: 'Fail: choice child 3 should not run' }, + }, + ], + }, + ], +} + +const actionTargetCreature = { + _id: targetCreatureId, + props: [ + { + type: 'attribute', + attributeType: 'stat', + variableName: 'armor', + baseValue: { calculation: '10' }, + }, + { + type: 'skill', + skillType: 'save', + variableName: 'strengthSave', + baseValue: { calculation: '3' }, + }, + ] +} + +const actionTargetCreature2 = { + _id: targetCreature2Id, + props: [ + { + type: 'attribute', + attributeType: 'stat', + variableName: 'armor', + baseValue: { calculation: '15' }, + }, + ] +} + +describe('Apply Branch Properties', function () { + // Increase timeout + this.timeout(8000); + + before(async function () { + await removeAllCreaturesAndProps(); + await createTestCreature(actionTestCreature); + await createTestCreature(actionTargetCreature); + await createTestCreature(actionTargetCreature2); + }); + + // If branch + it('Runs an if branch with a true condition', async function () { + const action = await runActionById(ifTrueBranchId); + assert.deepEqual(allMutations(action), [{ + contents: [{ value: 'this should run' }], + targetIds: [], + }]); + }); + it('runs an if branch with a false condition', async function () { + const action = await runActionById(ifFalseBranchId); + assert.deepEqual(allMutations(action), []); + }); + it('runs an if branch and chooses the correct child', async function () { + const action = await runActionById(indexBranchId); + assert.deepEqual(allMutations(action), [{ + contents: [{ value: 'Child 2 should run' }], + targetIds: [], + }]); + }); + + // Hit and miss branch + it('Runs only hit branches on an attack that hits', async function () { + const action = await runActionById(attackHitId, [targetCreatureId]); + assert.deepEqual(allMutations(action), [{ + contents: [{ name: 'Action' }], + targetIds: [targetCreatureId], + }, { + contents: [{ inline: true, name: 'Hit!', value: '1d20 [10] + 1\n**11**' }], + targetIds: [targetCreatureId], + }, { + contents: [{ value: 'attack hit branch' }], + targetIds: [targetCreatureId], + }]); + }); + it('Runs only miss branches on an attack that misses', async function () { + const action = await runActionById(attackMissId, [targetCreatureId]); + assert.deepEqual(allMutations(action), [{ + contents: [{ name: 'Action' }], + targetIds: [targetCreatureId], + }, { + contents: [{ inline: true, name: 'Miss!', value: '1d20 [10] − 1\n**9**' }], + targetIds: [targetCreatureId], + }, { + contents: [{ value: 'attack miss branch' }], + targetIds: [targetCreatureId], + }]); + }); + + // Save succeed and fail branches + it('Runs only miss branches on an attack that misses', async function () { + const action = await runActionById(saveSucceedId, [targetCreatureId]); + assert.deepEqual(allMutations(action), [{ + contents: [{ + name: 'Saving throw', + value: 'DC **10**', + inline: true + }, { + name: 'Successful save', + value: '1d20 [ 10 ] + 3\n**13**', + inline: true + }], + targetIds: [targetCreatureId], + }, { + contents: [{ value: 'made save branch' }], + targetIds: [targetCreatureId], + }]); + }); + it('Runs only miss branches on an attack that misses', async function () { + const action = await runActionById(saveFailId, [targetCreatureId]); + assert.deepEqual(allMutations(action), [{ + contents: [{ + name: 'Saving throw', + value: 'DC **15**', + inline: true + }, { + name: 'Failed save', + value: '1d20 [ 10 ] + 3\n**13**', + inline: true + }], + targetIds: [targetCreatureId], + }, { + contents: [{ value: 'failed save branch' }], + targetIds: [targetCreatureId], + }]); + }); + + // Random branches, RNG is fixed at average for testing, so child 2 should run + it('runs a random branch and chooses the correct child', async function () { + const action = await runActionById(randomBranchId); + assert.deepEqual(allMutations(action), [{ + contents: [{ value: 'Random child 2 should run' }], + targetIds: [], + }]); + }); + + // Branches can split actions across targets + it('Can split actions to targets using a branch', async function () { + const action = await runActionById(eachTargetBranchId, [targetCreatureId, targetCreature2Id]); + assert.deepEqual(allMutations(action), [{ + contents: [{ value: 'some note' }], + targetIds: [targetCreatureId], + }, { + contents: [{ value: 'some note' }], + targetIds: [targetCreature2Id], + }]); + }); + + // Choice branches, choices are fixed to first option for testing + it('runs a choice branch and chooses the correct child', async function () { + const action = await runActionById(choiceBranchId); + assert.deepEqual(allMutations(action), [{ + contents: [{ value: 'Choice child 1 should run' }], + targetIds: [], + }]); + }); +}); diff --git a/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts index 8c41ce79..59a0bbea 100644 --- a/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts @@ -7,7 +7,6 @@ import recalculateCalculation from '/imports/api/engine/action/functions/recalcu import { PropTask } from '/imports/api/engine/action/tasks/Task'; import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; import { getPropertyChildren } from '/imports/api/engine/loadCreatures'; -import rollDice from '/imports/parser/rollDice'; export default async function applyBranchProperty( task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider @@ -33,7 +32,7 @@ export default async function applyBranchProperty( if (!isFinite(prop.condition?.value)) { result.appendLog({ name: 'Branch Error', - value: 'Index did not resolve into a valid number' + value: `Index did not resolve into a valid number, got \`${prop.condition?.value}\` instead` }, targets); return applyAfterTasksSkipChildren(action, prop, targets, userInput); } @@ -98,7 +97,7 @@ export default async function applyBranchProperty( case 'random': { const children = await getPropertyChildren(action.creatureId, prop); if (children.length) { - const index = rollDice(1, children.length)[0]; + const index = (await userInput.rollDice([{ number: 1, diceSize: children.length }]))[0][0]; const child = children[index - 1]; return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); } else { diff --git a/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts b/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts index 8f76dddc..43667ed0 100644 --- a/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts @@ -2,6 +2,7 @@ import { getFromScope } from '/imports/api/creature/creatures/CreatureVariables' import { EngineAction } from '/imports/api/engine/action/EngineActions'; import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation'; import { PropTask } from '/imports/api/engine/action/tasks/Task'; @@ -10,6 +11,9 @@ import { getVariables } from '/imports/api/engine/loadCreatures'; import numberToSignedString from '/imports/api/utility/numberToSignedString'; import { isFiniteNode } from '/imports/parser/parseTree/constant'; +// TODO saves are not split to targets correctly +// This will cause issues with triggers firing on saves on multiple targets + export default async function applySavingThrowProperty( task: PropTask, action: EngineAction, result: TaskResult, inputProvider: InputProvider ): Promise { @@ -23,7 +27,7 @@ export default async function applySavingThrowProperty( recalculateCalculation(prop.dc, action, 'reduce', inputProvider); - if (!isFiniteNode(prop.dc)) { + if (!isFiniteNode(prop.dc?.parseNode)) { result.appendLog({ name: 'Error', value: 'Saving throw requires a DC', @@ -33,7 +37,7 @@ export default async function applySavingThrowProperty( const dc = (prop.dc?.value); if (!prop.silent) result.appendLog({ - name: prop.name, + name: getPropertyTitle(prop), value: `DC **${dc}**`, inline: true, ...prop.silent && { silenced: prop.silent } @@ -43,7 +47,7 @@ export default async function applySavingThrowProperty( // If there are no save targets, apply all children as if the save both // succeeded and failed if (!saveTargetIds?.length) { - result.scope = { + result.pushScope = { ['~saveFailed']: { value: true }, ['~saveSucceeded']: { value: true }, } @@ -53,14 +57,14 @@ export default async function applySavingThrowProperty( // Each target makes the saving throw for (const targetId of saveTargetIds) { - const save = getFromScope('save', getVariables(targetId)); + const save = getFromScope(prop.stat, getVariables(targetId)); if (!save) { result.appendLog({ name: 'Saving throw error', value: 'No saving throw found: ' + prop.stat, }, [targetId]); - applyDefaultAfterPropTasks(action, prop, [targetId], inputProvider); + return applyDefaultAfterPropTasks(action, prop, [targetId], inputProvider); } const rollModifierText = numberToSignedString(save.value, true); @@ -90,14 +94,15 @@ export default async function applySavingThrowProperty( value = rolledValue; resultPrefix = `1d20 [ ${value} ] ${rollModifierText}` } - scope['~saveDiceRoll'] = { value }; + result.pushScope = {}; + result.pushScope['~saveDiceRoll'] = { value }; const resultValue = value + rollModifier || 0; - scope['~saveRoll'] = { value: resultValue }; + result.pushScope['~saveRoll'] = { value: resultValue }; const saveSuccess = resultValue >= dc; if (saveSuccess) { - scope['~saveSucceeded'] = { value: true }; + result.pushScope['~saveSucceeded'] = { value: true }; } else { - scope['~saveFailed'] = { value: true }; + result.pushScope['~saveFailed'] = { value: true }; } if (!prop.silent) result.appendLog({ name: saveSuccess ? 'Successful save' : 'Failed save', diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.js index 94b5debe..96362d7c 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.js @@ -14,8 +14,9 @@ export default async function computeCreature(creatureId) { async function computeComputation(computation, creatureId) { try { await computeCreatureComputation(computation); - writeAlteredProperties(computation); - writeScope(creatureId, computation); + const writePromise = writeAlteredProperties(computation); + const scopeWritePromise = writeScope(creatureId, computation); + await Promise.all([writePromise, scopeWritePromise]); } catch (e) { const errorText = e.reason || e.message || e.toString(); computation.errors.push({ diff --git a/app/imports/api/properties/tests/propTestBuilder.testFn.js b/app/imports/api/properties/tests/propTestBuilder.testFn.js index deddfd79..4d17e677 100644 --- a/app/imports/api/properties/tests/propTestBuilder.testFn.js +++ b/app/imports/api/properties/tests/propTestBuilder.testFn.js @@ -17,7 +17,7 @@ export function propsFromForest( const children = prop.children; // Check the property has a type if (!prop.type) { - throw 'Type is required on every property, not found on above doc'; + throw new Error('Type is required on every property, not found on doc: ' + JSON.stringify(prop, null, 2)); } // Create the clean doc const doc = {