From aee9d6b8cb916040c798542d576664240ecf9159 Mon Sep 17 00:00:00 2001 From: Thaum Rystra <9525416+ThaumRystra@users.noreply.github.com> Date: Thu, 15 Feb 2024 22:30:50 +0200 Subject: [PATCH] refactored action engine into individual files --- .vscode/settings.json | 8 + .../methods/copyPropertyToLibrary.js | 2 +- .../api/creature/creatures/Creatures.js | 2 - .../{actions => action}/ActionEngine.test.ts | 185 ++- .../api/engine/action/EngineActions.ts | 135 ++ .../applyProperties/applyActionProperty.ts | 273 ++++ .../applyAdjustmentProperty.ts | 63 + .../applyProperties/applyBranchProperty.ts | 129 ++ .../applyProperties/applyBuffProperty.ts | 165 ++ .../applyBuffRemoverProperty.ts} | 59 +- .../applyProperties/applyDamageProperty.ts} | 14 +- .../applyProperties/applyFolderProperty.ts | 11 + .../applyProperties/applyNoteProperty.ts | 35 + .../applyProperties/applyRollProperty.ts | 58 + .../applySavingThrowProperty.ts} | 8 +- .../applyProperties/applyToggleProperty.ts} | 4 +- .../engine/action/applyProperties/index.ts | 26 + .../engine/action/functions/applyAction.ts | 42 + .../action/functions/applyTaskGroups.ts | 141 ++ .../functions/getEffectiveActionScope.ts | 60 + .../functions/recalculateCalculation.ts} | 11 +- .../recalculateInlineCalculations.ts} | 0 .../engine/action/functions/spendResources.ts | 75 + .../methods}/doCastSpell.js | 2 + .../{actions => action/methods}/doCheck.js | 4 +- .../api/engine/action/methods/insertAction.ts | 21 + .../api/engine/action/methods/runAction.ts | 34 + app/imports/api/engine/action/tasks/Task.ts | 33 + .../api/engine/action/tasks/TaskResult.ts | 73 + .../action/tasks/applyDamagePropTask.ts | 131 ++ .../action/tasks/applyItemAsAmmoTask.ts | 55 + .../api/engine/action/tasks/applyTask.ts | 49 + .../api/engine/actions/ActionContext.ts | 82 - .../api/engine/actions/ActionEngine.ts | 1452 ----------------- .../api/engine/actions/applyProperty.ts | 38 - .../applyPropertyByType/applyAction.ts | 329 ---- .../applyPropertyByType/applyAdjustment.js | 56 - .../applyPropertyByType/applyBranch.js | 95 -- .../actions/applyPropertyByType/applyBuff.js | 169 -- .../applyPropertyByType/applyFolder.js | 9 - .../applyPropertyByType/applyItemAsAmmo.js | 42 - .../actions/applyPropertyByType/applyNote.js | 25 - .../actions/applyPropertyByType/applyRoll.js | 56 - .../shared/applyChildren.js | 10 - .../applyPropertyByType/shared/logErrors.js | 7 - .../engine/actions/applyTriggers.testFn.js | 67 - .../api/engine/actions/applyTriggers.ts | 114 -- app/imports/api/engine/actions/doAction.js | 111 -- .../api/engine/actions/doAction.test.js | 58 - .../api/engine/actions/getUserInput.js | 90 - app/imports/api/engine/actions/index.js | 2 - app/imports/api/engine/loadCreatures.ts | 5 +- .../api/parenting/parentingFunctions.ts | 4 +- app/imports/api/utility/getPropertyTitle.ts | 6 + .../components/actions/ActionCard.vue | 2 +- .../components/actions/EventButton.vue | 2 +- .../components/attributes/AbilityListTile.vue | 1 + .../attributes/AttributeCardContent.vue | 2 +- .../components/attributes/SpellSlotCard.vue | 2 +- .../components/skills/SkillListTile.vue | 1 + .../ui/properties/viewers/ActionViewer.vue | 3 +- .../client/ui/tabletop/TabletopActionCard.vue | 2 +- app/imports/parser/parseTree/accessor.js | 2 +- 63 files changed, 1854 insertions(+), 2898 deletions(-) create mode 100644 .vscode/settings.json rename app/imports/api/engine/{actions => action}/ActionEngine.test.ts (62%) create mode 100644 app/imports/api/engine/action/EngineActions.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyActionProperty.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyBranchProperty.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyBuffProperty.ts rename app/imports/api/engine/{actions/applyPropertyByType/applyBuffRemover.js => action/applyProperties/applyBuffRemoverProperty.ts} (56%) rename app/imports/api/engine/{actions/applyPropertyByType/applyDamage.js => action/applyProperties/applyDamageProperty.ts} (91%) create mode 100644 app/imports/api/engine/action/applyProperties/applyFolderProperty.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyNoteProperty.ts create mode 100644 app/imports/api/engine/action/applyProperties/applyRollProperty.ts rename app/imports/api/engine/{actions/applyPropertyByType/applySavingThrow.js => action/applyProperties/applySavingThrowProperty.ts} (86%) rename app/imports/api/engine/{actions/applyPropertyByType/applyToggle.js => action/applyProperties/applyToggleProperty.ts} (52%) create mode 100644 app/imports/api/engine/action/applyProperties/index.ts create mode 100644 app/imports/api/engine/action/functions/applyAction.ts create mode 100644 app/imports/api/engine/action/functions/applyTaskGroups.ts create mode 100644 app/imports/api/engine/action/functions/getEffectiveActionScope.ts rename app/imports/api/engine/{actions/applyPropertyByType/shared/recalculateCalculation.js => action/functions/recalculateCalculation.ts} (85%) rename app/imports/api/engine/{actions/applyPropertyByType/shared/recalculateInlineCalculations.js => action/functions/recalculateInlineCalculations.ts} (100%) create mode 100644 app/imports/api/engine/action/functions/spendResources.ts rename app/imports/api/engine/{actions => action/methods}/doCastSpell.js (98%) rename app/imports/api/engine/{actions => action/methods}/doCheck.js (96%) create mode 100644 app/imports/api/engine/action/methods/insertAction.ts create mode 100644 app/imports/api/engine/action/methods/runAction.ts create mode 100644 app/imports/api/engine/action/tasks/Task.ts create mode 100644 app/imports/api/engine/action/tasks/TaskResult.ts create mode 100644 app/imports/api/engine/action/tasks/applyDamagePropTask.ts create mode 100644 app/imports/api/engine/action/tasks/applyItemAsAmmoTask.ts create mode 100644 app/imports/api/engine/action/tasks/applyTask.ts delete mode 100644 app/imports/api/engine/actions/ActionContext.ts delete mode 100644 app/imports/api/engine/actions/ActionEngine.ts delete mode 100644 app/imports/api/engine/actions/applyProperty.ts delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyAction.ts delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyBranch.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyBuff.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyFolder.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyItemAsAmmo.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyNote.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/applyRoll.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/shared/applyChildren.js delete mode 100644 app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js delete mode 100644 app/imports/api/engine/actions/applyTriggers.testFn.js delete mode 100644 app/imports/api/engine/actions/applyTriggers.ts delete mode 100644 app/imports/api/engine/actions/doAction.js delete mode 100644 app/imports/api/engine/actions/doAction.test.js delete mode 100644 app/imports/api/engine/actions/getUserInput.js delete mode 100644 app/imports/api/engine/actions/index.js create mode 100644 app/imports/api/utility/getPropertyTitle.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..38caf0bf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "autorun", + "cyrb", + "EJSON", + "uncomputed" + ] +} \ No newline at end of file diff --git a/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js b/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js index 69f072ec..838e14c0 100644 --- a/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js +++ b/app/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary.js @@ -172,7 +172,7 @@ function assertSourceLibraryCopyPermission(props, method) { }); } -function cleanProps(props) { +export function cleanProps(props) { return props.map(prop => { let schema = LibraryNodes.simpleSchema(prop); return schema.clean(prop); diff --git a/app/imports/api/creature/creatures/Creatures.js b/app/imports/api/creature/creatures/Creatures.js index 8754e850..da152451 100644 --- a/app/imports/api/creature/creatures/Creatures.js +++ b/app/imports/api/creature/creatures/Creatures.js @@ -226,5 +226,3 @@ Creatures.attachSchema(CreatureSchema); export default Creatures; export { CreatureSchema }; - -import '/imports/api/engine/actions/doAction'; diff --git a/app/imports/api/engine/actions/ActionEngine.test.ts b/app/imports/api/engine/action/ActionEngine.test.ts similarity index 62% rename from app/imports/api/engine/actions/ActionEngine.test.ts rename to app/imports/api/engine/action/ActionEngine.test.ts index 57bf3a04..570c7f9d 100644 --- a/app/imports/api/engine/actions/ActionEngine.test.ts +++ b/app/imports/api/engine/action/ActionEngine.test.ts @@ -4,20 +4,18 @@ 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, { Action, Update, LogContent, applyAction } from '/imports/api/engine/actions/ActionEngine'; import computeCreature from '/imports/api/engine/computeCreature'; import { loadCreature } from '/imports/api/engine/loadCreatures'; +import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyAction } from '/imports/api/engine/action/functions/applyAction'; +import { LogContent, Removal, Update } from '/imports/api/engine/action/tasks/TaskResult'; const creatureId = Random.id(); const targetId = Random.id(); describe('Interrupt action system', function () { - let unload: (() => void) | undefined = undefined; - const dummySubscription = { - onStop(fn) { - unload = fn; - } - }; + const dummySubscription = Tracker.autorun(() => undefined) + this.timeout(8000); before(async function () { // Remove old data await Promise.all([ @@ -48,7 +46,7 @@ describe('Interrupt action system', function () { loadCreature(creatureId, dummySubscription); }); after(function () { - unload?.(); + dummySubscription.stop(); }); it('writes notes to the log', async function () { const action = await runActionById(note1Id); @@ -86,7 +84,7 @@ describe('Interrupt action system', function () { it('Halts execution of choice branches', async function () { let userInputRequested = false; const requestUserInput = () => { userInputRequested = true; return 0 }; - const action = await runActionById(choiceBranchId, requestUserInput); + await runActionById(choiceBranchId, requestUserInput); assert.isTrue(userInputRequested, 'User input should be requested when a choice branch is applied'); }); it('Applies adjustments', async function () { @@ -124,29 +122,102 @@ describe('Interrupt action system', function () { } ]); }); + it('Applies buffs', async function () { + const action = await runActionById(buffId); + const inserts = allInserts(action); + const newIds = inserts.map(p => p._id); + assert.notEqual(buffId, newIds[0]); + assert.deepEqual(inserts, [ + { + _id: newIds[0], + left: 43, + parentId: null, + right: 48, + root: { + collection: 'creatures', + id: creatureId, + }, + tags: [], + target: 'self', + type: 'buff', + }, { + _id: newIds[1], + attributeType: 'stat', + baseValue: { + calculation: '13 + buffSourceStat + 7', + }, + left: 44, + parentId: newIds[0], + right: 45, + root: { + collection: 'creatures', + id: creatureId, + }, + tags: [], + type: 'attribute', + variableName: 'buffStat', + }, { + _id: newIds[2], + left: 46, + parentId: newIds[0], + removeAll: true, + right: 47, + root: { + collection: 'creatures', + id: creatureId, + }, + tags: [], + target: 'self', + targetParentBuff: true, + type: 'buffRemover', + } + ]); + }); + it('Removes parent buffs', async function () { + const action = await runActionById(removeParentBuffId); + console.log(allLogContent(action)); + assert.deepEqual(allRemovals(action), [ + { propId: buffId } + ]); + }); + it('Removes all buffs by tag', async function () { + const action = await runActionById(removeTaggedBuffsId); + console.log(allLogContent(action)); + assert.deepEqual(allRemovals(action), [ + { propId: taggedBuffId }, + { propId: secondTaggedBuffId }, + ]); + }); + it('Removes a single buff by tag', async function () { + const action = await runActionById(removeOneTaggedBuffId); + console.log(allLogContent(action)); + assert.deepEqual(allRemovals(action), [ + { propId: taggedBuffId }, + ]); + }); }); function createAction(prop, targetIds?) { - const action: Action = { + const action: EngineAction = { creatureId: prop.root.id, rootPropId: prop._id, results: [], taskCount: 0, targetIds, }; - return Actions.insertAsync(action); + return EngineActions.insertAsync(action); } async function runActionById(propId, userInputFn = () => 0) { const prop = await CreatureProperties.findOneAsync(propId); const actionId = await createAction(prop); - const action = await Actions.findOneAsync(actionId); + const action = await EngineActions.findOneAsync(actionId); if (!action) throw 'Action is expected to exist'; await applyAction(action, userInputFn, { simulate: true }); return action; } -function allUpdates(action: Action) { +function allUpdates(action: EngineAction) { const updates: Update[] = []; action.results.forEach(result => { result.mutations.forEach(mutation => { @@ -158,7 +229,31 @@ function allUpdates(action: Action) { return updates; } -function allLogContent(action: Action) { +function allInserts(action: EngineAction) { + const inserts: any[] = []; + action.results.forEach(result => { + result.mutations.forEach(mutation => { + mutation.inserts?.forEach(update => { + inserts.push(update); + }); + }); + }); + return inserts; +} + +function allRemovals(action: EngineAction) { + const removals: Removal[] = []; + action.results.forEach(result => { + result.mutations.forEach(mutation => { + mutation.removals?.forEach(update => { + removals.push(update); + }); + }); + }); + return removals +} + +function allLogContent(action: EngineAction) { const contents: LogContent[] = []; action.results.forEach(result => { result.mutations.forEach(mutation => { @@ -170,8 +265,9 @@ function allLogContent(action: Action) { return contents; } -let note1Id, folderId, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId, adjustedStatId, - adjustmentIncrementId, adjustmentSetId, rollId; +let note1Id, folderId, ifTruthyBranchId, ifFalsyBranchId, indexBranchId, choiceBranchId, + adjustedStatId, adjustmentIncrementId, adjustmentSetId, rollId, buffId, + removeParentBuffId, removeTaggedBuffsId, removeOneTaggedBuffId, taggedBuffId, secondTaggedBuffId, buffAttChildId; const propForest = [ // Apply a simple note @@ -255,6 +351,63 @@ const propForest = [ { type: 'note', summary: { text: 'adjustment increment applied' } }, ], }, + // Apply buffs + { + _id: Random.id(), + type: 'attribute', + attributeType: 'stat', + variableName: 'buffSourceStat', + baseValue: { calculation: '13' }, + }, { + _id: buffId = Random.id(), + type: 'buff', + target: 'self', + children: [ + { + _id: buffAttChildId = Random.id(), + type: 'attribute', + attributeType: 'stat', + variableName: 'buffStat', + baseValue: { calculation: 'buffSourceStat + ~target.buffSourceStat + 7' }, + }, { + _id: removeParentBuffId = Random.id(), + type: 'buffRemover', + target: 'self', + targetParentBuff: true, + }, + ], + }, + // Extra buffs with and without tags + { + _id: taggedBuffId = Random.id(), + name: 'Tagged Buff', + type: 'buff', + tags: ['buff tag', 'other tag'] + }, { + _id: secondTaggedBuffId = Random.id(), + name: 'Tagged buff 2', + type: 'buff', + tags: ['buff tag', 'yet another tag'] + }, { + _id: Random.id(), + name: 'Untagged buff', + type: 'buff', + tags: ['other tag'] + }, + // Remove buffs by tag + { + _id: removeTaggedBuffsId = Random.id(), + type: 'buffRemover', + target: 'self', + removeAll: true, + targetTags: 'buff tag', + }, { + _id: removeOneTaggedBuffId = Random.id(), + type: 'buffRemover', + target: 'self', + removeAll: false, + targetTags: 'buff tag', + }, // Apply rolls { _id: rollId = Random.id(), diff --git a/app/imports/api/engine/action/EngineActions.ts b/app/imports/api/engine/action/EngineActions.ts new file mode 100644 index 00000000..5a9f809f --- /dev/null +++ b/app/imports/api/engine/action/EngineActions.ts @@ -0,0 +1,135 @@ +import SimpleSchema from 'simpl-schema'; +import TaskResult from './tasks/TaskResult'; +import LogContentSchema from '/imports/api/creature/log/LogContentSchema'; + +const EngineActions = new Mongo.Collection('actions'); + +export interface EngineAction { + _id?: string; + _isSimulation?: boolean; + _stepThrough?: boolean; + creatureId: string; + rootPropId: string; + targetIds?: string[]; + results: TaskResult[]; + taskCount: number; +} + +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: Object, + optional: true, + blackbox: true, + }, + + // 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: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'results.$.targetIds': { + type: Array, + defaultValue: [], + }, + 'results.$.targetIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + // Changes that override the local scope + 'results.$.scope': { + type: Object, + optional: true, + blackbox: true, + }, + // Changes that consume pushed values from the local scope + 'results.$.popScope': { + type: Object, + optional: true, + blackbox: true, + }, + // Changes that push values to the local scope + 'results.$.pushScope': { + type: Object, + optional: true, + blackbox: true, + }, + // database changes + 'results.$.mutations': { + type: Array, + optional: true, + }, + 'results.$.mutations.$': { + type: Object, + }, + 'results.$.mutations.$.targetIds': { + type: Array, + }, + 'results.$.mutations.$.targetIds.$': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + 'results.$.mutations.$.updates': { + type: Array, + optional: true, + }, + 'results.$.mutations.$.updates.$': { + type: Object, + }, + 'results.$.mutations.$.updates.$.propId': { + 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, + }, + 'results.$.mutations.$.contents.$': { + type: LogContentSchema, + }, +}); + +// @ts-expect-error Collections2 lacks TypeScript support +EngineActions.attachSchema(ActionSchema); + +export default EngineActions; +export { ActionSchema } diff --git a/app/imports/api/engine/action/applyProperties/applyActionProperty.ts b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts new file mode 100644 index 00000000..b0e3f5fa --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts @@ -0,0 +1,273 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { PropTask } from '../tasks/Task'; +import TaskResult, { LogContent } from '../tasks/TaskResult'; +import { getPropertiesOfType } from '/imports/api/engine/loadCreatures'; +import applyTask from '/imports/api/engine/action/tasks/applyTask'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; +import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations'; +import spendResources from '/imports/api/engine/action/functions/spendResources'; +import { applyAfterChildrenTriggers, applyAfterTriggers, applyChildren } from '/imports/api/engine/action/functions/applyTaskGroups'; +import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation'; +import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; +import numberToSignedString from '/imports/api/utility/numberToSignedString'; +import rollDice from '/imports/parser/rollDice'; + +export default async function applyActionProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput +): Promise { + const prop = task.prop; + const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds; + + //Log the name and summary, check that the property has enough resources to fire + const content: LogContent = { name: prop.name }; + if (prop.summary?.text) { + await recalculateInlineCalculations(prop.summary, action); + content.value = prop.summary.value; + } + if (prop.silent) content.silenced = true; + result.appendLog(content, targetIds); + + // Check Uses + if (prop.usesLeft <= 0) { + if (!prop.silent) result.appendLog({ + name: 'Error', + value: `${prop.name || 'action'} does not have enough uses left`, + }, targetIds); + return; + } + + // Check Resources + if (prop.insufficientResources) { + if (!prop.silent) result.appendLog({ + name: 'Error', + value: 'This creature doesn\'t have sufficient resources to perform this action', + }, targetIds); + return; + } + + spendResources(action, prop, targetIds, result, userInput); + + const attack = prop.attackRoll || prop.attackRollBonus; + + // Attack if there is an attack roll + if (attack && attack.calculation) { + if (targetIds.length) { + for (const target of targetIds) { + await applyAttackToTarget(action, prop, attack, targetIds, result, userInput); + await applyAfterTriggers(action, prop, [target], userInput); + await applyChildren(action, prop, [target], userInput); + } + } else { + await applyAttackWithoutTarget(action, prop, attack, result, userInput); + await applyAfterTriggers(action, prop, targetIds, userInput); + await applyChildren(action, prop, targetIds, userInput); + } + } else { + await applyAfterTriggers(action, prop, targetIds, userInput); + await applyChildren(action, prop, targetIds, userInput); + } + if (prop.actionType === 'event' && prop.variableName) { + resetProperties(action, prop, result, userInput); + } + + // Finish + return await applyAfterChildrenTriggers(action, prop, targetIds, userInput); +} + +async function applyAttackToTarget(action, prop, attack, target, taskResult: TaskResult, userInput) { + taskResult.pushScope = { + '~attackHit': {}, + '~attackMiss': {}, + '~criticalHit': {}, + '~criticalMiss': {}, + '~attackRoll': {}, + } + + await recalculateCalculation(attack, action, 'reduce'); + const scope = await getEffectiveActionScope(action); + const contents: LogContent[] = []; + + const { + resultPrefix, + result, + criticalHit, + criticalMiss, + } = await rollAttack(attack, scope, taskResult.pushScope); + + if (target.variables.armor) { + const armor = target.variables.armor.value; + + let name = criticalHit ? 'Critical Hit!' : + criticalMiss ? 'Critical Miss!' : + result > armor ? 'Hit!' : 'Miss!'; + if (scope['~attackAdvantage']?.value === 1) { + name += ' (Advantage)'; + } else if (scope['~attackAdvantage']?.value === -1) { + name += ' (Disadvantage)'; + } + + contents.push({ + name, + value: `${resultPrefix}\n**${result}**`, + inline: true, + silenced: prop.silent, + }); + + if (criticalMiss || result < armor) { + scope['~attackMiss'] = { value: true }; + } else { + scope['~attackHit'] = { value: true }; + } + } else { + contents.push({ + name: 'Error', + value: 'Target has no `armor`', + inline: true, + silenced: prop.silent, + }, { + name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit', + value: `${resultPrefix}\n**${result}**`, + inline: true, + silenced: prop.silent, + }); + } + if (contents.length) { + taskResult.mutations.push({ + contents, + targetIds: [target], + }); + } +} + +async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskResult, userInput) { + taskResult.pushScope = { + '~attackHit': {}, + '~attackMiss': {}, + '~criticalHit': {}, + '~criticalMiss': {}, + '~attackRoll': {}, + } + await recalculateCalculation(attack, action, 'reduce'); + const scope = await getEffectiveActionScope(action); + const { + resultPrefix, + result, + criticalHit, + criticalMiss, + } = await rollAttack(attack, scope, taskResult.pushScope); + let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit'; + if (scope['~attackAdvantage']?.value === 1) { + name += ' (Advantage)'; + } else if (scope['~attackAdvantage']?.value === -1) { + name += ' (Disadvantage)'; + } + if (!criticalMiss) { + scope['~attackHit'] = { value: true } + } + if (!criticalHit) { + scope['~attackMiss'] = { value: true }; + } + taskResult.mutations.push({ + contents: [{ + name, + value: `${resultPrefix}\n**${result}**`, + inline: true, + silenced: prop.silent, + }], + targetIds: [], + }); +} + +async function rollAttack(attack, scope, resultPushScope) { + const rollModifierText = numberToSignedString(attack.value, true); + let value, resultPrefix; + if (scope['~attackAdvantage']?.value === 1) { + const [a, b] = await rollDice(2, 20); + if (a >= b) { + value = a; + resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; + } else { + value = b; + resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; + } + } else if (scope['~attackAdvantage']?.value === -1) { + const [a, b] = await rollDice(2, 20); + if (a <= b) { + value = a; + resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; + } else { + value = b; + resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; + } + } else { + value = await rollDice(1, 20)[0]; + resultPrefix = `1d20 [${value}] ${rollModifierText}` + } + resultPushScope['~attackDiceRoll'] = { value }; + const result = value + attack.value; + resultPushScope['~attackRoll'] = { value: result }; + const { criticalHit, criticalMiss } = applyCrits(value, scope, resultPushScope); + return { resultPrefix, result, value, criticalHit, criticalMiss }; +} + +function applyCrits(value, scope, resultPushScope) { + let scopeCrit = scope['~criticalHitTarget']?.value; + if (scopeCrit?.parseType === 'constant') { + scopeCrit = scopeCrit.value; + } + const criticalHitTarget = scopeCrit || 20; + const criticalHit = value >= criticalHitTarget; + let criticalMiss; + if (criticalHit) { + resultPushScope['~criticalHit'] = { value: true }; + } else { + criticalMiss = value === 1; + if (criticalMiss) { + resultPushScope['~criticalMiss'] = { value: true }; + } + } + return { criticalHit, criticalMiss }; +} + +async function resetProperties(action: EngineAction, prop: any, result: TaskResult, userInput) { + const attributes = getPropertiesOfType(action.creatureId, 'attribute'); + for (const att of attributes) { + if (att.removed || att.inactive) continue; + if (att.reset !== prop.variableName) continue; + if (!att.damage) continue; + applyTask(action, { + prop: att, + targetIds: [action.creatureId], + subtaskFn: 'damageProp', + params: { + title: getPropertyTitle(att), + operation: 'increment', + value: -att.damage ?? 0, + targetProp: att, + }, + }, userInput) + } + const actions = [ + ...getPropertiesOfType(action.creatureId, 'action'), + ...getPropertiesOfType(action.creatureId, 'spell'), + ] + for (const act of actions) { + if (act.removed || act.inactive) continue; + if (act.reset !== prop.variableName) continue; + if (!act.usesUsed) continue; + result.mutations.push({ + targetIds: [action.creatureId], + updates: [{ + propId: act._id, + set: { usesUsed: 0 }, + type: act.type, + }], + contents: [{ + name: getPropertyTitle(act), + value: act.usesUsed >= 0 ? `Restored ${act.usesUsed} uses` : `Removed ${-act.usesUsed} uses`, + inline: true, + silenced: prop.silent, + }] + }); + } +} \ No newline at end of file diff --git a/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts b/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts new file mode 100644 index 00000000..c1b3ad20 --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts @@ -0,0 +1,63 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups'; +import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import applyTask from '/imports/api/engine/action/tasks/applyTask'; +import { getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; + +export default async function applyAdjustmentProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput +): Promise { + const prop = task.prop; + const damageTargetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds; + + if (damageTargetIds.length > 1) { + return await applyTaskToEachTarget(action, task, damageTargetIds, userInput); + } + + // Get the operation and value and push the damage hooks to the queue + if (!prop.amount) { + return; + } + + // Evaluate the amount + await recalculateCalculation(prop.amount, action, 'reduce'); + const value = +prop.amount.value; + if (!isFinite(value)) { + return; + } + + if (!damageTargetIds?.length) { + return; + } + + if (damageTargetIds.length !== 1) { + throw 'At this step, only a single target is supported' + } + const targetId = damageTargetIds[0]; + const statId = getVariables(targetId)?.[prop.stat]?._propId; + const stat = statId && getSingleProperty(targetId, statId); + if (!stat?.type) { + result.appendLog({ + name: 'Error', + value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`, + silenced: prop.silent, + }, damageTargetIds); + return; + } + + applyTask(action, { + prop, + targetIds: damageTargetIds, + subtaskFn: 'damageProp', + params: { + title: getPropertyTitle(prop), + operation: prop.operation, + value, + targetProp: stat, + }, + }, userInput); + return applyDefaultAfterPropTasks(action, prop, damageTargetIds, userInput); +} diff --git a/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts new file mode 100644 index 00000000..19af353d --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts @@ -0,0 +1,129 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyAfterPropTasksForSingleChild, applyAfterTasksSkipChildren, applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups'; +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'; +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 +): Promise { + const prop = task.prop; + const targets = task.targetIds; + + switch (prop.branchType) { + case 'if': { + await recalculateCalculation(prop.condition, action, 'reduce'); + if (prop.condition?.value) { + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'index': { + const children = await getPropertyChildren(action.creatureId, prop); + if (!children.length) { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + await 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); + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + let index = Math.floor(prop.condition?.value); + if (index < 1) index = 1; + if (index > children.length) index = children.length; + const child = children[index - 1]; + return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); + } + case 'hit': { + const scope = await getEffectiveActionScope(action); + if (scope['~attackHit']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On hit**' + }, targets); + } + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'miss': { + const scope = await getEffectiveActionScope(action); + if (scope['~attackMiss']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On miss**' + }, targets); + } + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'failedSave': { + const scope = await getEffectiveActionScope(action); + if (scope['~saveFailed']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On failed save**' + }, targets); + } + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'successfulSave': { + const scope = await getEffectiveActionScope(action); + if (scope['~saveSucceeded']?.value) { + if (!targets.length && !prop.silent) { + result.appendLog({ + value: '**On save**' + }, targets); + } + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'random': { + const children = await getPropertyChildren(action.creatureId, prop); + if (children.length) { + const index = rollDice(1, children.length)[0]; + const child = children[index - 1]; + return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); + } else { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + } + case 'eachTarget': + if (targets.length > 1) { + return applyTaskToEachTarget(action, task, targets, userInput); + } + return applyDefaultAfterPropTasks(action, prop, targets, userInput); + case 'choice': { + let index; + if (action._isSimulation) { + index = await userInput(prop); + } else { + // TODO + throw 'Reading stored user input not implemented' + } + const children = await getPropertyChildren(action.creatureId, prop); + if (!children.length) { + return applyAfterTasksSkipChildren(action, prop, targets, userInput); + } + if (!isFinite(index) || index < 0) index = 0; + if (index > children.length - 1) index = children.length - 1; + const child = children[index]; + return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); + } + } +} \ No newline at end of file diff --git a/app/imports/api/engine/action/applyProperties/applyBuffProperty.ts b/app/imports/api/engine/action/applyProperties/applyBuffProperty.ts new file mode 100644 index 00000000..28a04efc --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyBuffProperty.ts @@ -0,0 +1,165 @@ +import { get } from 'lodash'; + +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import { getPropertyDescendants } from '/imports/api/engine/loadCreatures'; +import resolve, { toString, map } from '/imports/parser/resolve'; +import computedSchemas from '/imports/api/properties/computedOnlyPropertySchemasIndex.js'; +import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey'; +import accessor from '/imports/parser/parseTree/accessor'; +import TaskResult, { Mutation } from '/imports/api/engine/action/tasks/TaskResult'; +import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; +import cyrb53 from '/imports/api/engine/computation/utility/cyrb53'; +import { renewDocIds } from '/imports/api/parenting/parentingFunctions'; +import { cleanProps } from '/imports/api/creature/creatureProperties/methods/copyPropertyToLibrary'; +import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; +import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX'; +import { applyAfterTasksSkipChildren } from '/imports/api/engine/action/functions/applyTaskGroups'; + +export default async function applyBuffProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput +) { + const prop = EJSON.clone(task.prop); + const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds; + + // Get the buff and its descendants + const propList = [ + EJSON.clone(prop), + ...getPropertyDescendants(action.creatureId, prop._id), + ]; + + // Crystalize the variables + if (!prop.skipCrystalization) { + await crystalizeVariables(action, propList, task, result); + } + + targetIds.forEach(target => { + // Create a per-target mutation + const mutation: Mutation = { targetIds: [target], contents: [] }; + + // Create a per-target copy of the propList + let targetPropList = EJSON.clone(propList); + + // Give the properties new IDs as descendants of the target + renewDocIds({ + docArray: targetPropList, + idMap: { + [prop.parentId]: null, + [prop.root.id]: target, + }, + collectionMap: { [prop.root.collection]: 'creatures' } + }); + + //Log the buff + let logValue = prop.description?.value + if (prop.description?.text) { + recalculateInlineCalculations(prop.description, action); + logValue = prop.description?.value; + } + result.appendLog({ + name: getPropertyTitle(prop), + value: logValue + }, [target]); + + // remove all the computed fields + targetPropList = cleanProps(targetPropList); + + // Insert the props in the mutation + mutation.inserts = targetPropList; + + // Add the mutation to the results + result.mutations.push(mutation); + }); + applyAfterTasksSkipChildren(action, prop, targetIds, userInput); +} + +/** + * Replaces all variables with their resolved values + * except variables of the form `~target.thing.total` become `thing.total` + */ +async function crystalizeVariables( + action: EngineAction, propList: any[], task: PropTask, result: TaskResult +) { + const scope = await getEffectiveActionScope(action); + propList.forEach(prop => { + if (prop._skipCrystalize) { + delete prop._skipCrystalize; + return; + } + // Iterate through all the calculations and crystalize them + computedSchemas[prop.type].computedFields().forEach(calcKey => { + applyFnToKey(prop, calcKey, (prop, key) => { + const calcObj = get(prop, key); + if (!calcObj?.parseNode) return; + calcObj.parseNode = map(calcObj.parseNode, node => { + // Skip nodes that aren't symbols or accessors + if ( + node.parseType !== 'accessor' + ) return node; + // Handle variables + if (node.parseType === 'accessor' && node.name === '~target') { + // strip ~target + if (node.path?.length > 0) { + const name = node.path.shift(); + return accessor.create({ + name, + path: node.path?.length ? node.path : undefined, + }); + } else { + // Can't strip if there isn't anything in the path after ~target + result.appendLog({ + name: 'Error', + value: 'Variable `~target` should not be used without a property: ~target.property', + }, task.targetIds); + } + return node; + } else { + // Resolve all other variables + const { result, context } = resolve('reduce', node, scope); + context.errors?.forEach(error => { + result.appendLog({ + name: 'Error', + value: error, + }, task.targetIds); + }); + return result; + } + }); + calcObj.calculation = toString(calcObj.parseNode); + calcObj.hash = cyrb53(calcObj.calculation); + }); + }); + // For each key in the schema + computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => { + // That ends in .inlineCalculations + applyFnToKey(prop, calcKey, (prop, key) => { + const inlineCalcObj = get(prop, key); + if (!inlineCalcObj) return; + + // If there is no text, skip + if (!inlineCalcObj.text) { + return; + } + + // Replace all the existing calculations + let index = -1; + inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => { + index += 1; + return `{${inlineCalcObj.inlineCalculations[index].calculation}}`; + }); + + // Set the value to the uncomputed string + inlineCalcObj.value = inlineCalcObj.text; + + // Write a new hash + const inlineCalcHash = cyrb53(inlineCalcObj.text); + if (inlineCalcHash === inlineCalcObj.hash) { + // Skip if nothing changed + return; + } + inlineCalcObj.hash = inlineCalcHash; + }); + }); + }); +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js b/app/imports/api/engine/action/applyProperties/applyBuffRemoverProperty.ts similarity index 56% rename from app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js rename to app/imports/api/engine/action/applyProperties/applyBuffRemoverProperty.ts index 3b69c230..117f1ad3 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBuffRemover.js +++ b/app/imports/api/engine/action/applyProperties/applyBuffRemoverProperty.ts @@ -1,39 +1,40 @@ -import { findLast, difference, intersection, filter } from 'lodash'; -import applyProperty from '../applyProperty'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import { getPropertyAncestors, getPropertiesOfType } from '/imports/api/engine/loadCreatures'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import { softRemove } from '/imports/api/parenting/softRemove'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from 'imports/api/engine/action/tasks/TaskResult'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; +import { findLast, filter, difference, intersection } from 'lodash'; +import { getPropertiesOfType, getPropertyAncestors } from '/imports/api/engine/loadCreatures'; import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; +import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups'; +import { EngineAction } from '/imports/api/engine/action/EngineActions'; -export default function applyBuffRemover(node, actionContext) { - // Apply triggers - applyNodeTriggers(node, 'before', actionContext); +export default function applyBuffRemoverProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput +) { + const prop = task.prop; - const prop = node.doc - - // Log Name if (prop.name && !prop.silent) { - actionContext.addLog({ name: prop.name }); + // Log Name + result.appendLog({ + name: getPropertyTitle(prop), + }, task.targetIds) } // Remove buffs if (prop.targetParentBuff) { // Remove nearest ancestor buff - const ancestors = getPropertyAncestors(actionContext.creature._id, prop._id); + const ancestors = getPropertyAncestors(action.creatureId, prop._id); const nearestBuff = findLast(ancestors, ancestor => ancestor.type === 'buff'); if (!nearestBuff) { - actionContext.addLog({ + result.appendLog({ name: 'Error', value: 'Buff remover does not have a parent buff to remove', - }); + }, task.targetIds); return; } - removeBuff(nearestBuff, actionContext, prop); + removeBuff(nearestBuff, prop, result); } else { // Get all the buffs targeted by tags - const allBuffs = getPropertiesOfType(actionContext.creature._id, 'buff'); + const allBuffs = getPropertiesOfType(action.creatureId, 'buff'); const targetedBuffs = filter(allBuffs, buff => { if (buff.inactive) return false; if (buffRemoverMatchTags(prop, buff)) return true; @@ -42,7 +43,7 @@ export default function applyBuffRemover(node, actionContext) { if (prop.removeAll) { // Remove all matching buffs targetedBuffs.forEach(buff => { - removeBuff(buff, actionContext, prop); + removeBuff(buff, prop, result); }); } else { // Sort in reverse order @@ -50,19 +51,23 @@ export default function applyBuffRemover(node, actionContext) { // Remove the one with the highest order const buff = targetedBuffs[0]; if (buff) { - removeBuff(buff, actionContext, prop); + removeBuff(buff, prop, result); } } } - applyChildren(node, actionContext); + applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); } -function removeBuff(buff, actionContext, prop) { - if (!prop.silent) actionContext.addLog({ - name: 'Removed', - value: `${buff.name || 'Buff'}` +function removeBuff(buff: any, prop, result: TaskResult) { + result.mutations.push({ + targetIds: result.targetIds, + removals: [{ propId: buff._id }], + contents: [{ + name: 'Removed', + value: `${buff.name || 'Buff'}`, + silenced: prop.silent, + }], }); - softRemove({ _id: buff._id, collection: CreatureProperties }); } function buffRemoverMatchTags(buffRemover, prop) { diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/action/applyProperties/applyDamageProperty.ts similarity index 91% rename from app/imports/api/engine/actions/applyPropertyByType/applyDamage.js rename to app/imports/api/engine/action/applyProperties/applyDamageProperty.ts index 0b6a022f..b36830e7 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/action/applyProperties/applyDamageProperty.ts @@ -1,16 +1,4 @@ -import { some, intersection, difference, remove, includes } from 'lodash'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs'; -import resolve, { Context, toString } from '/imports/parser/resolve'; -import logErrors from './shared/logErrors'; -import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation' -import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty'; -import { - getPropertiesOfType -} from '/imports/api/engine/loadCreatures'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags'; -import applySavingThrow from '/imports/api/engine/actions/applyPropertyByType/applySavingThrow'; +// TODO export default function applyDamage(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); diff --git a/app/imports/api/engine/action/applyProperties/applyFolderProperty.ts b/app/imports/api/engine/action/applyProperties/applyFolderProperty.ts new file mode 100644 index 00000000..f0d1f0b5 --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyFolderProperty.ts @@ -0,0 +1,11 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; + + +export default async function applyFolderProperty( + task: PropTask, action: EngineAction, userInput +): Promise { + const prop = task.prop; + return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); +} diff --git a/app/imports/api/engine/action/applyProperties/applyNoteProperty.ts b/app/imports/api/engine/action/applyProperties/applyNoteProperty.ts new file mode 100644 index 00000000..bbb79cc7 --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyNoteProperty.ts @@ -0,0 +1,35 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups'; +import recalculateInlineCalculations from '/imports/api/engine/action/functions/recalculateInlineCalculations'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult, { LogContent } from '/imports/api/engine/action/tasks/TaskResult'; + +export default async function applyNoteProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput +): Promise { + const prop = task.prop; + let contents: LogContent[] | undefined = undefined; + const logContent: LogContent = {}; + if (prop.name) logContent.name = prop.name; + if (prop.summary?.text) { + await recalculateInlineCalculations(prop.summary, action); + logContent.value = prop.summary.value; + } + + if (logContent.name || logContent.value) { + contents = [logContent]; + } + // Log description + if (prop.description?.text) { + await recalculateInlineCalculations(prop.description, action); + if (!contents) contents = []; + contents.push({ value: prop.description.value }); + } + if (contents) { + result.mutations.push({ + contents, + targetIds: task.targetIds, + }); + } + return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); +} \ No newline at end of file diff --git a/app/imports/api/engine/action/applyProperties/applyRollProperty.ts b/app/imports/api/engine/action/applyProperties/applyRollProperty.ts new file mode 100644 index 00000000..193cb88d --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applyRollProperty.ts @@ -0,0 +1,58 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { applyDefaultAfterPropTasks } from '/imports/api/engine/action/functions/applyTaskGroups'; +import { rollAndReduceCalculation } from '/imports/api/engine/action/functions/recalculateCalculation'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import { toString } from '/imports/parser/resolve'; + +export default async function roll( + task: PropTask, action: EngineAction, result: TaskResult, userInput +): Promise { + const prop = task.prop; + // If there isn't a calculation, just apply the children instead + if (!prop.roll?.calculation) { + return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); + } + + const logValue: string[] = []; + + // roll the dice only and store that string + const { + rolled, reduced, errors + } = await rollAndReduceCalculation(prop.roll, action); + + if (rolled.parseType !== 'constant') { + logValue.push(toString(rolled)); + } + errors?.forEach(error => { + result.appendLog({ name: 'Error', value: error.message }, task.targetIds); + }); + + // Store the result + if (reduced.parseType === 'constant') { + prop.roll.value = reduced.value; + } else if (reduced.parseType === 'error') { + prop.roll.value = null; + } else { + prop.roll.value = toString(reduced); + } + + // If we didn't end up with a constant or a number of finite value, give up + if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) { + return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); + } + const value = reduced.value; + + result.scope[prop.variableName] = { value }; + logValue.push(`**${value}**`); + + result.appendLog({ + name: prop.name, + value: logValue.join('\n'), + inline: true, + silenced: prop.silent, + }, task.targetIds); + + // Apply children + return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); +} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js b/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts similarity index 86% rename from app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js rename to app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts index 17269482..65e0a861 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applySavingThrow.js +++ b/app/imports/api/engine/action/applyProperties/applySavingThrowProperty.ts @@ -1,10 +1,4 @@ -import rollDice from '/imports/parser/rollDice'; -import recalculateCalculation from './shared/recalculateCalculation'; -import applyProperty from '../applyProperty'; -import numberToSignedString from '/imports/api/utility/numberToSignedString'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import { applyUnresolvedEffects } from '/imports/api/engine/actions/doCheck'; +// TODO export default function applySavingThrow(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js b/app/imports/api/engine/action/applyProperties/applyToggleProperty.ts similarity index 52% rename from app/imports/api/engine/actions/applyPropertyByType/applyToggle.js rename to app/imports/api/engine/action/applyProperties/applyToggleProperty.ts index 41222e0f..b55120d4 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyToggle.js +++ b/app/imports/api/engine/action/applyProperties/applyToggleProperty.ts @@ -1,6 +1,4 @@ -import recalculateCalculation from './shared/recalculateCalculation'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; +// TODO export default function applyToggle(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); diff --git a/app/imports/api/engine/action/applyProperties/index.ts b/app/imports/api/engine/action/applyProperties/index.ts new file mode 100644 index 00000000..9c8599e8 --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/index.ts @@ -0,0 +1,26 @@ +import action from './applyActionProperty'; +import adjustment from './applyAdjustmentProperty'; +import branch from './applyBranchProperty'; +import buff from './applyBuffProperty'; +import buffRemover from './applyBuffRemoverProperty'; +import damage from './applyDamageProperty'; +import folder from './applyFolderProperty'; +import note from './applyNoteProperty'; +import roll from './applyRollProperty'; +import savingThrow from './applySavingThrowProperty'; +import toggle from './applyToggleProperty'; + +export default { + action, + adjustment, + branch, + buff, + buffRemover, + damage, + folder, + note, + roll, + savingThrow, + propertySlot: folder, + toggle, +} diff --git a/app/imports/api/engine/action/functions/applyAction.ts b/app/imports/api/engine/action/functions/applyAction.ts new file mode 100644 index 00000000..ec811e22 --- /dev/null +++ b/app/imports/api/engine/action/functions/applyAction.ts @@ -0,0 +1,42 @@ +import { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions'; +import { getSingleProperty } from '/imports/api/engine/loadCreatures'; +import applyTask from '/imports/api/engine/action/tasks/applyTask' +import { isEmpty } from 'lodash'; + +// 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 +export async function applyAction(action: EngineAction, userInput?: any[] | Function, options?: { + simulate?: boolean, stepThrough?: boolean +}) { + const { simulate, stepThrough } = options || {}; + if (!simulate && stepThrough) throw 'Cannot step through unless simulating'; + if (simulate && typeof userInput !== 'function') throw 'Must provide a function to get user input when simulating'; + + action._stepThrough = stepThrough; + action._isSimulation = simulate; + action.taskCount = 0; + 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 }; +} + +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 Actions.updateAsync(original._id, { $set }); + } +} diff --git a/app/imports/api/engine/action/functions/applyTaskGroups.ts b/app/imports/api/engine/action/functions/applyTaskGroups.ts new file mode 100644 index 00000000..207ada37 --- /dev/null +++ b/app/imports/api/engine/action/functions/applyTaskGroups.ts @@ -0,0 +1,141 @@ +import { get } from 'lodash'; + +import { getPropertyChildren, getSingleProperty } from '/imports/api/engine/loadCreatures'; +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import applyTask from '../tasks/applyTask'; +import { PropTask } from '../tasks/Task'; + +/** + * Get all the child tasks of a given property + * @param action + * @param prop + * @param targetIds + * @returns + */ +export async function applyChildren( + action: EngineAction, prop, targetIds: string[], userInput +) { + const children = await getPropertyChildren(action.creatureId, prop); + // Push the child tasks and related triggers to the stack + for (const childProp of children) { + await applyTask(action, { prop: childProp, targetIds }, userInput); + } +} + +/** + * Get the afterChildren triggers for a given property + * @param prop + * @param targetIds + * @returns + */ +export async function applyAfterChildrenTriggers( + action: EngineAction, prop, targetIds: string[], userInput +) { + if (!prop.triggerIds?.afterChildren) return; + for (const triggerId of prop.triggerIds.afterChildren) { + const trigger = await getSingleProperty(action.creatureId, triggerId); + if (!trigger) continue; + await applyTask(action, { prop: trigger, targetIds }, userInput); + } +} + +export async function applyAfterTriggers( + action: EngineAction, prop, targetIds: string[], userInput +) { + if (!prop.triggerIds?.after) return; + for (const triggerId of prop.triggerIds.after) { + const trigger = await getSingleProperty(action.creatureId, triggerId); + if (!trigger) continue; + await applyTask(action, { prop: trigger, targetIds }, userInput); + } +} + +/** + * Applies the following: + * After triggers + * Children of the prop + * After-children triggers + * @param action + * @param prop + * @param targetIds + * @returns + */ +export async function applyDefaultAfterPropTasks( + action: EngineAction, prop, targetIds: string[], userInput +) { + await applyAfterTriggers(action, prop, targetIds, userInput); + await applyChildren(action, prop, targetIds, userInput); + await applyAfterChildrenTriggers(action, prop, targetIds, userInput); +} + +/** + * Applies the following: + * After triggers + * After-children triggers + * @param action + * @param prop + * @param targetIds + * @returns + */ +export async function applyAfterTasksSkipChildren( + action: EngineAction, prop, targetIds: string[], userInput +) { + await applyAfterTriggers(action, prop, targetIds, userInput); + await applyAfterChildrenTriggers(action, prop, targetIds, userInput); +} + +/** + * Returns a list of tasks containing the following: + * After triggers + * After-children triggers + * @param action + * @param prop + * @param targetIds + * @returns + */ +export async function applyAfterPropTasksForSingleChild( + action: EngineAction, prop, childProp, targetIds: string[], userInput +) { + await applyAfterTriggers(action, prop, targetIds, userInput); + await applyTask(action, { prop: childProp, targetIds }, userInput); + await applyAfterChildrenTriggers(action, prop, targetIds, userInput); +} + +/** + * Get all the trigger tasks for a given trigger path + * @param action + * @param prop + * @param targetIds + * @param triggerPath + * @returns + */ +export async function applyTriggers( + action: EngineAction, prop, targetIds: string[], triggerPath: string, userInput +) { + const triggerIds = get(prop?.triggers, triggerPath); + if (!triggerIds) return; + for (const triggerId of triggerIds) { + const trigger = await getSingleProperty(action.creatureId, triggerId); + if (!trigger) continue; + await applyTask(action, { prop: trigger, targetIds }, userInput); + } +} + +/** + * Split a task over its targets, incrementing task step by 1 + * @param task + * @param targetIds + * @returns Copies of the task, but with a single target each + */ +export async function applyTaskToEachTarget( + action: EngineAction, task: PropTask, targetIds: string[] = task.targetIds, userInput +) { + if (targetIds.length <= 1) throw 'Must have multiple targets to split a task'; + // If there are targets, apply a new task to each target + for (const targetId of targetIds) { + await applyTask(action, { + ...task, + targetIds: [targetId] + }, userInput); + } +} diff --git a/app/imports/api/engine/action/functions/getEffectiveActionScope.ts b/app/imports/api/engine/action/functions/getEffectiveActionScope.ts new file mode 100644 index 00000000..01a7df7f --- /dev/null +++ b/app/imports/api/engine/action/functions/getEffectiveActionScope.ts @@ -0,0 +1,60 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { getVariables } from '/imports/api/engine/loadCreatures'; + +// Combine all the action results into the scope at present +export async function getEffectiveActionScope(action: EngineAction) { + const scope = await getVariables(action.creatureId); + delete scope._id; + delete scope._creatureId; + // Combine the applied results + for (const result of action.results) { + // Pop keys that are not longer used by a busy property + if (result.popScope) { + for (const key in result.popScope) { + if (!result.popScope[key]) continue; + // If the popped keys have previous results, return to them + if (scope[key]?.previous) { + scope[key] = scope[key]?.previous; + } else { + // just remove the busy flag, the prop has been consumed + delete scope[key]?._busy + } + } + } + // For keys that have just started being used by a busy property + if (result.pushScope) { + for (const key in result.pushScope) { + // If the pushed keys already exist and are busy, + // save the previous results and overwrite + // the key + if (scope[key]?._busy) { + scope[key] = { + ...result.pushScope[key], + previous: scope[key], + _busy: true, + }; + } else { + scope[key] = { + ...result.pushScope[key], + _busy: true, + }; + } + } + } + // Assign other scope changes without bashing the scope[key].previous field + if (result.scope) { + for (const key in result.scope) { + if (scope[key]?.previous || scope[key]?._busy) { + scope[key] = { + ...result.scope[key], + previous: scope[key].previous, + _busy: scope[key]._busy, + }; + } else { + scope[key] = result.scope[key]; + } + } + } + } + return scope; +} \ No newline at end of file diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js b/app/imports/api/engine/action/functions/recalculateCalculation.ts similarity index 85% rename from app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js rename to app/imports/api/engine/action/functions/recalculateCalculation.ts index 4cd9a0aa..b8fb28ea 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation.js +++ b/app/imports/api/engine/action/functions/recalculateCalculation.ts @@ -1,4 +1,3 @@ -import logErrors from './logErrors'; import { Context, toPrimitiveOrString } from '/imports/parser/resolve'; import { aggregateCalculationEffects, @@ -7,12 +6,14 @@ import { } from '/imports/api/engine/computation/computeComputation/computeByType/computeCalculation'; import { getSingleProperty } from '/imports/api/engine/loadCreatures'; import resolve from '/imports/parser/resolve'; -import { getEffectiveActionScope } from '/imports/api/engine/actions/ActionEngine'; +import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; -// TODO move this whole file to Actions.ts -// Redo the work of imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js +// TODO Redo the work of +// imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js // But in the action scope -export default async function recalculateCalculation(calcObj, action, parseLevel = 'reduce', context, scope) { +export default async function recalculateCalculation( + calcObj, action, parseLevel = 'reduce', context, scope +) { if (!calcObj?.parseNode) return; calcObj._parseLevel = parseLevel; if (!scope) { diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js b/app/imports/api/engine/action/functions/recalculateInlineCalculations.ts similarity index 100% rename from app/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations.js rename to app/imports/api/engine/action/functions/recalculateInlineCalculations.ts diff --git a/app/imports/api/engine/action/functions/spendResources.ts b/app/imports/api/engine/action/functions/spendResources.ts new file mode 100644 index 00000000..83b9c358 --- /dev/null +++ b/app/imports/api/engine/action/functions/spendResources.ts @@ -0,0 +1,75 @@ +import { getFromScope } from '/imports/api/creature/creatures/CreatureVariables'; +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import applyTask from '/imports/api/engine/action/tasks/applyTask'; +import { getSingleProperty } from '/imports/api/engine/loadCreatures'; + +export default async function spendResources( + action: EngineAction, prop, targetIds: string[], result: TaskResult, userInput +) { + // Use uses + if (prop.usesLeft) { + result.mutations.push({ + targetIds, + updates: [{ + propId: prop._id, + inc: { usesUsed: 1, usesLeft: -1 }, + type: prop.type, + }], + contents: [{ + name: 'Uses left', + value: `${prop.usesLeft - 1}`, + inline: true, + silenced: prop.silent, + }] + }); + } + + // Iterate through all the resources consumed and damage them + if (prop.resources?.attributesConsumed?.length) { + for (const att of prop.resources.attributesConsumed) { + const scope = await getEffectiveActionScope(action); + const statToDamage = getFromScope(att.variableName, scope); + await recalculateCalculation(att.quantity, action, 'reduce'); + await applyTask(action, { + prop, + targetIds: [action.creatureId], + subtaskFn: 'damageProp', + params: { + operation: 'increment', + value: +att.quantity?.value || 0, + targetProp: statToDamage, + }, + }, userInput); + } + } + + // Iterate through all the items consumed and consume them + if (prop.resources?.itemsConsumed?.length) { + for (const itemConsumed of prop.resources.itemsConsumed) { + await recalculateCalculation(itemConsumed.quantity, action, 'reduce'); + if (!itemConsumed.itemId) { + throw 'No ammo was selected'; + } + const item = getSingleProperty(action.creatureId, itemConsumed.itemId); + if (!item || item.root.id !== prop.root.id) { + throw 'The prop\'s ammo was not found on the creature'; + } + const quantity = +itemConsumed?.quantity?.value; + if ( + !quantity || + !isFinite(quantity) + ) continue; + await applyTask(action, { + prop, + targetIds, + subtaskFn: 'consumeItemAsAmmo', + params: { + value: quantity, + item, + }, + }, userInput); + } + } +} \ No newline at end of file diff --git a/app/imports/api/engine/actions/doCastSpell.js b/app/imports/api/engine/action/methods/doCastSpell.js similarity index 98% rename from app/imports/api/engine/actions/doCastSpell.js rename to app/imports/api/engine/action/methods/doCastSpell.js index cdcdb53c..8efd7ba8 100644 --- a/app/imports/api/engine/actions/doCastSpell.js +++ b/app/imports/api/engine/action/methods/doCastSpell.js @@ -11,6 +11,8 @@ import { damagePropertyWork } from '/imports/api/creature/creatureProperties/met import { doActionWork } from '/imports/api/engine/actions/doAction'; import ActionContext from '/imports/api/engine/actions/ActionContext'; +// TODO Migrate this to the new action engine + const doAction = new ValidatedMethod({ name: 'creatureProperties.doCastSpell', validate: new SimpleSchema({ diff --git a/app/imports/api/engine/actions/doCheck.js b/app/imports/api/engine/action/methods/doCheck.js similarity index 96% rename from app/imports/api/engine/actions/doCheck.js rename to app/imports/api/engine/action/methods/doCheck.js index d95a5c93..02eb9d8e 100644 --- a/app/imports/api/engine/actions/doCheck.js +++ b/app/imports/api/engine/action/methods/doCheck.js @@ -7,9 +7,11 @@ import rollDice from '/imports/parser/rollDice'; import numberToSignedString from '/imports/api/utility/numberToSignedString'; import { applyTriggers } from '/imports/api/engine/actions/applyTriggers'; import ActionContext from '/imports/api/engine/actions/ActionContext'; -import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'; +import recalculateCalculation from '../../actions/applyPropertyByType/shared/recalculateCalculation'; import { getSingleProperty } from '/imports/api/engine/loadCreatures'; +// TODO Migrate this to the new action engine + const doCheck = new ValidatedMethod({ name: 'creatureProperties.doCheck', validate: new SimpleSchema({ diff --git a/app/imports/api/engine/action/methods/insertAction.ts b/app/imports/api/engine/action/methods/insertAction.ts new file mode 100644 index 00000000..1c85f4a6 --- /dev/null +++ b/app/imports/api/engine/action/methods/insertAction.ts @@ -0,0 +1,21 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import SimpleSchema from 'simpl-schema'; +import EngineActions, { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions'; +import { assertEditPermission } from '/imports/api/sharing/sharingPermissions'; +import { getCreature } from '/imports/api/engine/loadCreatures'; + +export const insertAction: ValidatedMethod = new ValidatedMethod({ + name: 'actions.insertAction', + validate: new SimpleSchema({ + action: ActionSchema + }).validator({ clean: true }), + run: async 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 }); + // Force a random id even if one was provided, we may use it later as the seed for PRNG + delete action._id; + return await EngineActions.insertAsync(action); + }, +}); diff --git a/app/imports/api/engine/action/methods/runAction.ts b/app/imports/api/engine/action/methods/runAction.ts new file mode 100644 index 00000000..281cfc28 --- /dev/null +++ b/app/imports/api/engine/action/methods/runAction.ts @@ -0,0 +1,34 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import SimpleSchema from 'simpl-schema'; +import EngineActions from '/imports/api/engine/action/EngineActions'; +import { assertEditPermission } from '/imports/api/sharing/sharingPermissions'; +import { getCreature } from '/imports/api/engine/loadCreatures'; + +export const runAction = new ValidatedMethod({ + name: 'actions.runAction', + validate: new SimpleSchema({ + action: { + type: Object, + blackbox: true, + }, + userInput: { + type: Object, + blackbox: true, + optional: true, + }, + stepThrough: { + type: Boolean, + optional: true, + } + }).validator(), + run: async function ({ actionId, userInput }: { actionId: string, userInput?: any }) { + const action = await EngineActions.findOneAsync(actionId); + if (!action) throw 'Action not found'; + assertEditPermission(getCreature(action.creatureId), this.userId); + const originalAction = EJSON.clone(action); + applyAction(action, userInput); + // Persist changes to the action + const writePromise = writeChangedAction(originalAction, action); + return writePromise; + }, +}); diff --git a/app/imports/api/engine/action/tasks/Task.ts b/app/imports/api/engine/action/tasks/Task.ts new file mode 100644 index 00000000..33233d15 --- /dev/null +++ b/app/imports/api/engine/action/tasks/Task.ts @@ -0,0 +1,33 @@ +type Task = PropTask | DamagePropTask | ItemAsAmmoTask; + +export default Task; + +interface BaseTask { + prop: { [key: string]: any }; + targetIds: string[]; +} + +export interface PropTask extends BaseTask { + subtaskFn?: undefined, +} + +export interface DamagePropTask extends BaseTask { + subtaskFn: 'damageProp'; + params: { + /** + * Use getPropertyTitle(prop) to set the title + */ + title?: string; + operation: 'increment' | 'set'; + value: number; + targetProp: any; + }; +} + +export interface ItemAsAmmoTask extends BaseTask { + subtaskFn: 'consumeItemAsAmmo'; + params: { + value: number; + item: any; + }; +} diff --git a/app/imports/api/engine/action/tasks/TaskResult.ts b/app/imports/api/engine/action/tasks/TaskResult.ts new file mode 100644 index 00000000..5551f9cc --- /dev/null +++ b/app/imports/api/engine/action/tasks/TaskResult.ts @@ -0,0 +1,73 @@ +/** + * The result of running a task containing all the changes that need to be made to the listed + * targets + * Each mutation may apply to a different subset of targets + */ +export default class TaskResult { + propId: string; + // The targets of the original task + targetIds: string[]; + scope: any; + // Consume pushed changes from the local scope, every change pushed must be popped later + popScope?: any; + // Push changes to the scope if the same task intends to consume them in later steps + // These changes will be marked as _busy until they are consumed + // This allows a property to run in between steps of the same property type without + // bashing the variables used to maintain state between steps while still exposing + // those variables to triggers that need to change them + // If multiple properties use the same variable at once, the values used by outer + // properties can be found on variable.previous + pushScope?: any; + mutations: Mutation[]; + constructor(propId: string, targetIds: string[]) { + this.propId = propId; + this.targetIds = targetIds; + this.mutations = []; + this.scope = {}; + } + // 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); + } +} + +export type Mutation = { + // Which creatures the mutation is applied to + // A mutation may apply to all, or a subset of, the result's targets and the acting creature + targetIds: string[]; + // What changes in the database + updates?: Update[]; + // What properties get added + // TODO make these properties a LibraryNode type + inserts?: any[]; + // What properties get deleted + removals?: Removal[]; + // Logged when this is applied + contents?: LogContent[]; +} + +export type Update = { + propId: string; + type: string, + set?: any; + inc?: any; +} + +export type Removal = { + propId: string; +} + +export type LogContent = { + name?: string; + value?: string; + inline?: boolean; + context?: any; + silenced?: boolean; +} diff --git a/app/imports/api/engine/action/tasks/applyDamagePropTask.ts b/app/imports/api/engine/action/tasks/applyDamagePropTask.ts new file mode 100644 index 00000000..25bbb5dc --- /dev/null +++ b/app/imports/api/engine/action/tasks/applyDamagePropTask.ts @@ -0,0 +1,131 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { DamagePropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import { applyTriggers } from '/imports/api/engine/action/functions/applyTaskGroups'; +import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; +import { getSingleProperty } from '/imports/api/engine/loadCreatures'; + +export default async function applyDamagePropTask( + task: DamagePropTask, action: EngineAction, result: TaskResult, userInput +): Promise { + const prop = task.prop; + + if (task.targetIds.length > 1) { + throw 'This subtask can only be called on a single target'; + } + const targetId = task.targetIds[0]; + + let { value } = task.params; + const { title, operation } = task.params; + let targetProp = task.params.targetProp; + + // Set the scope properties + result.pushScope = {}; + if (prop.operation === 'increment') { + if (value >= 0) { + result.pushScope['~damage'] = { value }; + } else { + result.pushScope['~healing'] = { value: -value }; + } + } else { + result.pushScope['~set'] = { value }; + } + // Store which property we're targeting + if (targetId === action.creatureId) { + result.pushScope['~attributeDamaged'] = { _propId: targetProp._id }; + } else { + result.pushScope['~attributeDamaged'] = targetProp; + } + + // Run the before triggers which may change scope properties + await applyTriggers(action, targetProp, [action.creatureId], 'damageProperty.before', userInput); + + // Refetch the scope properties + const scope = await getEffectiveActionScope(action); + result.popScope = { + '~damage': 1, '~healing': 1, '~set': 1, '~attributeDamaged': 1, + }; + value = +value; + if (operation === 'increment') { + if (value >= 0) { + value = scope['~damage']?.value; + } else { + value = -scope['~healing']?.value; + } + } else { + value = scope['~set']?.value; + } + const targetPropId = scope['~attributeDamaged']?._propId; + + // If there are no targets, just log the result that would apply and end + if (!task.targetIds?.length) { + // Get the locally equivalent stat with the same variable name + const statName = getPropertyTitle(targetProp); + result.appendLog({ + name: title, + value: `${statName}${operation === 'set' ? ' set to' : ''}` + + ` ${value}`, + inline: true, + silenced: prop.silent, + }, task.targetIds); + } + + let damage, newValue, increment; + targetProp = await getSingleProperty(targetId, targetPropId); + + if (!targetProp) return; + + if (operation === 'set') { + const total = targetProp.total || 0; + // Set represents what we want the value to be after damage + // So we need the actual damage to get to that value + damage = total - value; + // Damage can't exceed total value + if (damage > total && !targetProp.ignoreLowerLimit) damage = total; + // Damage must be positive + if (damage < 0 && !targetProp.ignoreUpperLimit) damage = 0; + newValue = targetProp.total - damage; + // Write the results + result.mutations.push({ + targetIds: [targetId], + updates: [{ + propId: targetProp._id, + set: { damage, value: newValue }, + type: targetProp.type, + }], + contents: [{ + name: title, + value: `${getPropertyTitle(targetProp)} set to ${value}`, + inline: true, + silenced: prop.silent, + }] + }); + } else if (operation === 'increment') { + const currentValue = targetProp.value || 0; + const currentDamage = targetProp.damage || 0; + increment = value; + // Can't increase damage above the remaining value + if (increment > currentValue && !targetProp.ignoreLowerLimit) increment = currentValue; + // Can't decrease damage below zero + if (-increment > currentDamage && !targetProp.ignoreUpperLimit) increment = -currentDamage; + damage = currentDamage + increment; + newValue = targetProp.total - damage; + // Write the results + result.mutations.push({ + targetIds: [targetId], + updates: [{ + propId: targetProp._id, + inc: { damage: increment, value: -increment }, + type: targetProp.type, + }], + contents: [{ + name: 'Attribute damage', + value: `${getPropertyTitle(targetProp)} ${value}`, + inline: true, + silenced: prop.silent, + }] + }); + } + await applyTriggers(action, prop, [action.creatureId], 'damageProperty.after', userInput); +} \ No newline at end of file diff --git a/app/imports/api/engine/action/tasks/applyItemAsAmmoTask.ts b/app/imports/api/engine/action/tasks/applyItemAsAmmoTask.ts new file mode 100644 index 00000000..4097991f --- /dev/null +++ b/app/imports/api/engine/action/tasks/applyItemAsAmmoTask.ts @@ -0,0 +1,55 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { + applyDefaultAfterPropTasks, applyTriggers +} from '/imports/api/engine/action/functions/applyTaskGroups'; +import { + getEffectiveActionScope +} from '/imports/api/engine/action/functions/getEffectiveActionScope'; +import { ItemAsAmmoTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import { getPropertyChildren } from '/imports/api/engine/loadCreatures'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; + +export default async function applyItemAsAmmoTask(task: ItemAsAmmoTask, action: EngineAction, result: TaskResult, userInput): Promise { + const prop = task.prop; + const { item } = task.params + let { value } = task.params; + + if (item.type !== 'item') throw 'Must use an item as ammo'; + + // Store the ammo item and value in the scope + result.scope['#ammo'] = { propId: item._id }; + result.pushScope = { ['~ammoConsumed']: { value } }; + + // Apply the before triggers + await applyTriggers(action, item, [action.creatureId], 'ammo.before', userInput); + + // Refetch the scope properties + const scope = await getEffectiveActionScope(action); + result.popScope = { + '~ammoConsumed': 1, + }; + value = scope['~ammoConsumed']?.value || 0; + + const itemChildren = await getPropertyChildren(action.creatureId, item); + + // Do the quantity adjustment + // Check if property has quantity + result.mutations.push({ + targetIds: task.targetIds, + updates: [{ + propId: item._id, + inc: { quantity: -value }, + type: 'item', + }], + // Log the item name as a heading if it has child properties to apply + contents: itemChildren.length ? [{ + name: getPropertyTitle(item) || 'Ammo', + inline: false, + silenced: prop.silent, + }] : undefined, + }); + + await applyTriggers(action, item, [action.creatureId], 'ammo.after', userInput); + return applyDefaultAfterPropTasks(action, item, task.targetIds, userInput); +} \ No newline at end of file diff --git a/app/imports/api/engine/action/tasks/applyTask.ts b/app/imports/api/engine/action/tasks/applyTask.ts new file mode 100644 index 00000000..6794f596 --- /dev/null +++ b/app/imports/api/engine/action/tasks/applyTask.ts @@ -0,0 +1,49 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import Task from './Task'; +import TaskResult from '/imports/api/engine/action/tasks/TaskResult'; +import applyDamagePropTask from '/imports/api/engine/action/tasks/applyDamagePropTask'; +import applyItemAsAmmoTask from '/imports/api/engine/action/tasks/applyItemAsAmmoTask'; +import { getSingleProperty } from '/imports/api/engine/loadCreatures'; +import applyProperties from '/imports/api/engine/action/applyProperties'; + +export default async function applyTask(action: EngineAction, task: Task, userInput?): Promise { + action.taskCount += 1; + if (action.taskCount > 100) throw 'Only 100 properties can be applied at once'; + + if (task.subtaskFn) { + const result = new TaskResult(task.prop._id, task.targetIds); + action.results.push(result); + switch (task.subtaskFn) { + case 'damageProp': + return applyDamagePropTask(task, action, result, userInput); + case 'consumeItemAsAmmo': + return applyItemAsAmmoTask(task, action, result, userInput); + } + } else { + // Get property + const prop = task.prop; + + // Ensure the prop exists + if (!prop) throw new Meteor.Error('Not found', 'Property could not be found'); + + // If the property is deactivated by a toggle, skip it + if (prop.deactivatedByToggle) return; + + // Before triggers + if (prop.triggerIds?.before?.length) { + for (const triggerId of prop.triggerIds.before) { + const trigger = await getSingleProperty(action.creatureId, triggerId); + if (!trigger) continue; + await applyTask(action, { prop: trigger, targetIds: task.targetIds }, userInput); + } + } + + // Create a result an push it to the action results, pass it to the apply function to modify + const result = new TaskResult(task.prop._id, task.targetIds); + result.scope[`#${prop.type}`] = prop; + action.results.push(result); + + // Apply the property + return applyProperties[prop.type]?.(task, action, result, userInput); + } +} \ No newline at end of file diff --git a/app/imports/api/engine/actions/ActionContext.ts b/app/imports/api/engine/actions/ActionContext.ts deleted file mode 100644 index 969f9523..00000000 --- a/app/imports/api/engine/actions/ActionContext.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/log/CreatureLogs'; -import { - getCreature, getVariables, getPropertiesOfType, replaceLinkedVariablesWithProps -} from '/imports/api/engine/loadCreatures'; -import { groupBy, remove } from 'lodash'; - -export default class ActionContext { - constructor(creatureId, targetIds = [], method, invocationId) { - // Get the creature - this.creature = getCreature(creatureId) - // Store the details for pausing for user interaction - this.invocationId = invocationId; - this.userInputStep = 0; - - if (!this.creature) { - throw new Meteor.Error('No Creature', `No creature could be found with id: ${creatureId}`) - } - // Create a log - this.log = CreatureLogSchema.clean({ - creatureId: creatureId, - creatureName: this.creature.name, - }); - - // Get the variables of the acting creature - this.creature.variables = getVariables(creatureId); - replaceLinkedVariablesWithProps(this.creature.variables); - delete this.creature.variables._id; - delete this.creature.variables._creatureId; - // Alias as scope - this.scope = this.creature.variables; - - // Get the targets and their variables - this.targets = []; - targetIds.forEach(targetId => { - let target; - if (targetId === creatureId) { - target = this.creature; - } else { - target = getCreature(targetId); - target.variables = getVariables(targetId); - delete target.variables._id; - delete target.variables._creatureId; - } - this.targets.push(target); - }); - - // Store a reference to the method for inserting the log - this.method = method; - - // Get triggers - this.triggers = getPropertiesOfType(creatureId, 'trigger'); - // Remove deleted or inactive triggers - remove(this.triggers, trigger => trigger.removed || trigger.inactive); - // Sort triggers by order - this.triggers.sort((a, b) => a.order - b.order); - // Group the triggers into triggers.. or - // triggers.doActionProperty.. - this.triggers = groupBy(this.triggers, 'event'); - for (const event in this.triggers) { - if (event === 'doActionProperty') { - this.triggers[event] = groupBy(this.triggers[event], 'actionPropertyType'); - for (const propertyType in this.triggers[event]) { - this.triggers[event][propertyType] = groupBy(this.triggers[event][propertyType], 'timing'); - } - } else { - this.triggers[event] = groupBy(this.triggers[event], 'timing'); - } - } - } - addLog(content) { - if (content.name || content.value) { - this.log.content.push(content); - } - } - writeLog() { - insertCreatureLogWork({ - log: this.log, - creature: this.creature, - method: this.method, - }); - } -} \ No newline at end of file diff --git a/app/imports/api/engine/actions/ActionEngine.ts b/app/imports/api/engine/actions/ActionEngine.ts deleted file mode 100644 index ea73d5d3..00000000 --- a/app/imports/api/engine/actions/ActionEngine.ts +++ /dev/null @@ -1,1452 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { forEach, get, isEmpty, pick, result } from 'lodash'; -import LogContentSchema from '/imports/api/creature/log/LogContentSchema'; -import { getCreature, getPropertiesOfType, getPropertyChildren, getSingleProperty, getVariables } from '/imports/api/engine/loadCreatures'; -import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations'; -import recalculateCalculation, { rollAndReduceCalculation } from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'; -import rollDice from '/imports/parser/rollDice'; -import { getFromScope } from '/imports/api/creature/creatures/CreatureVariables'; -import { getPropertyName } from '/imports/constants/PROPERTIES'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { assertEditPermission } from '/imports/api/sharing/sharingPermissions'; -import numberToSignedString from '/imports/api/utility/numberToSignedString'; -import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex'; -import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey'; -import resolve, { map, toString } from '/imports/parser/resolve'; -import accessor from '/imports/parser/parseTree/accessor'; -import cyrb53 from '/imports/api/engine/computation/utility/cyrb53'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -const Actions = new Mongo.Collection('actions'); - -export interface Action { - _id?: string; - _isSimulation?: boolean; - _stepThrough?: boolean; - creatureId: string; - rootPropId: string; - targetIds?: string[]; - results: TaskResult[]; - taskCount: number; -} - -type Task = PropTask | DamagePropTask | ItemAsAmmoTask; - -interface BaseTask { - prop: { [key: string]: any }; - targetIds: string[]; -} - -interface PropTask extends BaseTask { - subtaskFn?: undefined, -} - -class TaskResult { - propId: string; - targetIds: string[]; - scope: any; - // Consume pushed changes from the local scope, every change pushed must be popped later - popScope?: any; - // Push changes to the scope if the same task intends to consume them in later steps - // These changes will be marked as _busy until they are consumed - // This allows a property to run in between steps of the same property type without - // bashing the variables used to maintain state between steps while still exposing - // those variables to triggers that need to change them - // If multiple properties use the same variable at once, the values used by outer - // properties can be found on variable.previous - pushScope?: any; - mutations: Mutation[]; - constructor(propId: string, targetIds: string[]) { - this.propId = propId; - this.targetIds = targetIds; - this.mutations = []; - this.scope = {}; - } - // 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 = { - // Which creatures the mutation is applied to - targetIds: string[]; - // What changes in the database - updates?: Update[]; - // Logged when this is applied - contents?: LogContent[]; -} - -export type Update = { - propId: string; - type: string, - set?: any; - inc?: any; -} - -export type LogContent = { - name?: string; - value?: string; - inline?: boolean; - context?: any; - silenced?: boolean; -} - -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: Object, - optional: true, - blackbox: true, - }, - - // 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: String, - regEx: SimpleSchema.RegEx.Id, - }, - 'results.$.targetIds': { - type: Array, - defaultValue: [], - }, - 'results.$.targetIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - // Changes that override the local scope - 'results.$.scope': { - type: Object, - optional: true, - blackbox: true, - }, - // Changes that consume pushed values from the local scope - 'results.$.popScope': { - type: Object, - optional: true, - blackbox: true, - }, - // Changes that push values to the local scope - 'results.$.pushScope': { - type: Object, - optional: true, - blackbox: true, - }, - // database changes - 'results.$.mutations': { - type: Array, - optional: true, - }, - 'results.$.mutations.$': { - type: Object, - }, - 'results.$.mutations.$.targetIds': { - type: Array, - }, - 'results.$.mutations.$.targetIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - 'results.$.mutations.$.updates': { - type: Array, - optional: true, - }, - 'results.$.mutations.$.updates.$': { - type: Object, - }, - 'results.$.mutations.$.updates.$.propId': { - 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, - }, - 'results.$.mutations.$.contents.$': { - type: LogContentSchema, - }, -}); - -// @ts-expect-error Collections2 lacks TypeScript support -Actions.attachSchema(ActionSchema); - -export default Actions; - -export const insertAction: ValidatedMethod = new ValidatedMethod({ - name: 'actions.insertAction', - validate: new SimpleSchema({ - action: ActionSchema - }).validator({ clean: true }), - run: async function ({ action }: { action: Action }) { - 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 - Actions.removeAsync({ 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 await Actions.insertAsync(action); - }, -}); - -export const runAction = new ValidatedMethod({ - name: 'actions.runAction', - validate: new SimpleSchema({ - action: { - type: Object, - blackbox: true, - }, - userInput: { - type: Object, - blackbox: true, - optional: true, - }, - stepThrough: { - type: Boolean, - optional: true, - } - }).validator(), - run: async function ({ actionId, userInput }: { actionId: string, userInput?: any }) { - const action = await Actions.findOneAsync(actionId); - if (!action) throw 'Action not found'; - assertEditPermission(getCreature(action.creatureId), this.userId); - const originalAction = EJSON.clone(action); - applyAction(action, userInput); - // Persist changes to the action - const writePromise = writeChangedAction(originalAction, action); - return writePromise; - }, -}); - -// 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 -export async function applyAction(action: Action, userInput?: any[] | Function, options?: { - simulate?: boolean, stepThrough?: boolean -}) { - const { simulate, stepThrough } = options || {}; - if (!simulate && stepThrough) throw 'Cannot step through unless simulating'; - if (simulate && typeof userInput !== 'function') throw 'Must provide a function to get user input when simulating'; - - action._stepThrough = stepThrough; - action._isSimulation = simulate; - action.taskCount = 0; - 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 }; -} - -// TODO create a function to get the effective value of a property, -// simulating all the result updates in the action so far - -async function applyTask(action: Action, task: Task, userInput?): Promise { - action.taskCount += 1; - if (action.taskCount > 100) throw 'Only 100 properties can be applied at once'; - - if (task.subtaskFn) { - const result = new TaskResult(task.prop._id, task.targetIds); - action.results.push(result); - switch (task.subtaskFn) { - case 'damageProp': - return damageProp(task, action, result, userInput); - case 'consumeItemAsAmmo': - return consumeItemAsAmmo(task, action, result, userInput); - } - } else { - // Get property - const prop = task.prop; - - // Ensure the prop exists - if (!prop) throw new Meteor.Error('Not found', 'Property could not be found'); - - // If the property is deactivated by a toggle, skip it - if (prop.deactivatedByToggle) return; - - // Before triggers - if (prop.triggerIds?.before?.length) { - for (const triggerId of prop.triggerIds.before) { - const trigger = await getSingleProperty(action.creatureId, triggerId); - if (!trigger) continue; - await applyTask(action, { prop: trigger, targetIds: task.targetIds }, userInput); - } - } - - // Create a result an push it to the action results, pass it to the apply function to modify - const result = new TaskResult(task.prop._id, task.targetIds); - result.scope[`#${prop.type}`] = prop; - action.results.push(result); - - // Apply the property - return applyPropertyByType[prop.type]?.(task, action, result, userInput); - } -} - -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) && original._id) { - return Actions.updateAsync(original._id, { $set }); - } -} - -function getPropertyTitle(prop) { - if (prop.name) return prop.name; - return getPropertyName(prop.type); -} - -/** - * Get all the child tasks of a given property - * @param action - * @param prop - * @param targetIds - * @returns - */ -async function applyChildren(action: Action, prop, targetIds, userInput) { - const children = await getPropertyChildren(action.creatureId, prop); - // Push the child tasks and related triggers to the stack - for (const childProp of children) { - await applyTask(action, { prop: childProp, targetIds }, userInput); - } -} - -/** - * Get the afterChildren triggers for a given property - * @param prop - * @param targetIds - * @returns - */ -async function applyAfterChildrenTriggers(action: Action, prop, targetIds, userInput) { - if (!prop.triggerIds?.afterChildren) return; - for (const triggerId of prop.triggerIds.afterChildren) { - const trigger = await getSingleProperty(action.creatureId, triggerId); - if (!trigger) continue; - await applyTask(action, { prop: trigger, targetIds }, userInput); - } -} - -async function applyAfterTriggers(action: Action, prop, targetIds, userInput) { - if (!prop.triggerIds?.after) return; - for (const triggerId of prop.triggerIds.after) { - const trigger = await getSingleProperty(action.creatureId, triggerId); - if (!trigger) continue; - await applyTask(action, { prop: trigger, targetIds }, userInput); - } -} - -/** - * Applies the following: - * After triggers - * Children of the prop - * After-children triggers - * @param action - * @param prop - * @param targetIds - * @returns - */ -async function applyDefaultAfterPropTasks(action: Action, prop, targetIds, userInput) { - await applyAfterTriggers(action, prop, targetIds, userInput); - await applyChildren(action, prop, targetIds, userInput); - await applyAfterChildrenTriggers(action, prop, targetIds, userInput); -} - -/** - * Applies the following: - * After triggers - * After-children triggers - * @param action - * @param prop - * @param targetIds - * @returns - */ -async function applyAfterTasksSkipChildren(action: Action, prop, targetIds, userInput) { - await applyAfterTriggers(action, prop, targetIds, userInput); - await applyAfterChildrenTriggers(action, prop, targetIds, userInput); -} - -/** - * Returns a list of tasks containing the following: - * After triggers - * After-children triggers - * @param action - * @param prop - * @param targetIds - * @returns - */ -async function applyAfterPropTasksForSingleChild(action: Action, prop, childProp, targetIds, userInput) { - await applyAfterTriggers(action, prop, targetIds, userInput); - await applyTask(action, { prop: childProp, targetIds }, userInput); - await applyAfterChildrenTriggers(action, prop, targetIds, userInput); -} - -/** - * Get all the trigger tasks for a given trigger path - * @param action - * @param prop - * @param targetIds - * @param triggerPath - * @returns - */ -async function applyTriggers(action: Action, prop, targetIds: string[], triggerPath: string, userInput) { - const triggerIds = get(prop?.triggers, triggerPath); - if (!triggerIds) return; - for (const triggerId of triggerIds) { - const trigger = await getSingleProperty(action.creatureId, triggerId); - if (!trigger) continue; - await applyTask(action, { prop: trigger, targetIds }, userInput); - } -} - -/** - * Split a task over its targets, incrementing task step by 1 - * @param task - * @param targetIds - * @returns Copies of the task, but with a single target each - */ -async function applyTaskToEachTarget(action: Action, task: PropTask, targetIds: string[] = task.targetIds, userInput) { - if (targetIds.length <= 1) throw 'Must have multiple targets to split a task'; - // If there are targets, apply a new task to each target - for (const targetId of targetIds) { - await applyTask(action, { - ...task, - targetIds: [targetId] - }, userInput); - } -} - -// Combine all the action results into the scope at present -export async function getEffectiveActionScope(action: Action) { - const scope = await getVariables(action.creatureId); - delete scope._id; - delete scope._creatureId; - // Combine the applied results - for (const result of action.results) { - // Pop keys that are not longer used by a busy property - if (result.popScope) { - for (const key in result.popScope) { - if (!result.popScope[key]) continue; - // If the popped keys have previous results, return to them - if (scope[key]?.previous) { - scope[key] = scope[key]?.previous; - } else { - // just remove the busy flag, the prop has been consumed - delete scope[key]?._busy - } - } - } - // For keys that have just started being used by a busy property - if (result.pushScope) { - for (const key in result.pushScope) { - // If the pushed keys already exist and are busy, - // save the previous results and overwrite - // the key - if (scope[key]?._busy) { - scope[key] = { - ...result.pushScope[key], - previous: scope[key], - _busy: true, - }; - } else { - scope[key] = { - ...result.pushScope[key], - _busy: true, - }; - } - } - } - // Assign other scope changes without bashing the scope[key].previous field - if (result.scope) { - for (const key in result.scope) { - if (scope[key]?.previous || scope[key]?._busy) { - scope[key] = { - ...result.scope[key], - previous: scope[key].previous, - _busy: scope[key]._busy, - }; - } else { - scope[key] = result.scope[key]; - } - } - } - } - return scope; -} - - -const applyPropertyByType = { - - async action(task: PropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds; - - //Log the name and summary, check that the property has enough resources to fire - const content: LogContent = { name: prop.name }; - if (prop.summary?.text) { - await recalculateInlineCalculations(prop.summary, action); - content.value = prop.summary.value; - } - if (prop.silent) content.silenced = true; - result.appendLog(content, targetIds); - - // Check Uses - if (prop.usesLeft <= 0) { - if (!prop.silent) result.appendLog({ - name: 'Error', - value: `${prop.name || 'action'} does not have enough uses left`, - }, targetIds); - return; - } - - // Check Resources - if (prop.insufficientResources) { - if (!prop.silent) result.appendLog({ - name: 'Error', - value: 'This creature doesn\'t have sufficient resources to perform this action', - }, targetIds); - return; - } - - spendResources(action, prop, targetIds, result, userInput); - - const attack = prop.attackRoll || prop.attackRollBonus; - - // Attack if there is an attack roll - if (attack && attack.calculation) { - if (targetIds.length) { - for (const target of targetIds) { - await applyAttackToTarget(action, prop, attack, targetIds, result, userInput); - await applyAfterTriggers(action, prop, [target], userInput); - await applyChildren(action, prop, [target], userInput); - } - } else { - await applyAttackWithoutTarget(action, prop, attack, result, userInput); - await applyAfterTriggers(action, prop, targetIds, userInput); - await applyChildren(action, prop, targetIds, userInput); - } - } else { - await applyAfterTriggers(action, prop, targetIds, userInput); - await applyChildren(action, prop, targetIds, userInput); - } - if (prop.actionType === 'event' && prop.variableName) { - resetProperties(action, prop, result, userInput); - } - - // Finish - return await applyAfterChildrenTriggers(action, prop, targetIds, userInput); - }, - - async adjustment(task: PropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - const damageTargetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds; - - if (damageTargetIds.length > 1) { - return await applyTaskToEachTarget(action, task, damageTargetIds, userInput); - } - - // Get the operation and value and push the damage hooks to the queue - if (!prop.amount) { - return; - } - - // Evaluate the amount - await recalculateCalculation(prop.amount, action, 'reduce'); - const value = +prop.amount.value; - if (!isFinite(value)) { - return; - } - - if (!damageTargetIds?.length) { - return; - } - - if (damageTargetIds.length !== 1) { - throw 'At this step, only a single target is supported' - } - const targetId = damageTargetIds[0]; - const statId = getVariables(targetId)?.[prop.stat]?._propId; - const stat = statId && getSingleProperty(targetId, statId); - if (!stat?.type) { - result.appendLog({ - name: 'Error', - value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set`, - silenced: prop.silent, - }, damageTargetIds); - return; - } - - applyTask(action, { - prop, - targetIds: damageTargetIds, - subtaskFn: 'damageProp', - params: { - title: getPropertyTitle(prop), - operation: prop.operation, - value, - targetProp: stat, - }, - }, userInput); - return applyDefaultAfterPropTasks(action, prop, damageTargetIds, userInput); - }, - - async branch(task: PropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - const targets = task.targetIds; - - switch (prop.branchType) { - case 'if': { - await recalculateCalculation(prop.condition, action, 'reduce'); - if (prop.condition?.value) { - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'index': { - const children = await getPropertyChildren(action.creatureId, prop); - if (!children.length) { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - await 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); - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - let index = Math.floor(prop.condition?.value); - if (index < 1) index = 1; - if (index > children.length) index = children.length; - const child = children[index - 1]; - return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); - } - case 'hit': { - const scope = await getEffectiveActionScope(action); - if (scope['~attackHit']?.value) { - if (!targets.length && !prop.silent) { - result.appendLog({ - value: '**On hit**' - }, targets); - } - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'miss': { - const scope = await getEffectiveActionScope(action); - if (scope['~attackMiss']?.value) { - if (!targets.length && !prop.silent) { - result.appendLog({ - value: '**On miss**' - }, targets); - } - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'failedSave': { - const scope = await getEffectiveActionScope(action); - if (scope['~saveFailed']?.value) { - if (!targets.length && !prop.silent) { - result.appendLog({ - value: '**On failed save**' - }, targets); - } - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'successfulSave': { - const scope = await getEffectiveActionScope(action); - if (scope['~saveSucceeded']?.value) { - if (!targets.length && !prop.silent) { - result.appendLog({ - value: '**On save**' - }, targets); - } - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'random': { - const children = await getPropertyChildren(action.creatureId, prop); - if (children.length) { - const index = rollDice(1, children.length)[0]; - const child = children[index - 1]; - return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); - } else { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - } - case 'eachTarget': - if (targets.length > 1) { - return applyTaskToEachTarget(action, task, targets, userInput); - } - return applyDefaultAfterPropTasks(action, prop, targets, userInput); - case 'choice': { - let index; - if (action._isSimulation) { - index = await userInput(prop); - } else { - // TODO - throw 'Reading stored user input not implemented' - } - const children = await getPropertyChildren(action.creatureId, prop); - if (!children.length) { - return applyAfterTasksSkipChildren(action, prop, targets, userInput); - } - if (!isFinite(index) || index < 0) index = 0; - if (index > children.length - 1) index = children.length - 1; - const child = children[index]; - return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); - } - } - }, - - async buff(task: PropTask, action: Action, userInput) { - const prop = task.prop; - const buffTargets = task.targetIds; - - // Mark the buff as dirty for recalculation - prop.dirty = true; - - // Then copy the descendants of the buff to the targets - const propList = [prop]; - function addChildrenToPropList(children, { skipCrystalize } = { skipCrystalize: false }) { - children.forEach(child => { - if (skipCrystalize) child.node._skipCrystalize = true; - propList.push(child.node); - // recursively add the child's children, but don't crystalize nested buffs - addChildrenToPropList(child.children, { - skipCrystalize: skipCrystalize || child.node.type === 'buff' - }); - }); - } - addChildrenToPropList(node.children); - if (!prop.skipCrystalization) { - crystalizeVariables({ propList, actionContext }); - } - - buffTargets.forEach(target => { - const targetPropList = EJSON.clone(propList); - // Move the properties to the target by replacing the old subtree parent and root with the ' - // target id - renewDocIds({ - docArray: targetPropList, - idMap: { - [prop.parentId]: target._id, - [prop.root.id]: target._id, - }, - collectionMap: { [prop.root.collection]: 'creatures' } - }); - // Apply the buff - CreatureProperties.batchInsert(targetPropList); - - //Log the buff - let logValue = prop.description?.value - if (prop.description?.text) { - recalculateInlineCalculations(prop.description, actionContext); - logValue = prop.description?.value; - } - if ((prop.name || prop.description?.value) && !prop.silent) { - if (target._id === actionContext.creature._id) { - // Targeting self - actionContext.addLog({ - name: prop.name, - value: logValue, - }); - } else { - // Targeting other - insertCreatureLog.call({ - log: { - creatureId: target._id, - content: [{ - name: prop.name, - value: logValue, - }], - } - }); - } - } - }); - applyNodeTriggers(node, 'after', actionContext); - applyNodeTriggers(node, 'afterChildren', actionContext); - }, - - async folder(task: PropTask, action: Action, userInput): Promise { - const prop = task.prop; - return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); - }, - - async note(task: PropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - let contents: LogContent[] | undefined = undefined; - const logContent: LogContent = {}; - if (prop.name) logContent.name = prop.name; - if (prop.summary?.text) { - await recalculateInlineCalculations(prop.summary, action); - logContent.value = prop.summary.value; - } - - if (logContent.name || logContent.value) { - contents = [logContent]; - } - // Log description - if (prop.description?.text) { - await recalculateInlineCalculations(prop.description, action); - if (!contents) contents = []; - contents.push({ value: prop.description.value }); - } - if (contents) { - result.mutations.push({ - contents, - targetIds: task.targetIds, - }); - } - return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); - }, - - async roll(task: PropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - // If there isn't a calculation, just apply the children instead - if (!prop.roll?.calculation) { - return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); - } - - const logValue: string[] = []; - - // roll the dice only and store that string - const { - rolled, reduced, errors - } = await rollAndReduceCalculation(prop.roll, action); - - if (rolled.parseType !== 'constant') { - logValue.push(toString(rolled)); - } - errors?.forEach(error => { - result.appendLog({ name: 'Error', value: error.message }, task.targetIds); - }); - - // Store the result - if (reduced.parseType === 'constant') { - prop.roll.value = reduced.value; - } else if (reduced.parseType === 'error') { - prop.roll.value = null; - } else { - prop.roll.value = toString(reduced); - } - - // If we didn't end up with a constant or a number of finite value, give up - if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) { - return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); - } - const value = reduced.value; - - result.scope[prop.variableName] = { value }; - logValue.push(`**${value}**`); - - result.appendLog({ - name: prop.name, - value: logValue.join('\n'), - inline: true, - silenced: prop.silent, - }, task.targetIds); - - // Apply children - return applyDefaultAfterPropTasks(action, prop, task.targetIds, userInput); - }, -} - - -// Sub tasks - -interface DamagePropTask extends BaseTask { - subtaskFn: 'damageProp'; - params: { - /** - * Use getPropertyTitle(prop) to set the title - */ - title?: string; - operation: 'increment' | 'set'; - value: number; - targetProp: any; - }; -} - -async function damageProp(task: DamagePropTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - - if (task.targetIds.length > 1) { - throw 'This subtask can only be called on a single target'; - } - const targetId = task.targetIds[0]; - - let { value } = task.params; - const { title, operation } = task.params; - let targetProp = task.params.targetProp; - - // Set the scope properties - result.pushScope = {}; - if (prop.operation === 'increment') { - if (value >= 0) { - result.pushScope['~damage'] = { value }; - } else { - result.pushScope['~healing'] = { value: -value }; - } - } else { - result.pushScope['~set'] = { value }; - } - // Store which property we're targeting - if (targetId === action.creatureId) { - result.pushScope['~attributeDamaged'] = { _propId: targetProp._id }; - } else { - result.pushScope['~attributeDamaged'] = targetProp; - } - - // Run the before triggers which may change scope properties - await applyTriggers(action, targetProp, [action.creatureId], 'damageProperty.before', userInput); - - // Refetch the scope properties - const scope = await getEffectiveActionScope(action); - result.popScope = { - '~damage': 1, '~healing': 1, '~set': 1, '~attributeDamaged': 1, - }; - value = +value; - if (operation === 'increment') { - if (value >= 0) { - value = scope['~damage']?.value; - } else { - value = -scope['~healing']?.value; - } - } else { - value = scope['~set']?.value; - } - const targetPropId = scope['~attributeDamaged']?._propId; - - // If there are no targets, just log the result that would apply and end - if (!task.targetIds?.length) { - // Get the locally equivalent stat with the same variable name - const statName = getPropertyTitle(targetProp); - result.appendLog({ - name: title, - value: `${statName}${operation === 'set' ? ' set to' : ''}` + - ` ${value}`, - inline: true, - silenced: prop.silent, - }, task.targetIds); - } - - let damage, newValue, increment; - targetProp = await getSingleProperty(targetId, targetPropId); - - if (!targetProp) return; - - if (operation === 'set') { - const total = targetProp.total || 0; - // Set represents what we want the value to be after damage - // So we need the actual damage to get to that value - damage = total - value; - // Damage can't exceed total value - if (damage > total && !targetProp.ignoreLowerLimit) damage = total; - // Damage must be positive - if (damage < 0 && !targetProp.ignoreUpperLimit) damage = 0; - newValue = targetProp.total - damage; - // Write the results - result.mutations.push({ - targetIds: [targetId], - updates: [{ - propId: targetProp._id, - set: { damage, value: newValue }, - type: targetProp.type, - }], - contents: [{ - name: title, - value: `${getPropertyTitle(targetProp)} set to ${value}`, - inline: true, - silenced: prop.silent, - }] - }); - } else if (operation === 'increment') { - const currentValue = targetProp.value || 0; - const currentDamage = targetProp.damage || 0; - increment = value; - // Can't increase damage above the remaining value - if (increment > currentValue && !targetProp.ignoreLowerLimit) increment = currentValue; - // Can't decrease damage below zero - if (-increment > currentDamage && !targetProp.ignoreUpperLimit) increment = -currentDamage; - damage = currentDamage + increment; - newValue = targetProp.total - damage; - // Write the results - result.mutations.push({ - targetIds: [targetId], - updates: [{ - propId: targetProp._id, - inc: { damage: increment, value: -increment }, - type: targetProp.type, - }], - contents: [{ - name: 'Attribute damage', - value: `${getPropertyTitle(targetProp)} ${value}`, - inline: true, - silenced: prop.silent, - }] - }); - } - await applyTriggers(action, prop, [action.creatureId], 'damageProperty.after', userInput); -} - - -interface ItemAsAmmoTask extends BaseTask { - subtaskFn: 'consumeItemAsAmmo'; - params: { - value: number; - item: any; - }; -} - -async function consumeItemAsAmmo(task: ItemAsAmmoTask, action: Action, result: TaskResult, userInput): Promise { - const prop = task.prop; - const { item } = task.params - let { value } = task.params; - - if (item.type !== 'item') throw 'Must use an item as ammo'; - - // Store the ammo item and value in the scope - result.scope['#ammo'] = { propId: item._id }; - result.pushScope = { ['~ammoConsumed']: { value } }; - - // Apply the before triggers - await applyTriggers(action, item, [action.creatureId], 'ammo.before', userInput); - - // Refetch the scope properties - const scope = await getEffectiveActionScope(action); - result.popScope = { - '~ammoConsumed': 1, - }; - value = scope['~ammoConsumed']?.value || 0; - - const itemChildren = await getPropertyChildren(action.creatureId, item); - - // Do the quantity adjustment - // Check if property has quantity - result.mutations.push({ - targetIds: task.targetIds, - updates: [{ - propId: item._id, - inc: { quantity: -value }, - type: 'item', - }], - // Log the item name as a heading if it has child properties to apply - contents: itemChildren.length ? [{ - name: getPropertyTitle(item) || 'Ammo', - inline: false, - silenced: prop.silent, - }] : undefined, - }); - - await applyTriggers(action, item, [action.creatureId], 'ammo.after', userInput); - return applyDefaultAfterPropTasks(action, item, task.targetIds, userInput); -} - -async function spendResources(action: Action, prop, targetIds: string[], result: TaskResult, userInput) { - // Use uses - if (prop.usesLeft) { - result.mutations.push({ - targetIds, - updates: [{ - propId: prop._id, - inc: { usesUsed: 1, usesLeft: -1 }, - type: prop.type, - }], - contents: [{ - name: 'Uses left', - value: `${prop.usesLeft - 1}`, - inline: true, - silenced: prop.silent, - }] - }); - } - - // Iterate through all the resources consumed and damage them - if (prop.resources?.attributesConsumed?.length) { - for (const att of prop.resources.attributesConsumed) { - const scope = await getEffectiveActionScope(action); - const statToDamage = getFromScope(att.variableName, scope); - await recalculateCalculation(att.quantity, action, 'reduce'); - await applyTask(action, { - prop, - targetIds: [action.creatureId], - subtaskFn: 'damageProp', - params: { - operation: 'increment', - value: +att.quantity?.value || 0, - targetProp: statToDamage, - }, - }, userInput); - } - } - - // Iterate through all the items consumed and consume them - if (prop.resources?.itemsConsumed?.length) { - for (const itemConsumed of prop.resources.itemsConsumed) { - await recalculateCalculation(itemConsumed.quantity, action, 'reduce'); - if (!itemConsumed.itemId) { - throw 'No ammo was selected'; - } - const item = getSingleProperty(action.creatureId, itemConsumed.itemId); - if (!item || item.root.id !== prop.root.id) { - throw 'The prop\'s ammo was not found on the creature'; - } - const quantity = +itemConsumed?.quantity?.value; - if ( - !quantity || - !isFinite(quantity) - ) continue; - await applyTask(action, { - prop, - targetIds, - subtaskFn: 'consumeItemAsAmmo', - params: { - value: quantity, - item, - }, - }, userInput); - } - } -} - -async function applyAttackToTarget(action, prop, attack, target, taskResult: TaskResult, userInput) { - taskResult.pushScope = { - '~attackHit': {}, - '~attackMiss': {}, - '~criticalHit': {}, - '~criticalMiss': {}, - '~attackRoll': {}, - } - - await recalculateCalculation(attack, action, 'reduce'); - const scope = await getEffectiveActionScope(action); - const contents: LogContent[] = []; - - const { - resultPrefix, - result, - criticalHit, - criticalMiss, - } = await rollAttack(attack, scope, taskResult.pushScope); - - if (target.variables.armor) { - const armor = target.variables.armor.value; - - let name = criticalHit ? 'Critical Hit!' : - criticalMiss ? 'Critical Miss!' : - result > armor ? 'Hit!' : 'Miss!'; - if (scope['~attackAdvantage']?.value === 1) { - name += ' (Advantage)'; - } else if (scope['~attackAdvantage']?.value === -1) { - name += ' (Disadvantage)'; - } - - contents.push({ - name, - value: `${resultPrefix}\n**${result}**`, - inline: true, - silenced: prop.silent, - }); - - if (criticalMiss || result < armor) { - scope['~attackMiss'] = { value: true }; - } else { - scope['~attackHit'] = { value: true }; - } - } else { - contents.push({ - name: 'Error', - value: 'Target has no `armor`', - inline: true, - silenced: prop.silent, - }, { - name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit', - value: `${resultPrefix}\n**${result}**`, - inline: true, - silenced: prop.silent, - }); - } - if (contents.length) { - taskResult.mutations.push({ - contents, - targetIds: [target], - }); - } -} - -async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskResult, userInput) { - taskResult.pushScope = { - '~attackHit': {}, - '~attackMiss': {}, - '~criticalHit': {}, - '~criticalMiss': {}, - '~attackRoll': {}, - } - await recalculateCalculation(attack, action, 'reduce'); - const scope = await getEffectiveActionScope(action); - const { - resultPrefix, - result, - criticalHit, - criticalMiss, - } = await rollAttack(attack, scope, taskResult.pushScope); - let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit'; - if (scope['~attackAdvantage']?.value === 1) { - name += ' (Advantage)'; - } else if (scope['~attackAdvantage']?.value === -1) { - name += ' (Disadvantage)'; - } - if (!criticalMiss) { - scope['~attackHit'] = { value: true } - } - if (!criticalHit) { - scope['~attackMiss'] = { value: true }; - } - taskResult.mutations.push({ - contents: [{ - name, - value: `${resultPrefix}\n**${result}**`, - inline: true, - silenced: prop.silent, - }], - targetIds: [], - }); -} - -async function rollAttack(attack, scope, resultPushScope) { - const rollModifierText = numberToSignedString(attack.value, true); - let value, resultPrefix; - if (scope['~attackAdvantage']?.value === 1) { - const [a, b] = await rollDice(2, 20); - if (a >= b) { - value = a; - resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; - } else { - value = b; - resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; - } - } else if (scope['~attackAdvantage']?.value === -1) { - const [a, b] = await rollDice(2, 20); - if (a <= b) { - value = a; - resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; - } else { - value = b; - resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; - } - } else { - value = await rollDice(1, 20)[0]; - resultPrefix = `1d20 [${value}] ${rollModifierText}` - } - resultPushScope['~attackDiceRoll'] = { value }; - const result = value + attack.value; - resultPushScope['~attackRoll'] = { value: result }; - const { criticalHit, criticalMiss } = applyCrits(value, scope, resultPushScope); - return { resultPrefix, result, value, criticalHit, criticalMiss }; -} - -function applyCrits(value, scope, resultPushScope) { - let scopeCrit = scope['~criticalHitTarget']?.value; - if (scopeCrit?.parseType === 'constant') { - scopeCrit = scopeCrit.value; - } - const criticalHitTarget = scopeCrit || 20; - const criticalHit = value >= criticalHitTarget; - let criticalMiss; - if (criticalHit) { - resultPushScope['~criticalHit'] = { value: true }; - } else { - criticalMiss = value === 1; - if (criticalMiss) { - resultPushScope['~criticalMiss'] = { value: true }; - } - } - return { criticalHit, criticalMiss }; -} - -async function resetProperties(action: Action, prop: any, result: TaskResult, userInput) { - const attributes = getPropertiesOfType(action.creatureId, 'attribute'); - for (const att of attributes) { - if (att.removed || att.inactive) continue; - if (att.reset !== prop.variableName) continue; - if (!att.damage) continue; - applyTask(action, { - prop: att, - targetIds: [action.creatureId], - subtaskFn: 'damageProp', - params: { - title: getPropertyTitle(att), - operation: 'increment', - value: -att.damage ?? 0, - targetProp: att, - }, - }, userInput) - } - const actions = [ - ...getPropertiesOfType(action.creatureId, 'action'), - ...getPropertiesOfType(action.creatureId, 'spell'), - ] - for (const act of actions) { - if (act.removed || act.inactive) continue; - if (act.reset !== prop.variableName) continue; - if (!act.usesUsed) continue; - result.mutations.push({ - targetIds: [action.creatureId], - updates: [{ - propId: act._id, - set: { usesUsed: 0 }, - type: act.type, - }], - contents: [{ - name: getPropertyTitle(act), - value: act.usesUsed >= 0 ? `Restored ${act.usesUsed} uses` : `Removed ${-act.usesUsed} uses`, - inline: true, - silenced: prop.silent, - }] - }); - } -} - -/** - * Replaces all variables with their resolved values - * except variables of the form `~target.thing.total` become `thing.total` - */ -function crystalizeVariables({ propList, actionContext }) { - propList.forEach(prop => { - if (prop._skipCrystalize) { - delete prop._skipCrystalize; - return; - } - // Iterate through all the calculations and crystalize them - computedSchemas[prop.type].computedFields().forEach(calcKey => { - applyFnToKey(prop, calcKey, (prop, key) => { - const calcObj = get(prop, key); - if (!calcObj?.parseNode) return; - calcObj.parseNode = map(calcObj.parseNode, node => { - // Skip nodes that aren't symbols or accessors - if ( - node.parseType !== 'accessor' - ) return node; - // Handle variables - if (node.name === '~target') { - // strip ~target - if (node.parseType === 'accessor') { - node.name = node.path.shift(); - if (!node.path.length) { - return accessor.create({ name: node.name }) - } - } else { - // Can't strip symbols - actionContext.addLog({ - name: 'Error', - value: 'Variable `~target` should not be used without a property: ~target.property', - }); - } - return node; - } else { - // Resolve all other variables - const { result, context } = resolve('reduce', node, actionContext.scope); - logErrors(context.errors, actionContext); - return result; - } - }); - calcObj.calculation = toString(calcObj.parseNode); - calcObj.hash = cyrb53(calcObj.calculation); - }); - }); - // For each key in the schema - computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => { - // That ends in .inlineCalculations - applyFnToKey(prop, calcKey, (prop, key) => { - const inlineCalcObj = get(prop, key); - if (!inlineCalcObj) return; - - // If there is no text, skip - if (!inlineCalcObj.text) { - return; - } - - // Replace all the existing calculations - let index = -1; - inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => { - index += 1; - return `{${inlineCalcObj.inlineCalculations[index].calculation}}`; - }); - - // Set the value to the uncomputed string - inlineCalcObj.value = inlineCalcObj.text; - - // Write a new hash - const inlineCalcHash = cyrb53(inlineCalcObj.text); - if (inlineCalcHash === inlineCalcObj.hash) { - // Skip if nothing changed - return; - } - inlineCalcObj.hash = inlineCalcHash; - }); - }); - }); -} diff --git a/app/imports/api/engine/actions/applyProperty.ts b/app/imports/api/engine/actions/applyProperty.ts deleted file mode 100644 index eeb010e6..00000000 --- a/app/imports/api/engine/actions/applyProperty.ts +++ /dev/null @@ -1,38 +0,0 @@ -import action from './applyPropertyByType/applyAction'; -import ammo from './applyPropertyByType/applyItemAsAmmo' -import adjustment from './applyPropertyByType/applyAdjustment'; -import branch from './applyPropertyByType/applyBranch'; -import buff from './applyPropertyByType/applyBuff'; -import buffRemover from './applyPropertyByType/applyBuffRemover'; -import damage from './applyPropertyByType/applyDamage'; -import folder from './applyPropertyByType/applyFolder'; -import note from './applyPropertyByType/applyNote'; -import roll from './applyPropertyByType/applyRoll'; -import savingThrow from './applyPropertyByType/applySavingThrow'; -import toggle from './applyPropertyByType/applyToggle'; -import ActionContext from '/imports/api/engine/actions/ActionContext'; -import { TreeNode } from '/imports/api/parenting/parentingFunctions'; -import { CreatureProperty } from '/imports/api/creature/creatureProperties/CreatureProperties'; - -const applyPropertyByType = { - action, - ammo, - adjustment, - branch, - buff, - buffRemover, - damage, - folder, - note, - propertySlot: folder, - roll, - savingThrow, - spell: action, - toggle, -}; - -export default function applyProperty(node: TreeNode, actionContext: ActionContext, ...rest) { - if (node.doc.deactivatedByToggle) return; - actionContext.scope[`#${node.doc.type}`] = node.doc; - applyPropertyByType[node.doc.type]?.(node, actionContext, ...rest); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAction.ts b/app/imports/api/engine/actions/applyPropertyByType/applyAction.ts deleted file mode 100644 index a353047a..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAction.ts +++ /dev/null @@ -1,329 +0,0 @@ -import recalculateInlineCalculations from './shared/recalculateInlineCalculations'; -import recalculateCalculation from './shared/recalculateCalculation'; -import rollDice from '/imports/parser/rollDice'; -import applyProperty from '../applyProperty'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty'; -import numberToSignedString from '/imports/api/utility/numberToSignedString'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import { resetProperties } from '/imports/api/creature/creatures/methods/restCreature'; -import { TreeNode, hasAncestorRelationship } from '/imports/api/parenting/parentingFunctions'; -import { Action } from '/imports/api/properties/Actions'; -import { LogContent } from '/imports/api/creature/log/LogContentSchema'; -import { Item } from '/imports/api/properties/Items'; - -interface Ammo extends Item { - type: 'ammo' - adjustment: number -} - -export default async function applyAction(node: TreeNode, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const prop = node.doc; - if (prop.target === 'self') actionContext.targets = [actionContext.creature]; - const targets = actionContext.targets; - - // Log the name and summary - const content: LogContent = { name: prop.name, }; - if (prop.summary?.text) { - recalculateInlineCalculations(prop.summary, actionContext); - content.value = prop.summary.value; - } - if (!prop.silent) actionContext.addLog(content); - - // Spend the resources - const failed = await spendResources(prop, actionContext); - if (failed) return; - - const attack = prop.attackRoll; - - // Attack if there is an attack roll - if (attack && attack.calculation) { - if (targets.length) { - for (const target of targets) { - await applyAttackToTarget({ attack, target, actionContext }); - // Apply the children, but only to the current target - actionContext.targets = [target]; - await applyChildren(node, actionContext); - } - } else { - await applyAttackWithoutTarget({ attack, actionContext }); - await applyChildren(node, actionContext); - } - } else { - await applyChildren(node, actionContext); - } - if (prop.actionType === 'event' && prop.variableName) { - resetProperties(actionContext.creature._id, prop.variableName, actionContext); - } -} - -function applyAttackWithoutTarget({ attack, actionContext }) { - delete actionContext.scope['~attackHit']; - delete actionContext.scope['~attackMiss']; - delete actionContext.scope['~criticalHit']; - delete actionContext.scope['~criticalMiss']; - delete actionContext.scope['~attackRoll']; - - recalculateCalculation(attack, actionContext); - const scope = actionContext.scope; - const { - resultPrefix, - result, - criticalHit, - criticalMiss, - } = rollAttack(attack, scope); - let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit'; - if (scope['~attackAdvantage']?.value === 1) { - name += ' (Advantage)'; - } else if (scope['~attackAdvantage']?.value === -1) { - name += ' (Disadvantage)'; - } - if (!criticalMiss) { - scope['~attackHit'] = { value: true } - } - if (!criticalHit) { - scope['~attackMiss'] = { value: true }; - } - - actionContext.addLog({ - name, - value: `${resultPrefix}\n**${result}**`, - inline: true, - }); -} - -function applyAttackToTarget({ attack, target, actionContext }) { - const scope = actionContext.scope; - delete scope['~attackHit']; - delete scope['~attackMiss']; - delete scope['~criticalHit']; - delete scope['~criticalMiss']; - delete scope['~attackDiceRoll']; - delete scope['~attackRoll']; - - recalculateCalculation(attack, actionContext); - - const { - resultPrefix, - result, - criticalHit, - criticalMiss, - } = rollAttack(attack, scope); - - if (target.variables.armor) { - const armor = target.variables.armor.value; - - let name = criticalHit ? 'Critical Hit!' : - criticalMiss ? 'Critical Miss!' : - result > armor ? 'Hit!' : 'Miss!'; - if (scope['~attackAdvantage']?.value === 1) { - name += ' (Advantage)'; - } else if (scope['~attackAdvantage']?.value === -1) { - name += ' (Disadvantage)'; - } - - actionContext.addLog({ - name, - value: `${resultPrefix}\n**${result}**`, - inline: true, - }); - if (criticalMiss || result < armor) { - scope['~attackMiss'] = { value: true }; - } else { - scope['~attackHit'] = { value: true }; - } - } else { - actionContext.addLog({ - name: 'Error', - value: 'Target has no `armor`', - }); - actionContext.addLog({ - name: criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit', - value: `${resultPrefix}\n**${result}**`, - inline: true, - }); - } -} - -function rollAttack(attack, scope) { - const rollModifierText = numberToSignedString(attack.value, true); - let value, resultPrefix; - if (scope['~attackAdvantage']?.value === 1) { - const [a, b] = rollDice(2, 20); - if (a >= b) { - value = a; - resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; - } else { - value = b; - resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; - } - } else if (scope['~attackAdvantage']?.value === -1) { - const [a, b] = rollDice(2, 20); - if (a <= b) { - value = a; - resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`; - } else { - value = b; - resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`; - } - } else { - value = rollDice(1, 20)[0]; - resultPrefix = `1d20 [${value}] ${rollModifierText}` - } - scope['~attackDiceRoll'] = { value }; - const result = value + attack.value; - scope['~attackRoll'] = { value: result }; - const { criticalHit, criticalMiss } = applyCrits(value, scope); - return { resultPrefix, result, value, criticalHit, criticalMiss }; -} - -function applyCrits(value, scope) { - let scopeCrit = scope['~criticalHitTarget']?.value; - if (scopeCrit?.parseType === 'constant') { - scopeCrit = scopeCrit.value; - } - const criticalHitTarget = scopeCrit || 20; - const criticalHit = value >= criticalHitTarget; - let criticalMiss; - if (criticalHit) { - scope['~criticalHit'] = { value: true }; - } else { - criticalMiss = value === 1; - if (criticalMiss) { - scope['~criticalMiss'] = { value: true }; - } - } - return { criticalHit, criticalMiss }; -} - -async function spendResources(prop: Action, actionContext) { - // Check Uses - if (!prop.usesLeft || prop.usesLeft <= 0) { - if (!prop.silent) actionContext.addLog({ - name: 'Error', - value: `${prop.name || 'action'} does not have enough uses left`, - }); - return true; - } - // Resources - if (prop.insufficientResources) { - if (!prop.silent) actionContext.addLog({ - name: 'Error', - value: 'This creature doesn\'t have sufficient resources to perform this action', - }); - return true; - } - // Items - const spendLog: string[] = []; - const gainLog: string[] = []; - const ammoToApply: TreeNode[] = []; - try { - prop.resources.itemsConsumed.forEach(itemConsumed => { - recalculateCalculation(itemConsumed.quantity, actionContext); - if (!itemConsumed.itemId) { - throw 'No ammo was selected for this prop'; - } - const item = CreatureProperties.findOne(itemConsumed.itemId) as Item; - if (!item || item.root.id !== prop.root.id) { - throw 'The prop\'s ammo was not found on the creature'; - } - - if ( - !itemConsumed?.quantity?.value || - !isFinite(+itemConsumed.quantity.value) - ) return; - const quantityConsumed = +itemConsumed.quantity.value; - - let logName = item.name; - if (quantityConsumed > 1 || quantityConsumed < -1) { - logName = item.plural || logName; - } - if (quantityConsumed > 0) { - spendLog.push(logName + ': ' + quantityConsumed); - } else if (quantityConsumed < 0) { - gainLog.push(logName + ': ' + -quantityConsumed); - } - // So long as the item isn't an ancestor of the current prop apply it - // If it was an ancestor this would be an infinite loop - if (!hasAncestorRelationship(item, prop)) { - ammoToApply.push({ - doc: { - ...item, - // Use ammo pseudo-type - type: 'ammo', - // Store the adjustment to be applied - adjustment: quantityConsumed, - }, - children: [] - }); - } - }); - } catch (e) { - actionContext.addLog({ - name: 'Error', - value: e.toString(), - }); - console.error(e); - return true; - } - // No more errors should be thrown after this line - - // Use uses - if (prop.usesLeft) { - CreatureProperties.update(prop._id, { - $inc: { usesUsed: 1 } - }, { - //@ts-expect-error no typings for collection 2 selector - selector: prop - }); - if (!prop.silent) actionContext.addLog({ - name: 'Uses left', - value: prop.usesLeft - 1, - inline: true, - }); - } - - // Damage stats - prop.resources.attributesConsumed.forEach(attConsumed => { - recalculateCalculation(attConsumed.quantity, actionContext); - - if (!attConsumed.quantity?.value) return; - const quantityConsumed = +attConsumed.quantity.value; - if (!attConsumed.variableName) return; - const stat = actionContext.scope[attConsumed.variableName]; - if (!stat) { - spendLog.push(attConsumed.variableName + ': ' + ' not found'); - return; - } - damagePropertyWork({ - prop: stat, - operation: 'increment', - value: attConsumed.quantity.value, - actionContext, - }); - if (quantityConsumed > 0) { - spendLog.push(stat.name + ': ' + quantityConsumed); - } else if (quantityConsumed < 0) { - gainLog.push(stat.name + ': ' + -quantityConsumed); - } - }); - - // Apply the ammo children - for (const node of ammoToApply) { - await applyProperty(node, actionContext); - } - - // Log all the spending - if (gainLog.length && !prop.silent) actionContext.addLog({ - name: 'Gained', - value: gainLog.join('\n'), - inline: true, - }); - if (spendLog.length && !prop.silent) actionContext.addLog({ - name: 'Spent', - value: spendLog.join('\n'), - inline: true, - }); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js b/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js deleted file mode 100644 index e14265d5..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyAdjustment.js +++ /dev/null @@ -1,56 +0,0 @@ -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import recalculateCalculation from './shared/recalculateCalculation'; -import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; - -export default function applyAdjustment(node, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const prop = node.doc - const damageTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; - - if (!prop.amount) { - return applyChildren(node, actionContext); - } - - // Evaluate the amount - recalculateCalculation(prop.amount, actionContext); - - const value = +prop.amount.value; - if (!isFinite(value)) { - return applyChildren(node, actionContext); - } - - if (damageTargets?.length) { - damageTargets.forEach(target => { - let stat = target.variables[prop.stat]; - if (!stat?.type) { - if (!prop.silent) actionContext.addLog({ - name: 'Error', - value: `Could not apply attribute damage, creature does not have \`${prop.stat}\` set` - }); - return applyChildren(node, actionContext); - } - damagePropertyWork({ - prop: stat, - operation: prop.operation, - value, - actionContext, - }); - if (!prop.silent) actionContext.addLog({ - name: 'Attribute damage', - value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + - ` ${value}`, - inline: true, - }); - }); - } else { - if (!prop.silent) actionContext.addLog({ - name: 'Attribute damage', - value: `${prop.stat}${prop.operation === 'set' ? ' set to' : ''}` + - ` ${value}`, - inline: true, - }); - } - - return applyChildren(node, actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js b/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js deleted file mode 100644 index bf415327..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBranch.js +++ /dev/null @@ -1,95 +0,0 @@ -import applyProperty from '../applyProperty'; -import recalculateCalculation from './shared/recalculateCalculation'; -import rollDice from '/imports/parser/rollDice'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; - -export default async function applyBranch(node, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const scope = actionContext.scope; - const targets = actionContext.targets; - const prop = node.doc - switch (prop.branchType) { - case 'if': - recalculateCalculation(prop.condition, actionContext); - if (prop.condition?.value) applyChildren(node, actionContext); - break; - case 'index': - if (node.children.length) { - recalculateCalculation(prop.condition, actionContext); - if (!isFinite(prop.condition?.value)) { - actionContext.addLog({ - name: 'Branch Error', - value: 'Index did not resolve into a valid number' - }); - break; - } - let index = Math.floor(prop.condition?.value); - if (index < 1) index = 1; - if (index > node.children.length) index = node.children.length; - applyNodeTriggers(node, 'after', actionContext); - applyProperty(node.children[index - 1], actionContext); - applyNodeTriggers(node, 'afterChildren', actionContext); - } - break; - case 'hit': - if (scope['~attackHit']?.value) { - if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On hit**' }); - applyChildren(node, actionContext); - } - break; - case 'miss': - if (scope['~attackMiss']?.value) { - if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On miss**' }); - applyChildren(node, actionContext); - } - break; - case 'failedSave': - if (scope['~saveFailed']?.value) { - if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On failed save**' }); - applyChildren(node, actionContext); - } - break; - case 'successfulSave': - if (scope['~saveSucceeded']?.value) { - if (!targets.length && !prop.silent) actionContext.addLog({ value: '**On save**', }); - applyChildren(node, actionContext); - } - break; - case 'random': - if (node.children.length) { - let index = rollDice(1, node.children.length)[0] - 1; - applyNodeTriggers(node, 'after', actionContext); - applyProperty(node.children[index], actionContext); - applyNodeTriggers(node, 'afterChildren', actionContext); - } - break; - case 'eachTarget': - if (targets.length) { - targets.forEach(target => { - applyNodeTriggers(node, 'after', actionContext); - actionContext.targets = [target] - node.children.forEach(child => applyProperty(child, actionContext)); - applyNodeTriggers(node, 'afterChildren', actionContext); - }); - } else { - applyChildren(node, actionContext); - } - break; - case 'choice': { - console.log('paused waiting for user input'); - let { index } = await getUserInput({ - index: 'number', - }, actionContext); - console.log('resuming with input ' + index); - if (!isFinite(index) || index < 0) index = 0; - if (index > node.children.length - 1) index = node.children.length - 1; - applyNodeTriggers(node, 'after', actionContext); - console.log('applying child ', index); - console.log(node.children[index]); - applyProperty(node.children[index], actionContext); - applyNodeTriggers(node, 'afterChildren', actionContext); - break; - } - } -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js b/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js deleted file mode 100644 index 6455835a..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyBuff.js +++ /dev/null @@ -1,169 +0,0 @@ -import { - renewDocIds, -} from '/imports/api/parenting/parentingFunctions'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import computedSchemas from '/imports/api/properties/computedPropertySchemasIndex'; -import applyFnToKey from '/imports/api/engine/computation/utility/applyFnToKey'; -import { get } from 'lodash'; -import resolve, { map, toString } from '/imports/parser/resolve'; -import accessor from '/imports/parser/parseTree/accessor'; -import logErrors from './shared/logErrors'; -import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs'; -import cyrb53 from '/imports/api/engine/computation/utility/cyrb53'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX'; -import recalculateInlineCalculations from './shared/recalculateInlineCalculations'; - -export default function applyBuff(node, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const prop = node.doc - let buffTargets = prop.target === 'self' ? [actionContext.creature] : actionContext.targets; - - // Mark the buff as dirty for recalculation - prop.dirty = true; - - // Then copy the descendants of the buff to the targets - let propList = [prop]; - function addChildrenToPropList(children, { skipCrystalize } = { skipCrystalize: false }) { - children.forEach(child => { - if (skipCrystalize) child.node._skipCrystalize = true; - propList.push(child.node); - // recursively add the child's children, but don't crystalize nested buffs - addChildrenToPropList(child.children, { - skipCrystalize: skipCrystalize || child.node.type === 'buff' - }); - }); - } - addChildrenToPropList(node.children); - if (!prop.skipCrystalization) { - crystalizeVariables({ propList, actionContext }); - } - - buffTargets.forEach(target => { - const targetPropList = EJSON.clone(propList); - // Move the properties to the target by replacing the old subtree parent and root with the ' - // target id - renewDocIds({ - docArray: targetPropList, - idMap: { - [prop.parentId]: target._id, - [prop.root.id]: target._id, - }, - collectionMap: { [prop.root.collection]: 'creatures' } - }); - // Apply the buff - CreatureProperties.batchInsert(targetPropList); - - //Log the buff - let logValue = prop.description?.value - if (prop.description?.text) { - recalculateInlineCalculations(prop.description, actionContext); - logValue = prop.description?.value; - } - if ((prop.name || prop.description?.value) && !prop.silent) { - if (target._id === actionContext.creature._id) { - // Targeting self - actionContext.addLog({ - name: prop.name, - value: logValue, - }); - } else { - // Targeting other - insertCreatureLog.call({ - log: { - creatureId: target._id, - content: [{ - name: prop.name, - value: logValue, - }], - } - }); - } - } - }); - applyNodeTriggers(node, 'after', actionContext); - applyNodeTriggers(node, 'afterChildren', actionContext); - - // Don't apply the children of the buff, they get copied to the target instead -} - -/** - * Replaces all variables with their resolved values - * except variables of the form `~target.thing.total` become `thing.total` - */ -function crystalizeVariables({ propList, actionContext }) { - propList.forEach(prop => { - if (prop._skipCrystalize) { - delete prop._skipCrystalize; - return; - } - // Iterate through all the calculations and crystalize them - computedSchemas[prop.type].computedFields().forEach(calcKey => { - applyFnToKey(prop, calcKey, (prop, key) => { - const calcObj = get(prop, key); - if (!calcObj?.parseNode) return; - calcObj.parseNode = map(calcObj.parseNode, node => { - // Skip nodes that aren't symbols or accessors - if ( - node.parseType !== 'accessor' - ) return node; - // Handle variables - if (node.name === '~target') { - // strip ~target - if (node.parseType === 'accessor') { - node.name = node.path.shift(); - if (!node.path.length) { - return accessor.create({ name: node.name }) - } - } else { - // Can't strip symbols - actionContext.addLog({ - name: 'Error', - value: 'Variable `~target` should not be used without a property: ~target.property', - }); - } - return node; - } else { - // Resolve all other variables - const { result, context } = resolve('reduce', node, actionContext.scope); - logErrors(context.errors, actionContext); - return result; - } - }); - calcObj.calculation = toString(calcObj.parseNode); - calcObj.hash = cyrb53(calcObj.calculation); - }); - }); - // For each key in the schema - computedSchemas[prop.type].inlineCalculationFields().forEach(calcKey => { - // That ends in .inlineCalculations - applyFnToKey(prop, calcKey, (prop, key) => { - const inlineCalcObj = get(prop, key); - if (!inlineCalcObj) return; - - // If there is no text, skip - if (!inlineCalcObj.text) { - return; - } - - // Replace all the existing calculations - let index = -1; - inlineCalcObj.text = inlineCalcObj.text.replace(INLINE_CALCULATION_REGEX, () => { - index += 1; - return `{${inlineCalcObj.inlineCalculations[index].calculation}}`; - }); - - // Set the value to the uncomputed string - inlineCalcObj.value = inlineCalcObj.text; - - // Write a new hash - const inlineCalcHash = cyrb53(inlineCalcObj.text); - if (inlineCalcHash === inlineCalcObj.hash) { - // Skip if nothing changed - return; - } - inlineCalcObj.hash = inlineCalcHash; - }); - }); - }); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js b/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js deleted file mode 100644 index 19f2c5f2..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyFolder.js +++ /dev/null @@ -1,9 +0,0 @@ -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; - -export default function applyFolder(node, actionContext) { - // Apply triggers - applyNodeTriggers(node, 'before', actionContext); - // Apply children - applyChildren(node, actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyItemAsAmmo.js b/app/imports/api/engine/actions/applyPropertyByType/applyItemAsAmmo.js deleted file mode 100644 index 9f25fd7c..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyItemAsAmmo.js +++ /dev/null @@ -1,42 +0,0 @@ -import { getPropertyDescendants } from '/imports/api/engine/loadCreatures'; -import applyProperty from '../applyProperty'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import { docsToForest as nodeArrayToTree } from '/imports/api/parenting/parentingFunctions'; -import { adjustQuantityWork } from '/imports/api/creature/creatureProperties/methods/adjustQuantity'; - -export default function applyItemAsAmmo(node, actionContext) { - // The item node should come without children, since it is not part of the original action tree - const prop = node.doc - // Get all the item's descendant properties - const properties = getPropertyDescendants(actionContext.creature._id, prop._id); - properties.sort((a, b) => a.order - b.order); - const propertyForest = nodeArrayToTree(properties); - - // Apply the item - applyNodeTriggers(node, 'before', actionContext); - - // Do the quantity adjustment - const itemProp = { ...prop, type: 'item' }; - delete itemProp.adjustment; - adjustQuantityWork({ - property: itemProp, - operation: 'increment', - value: prop.adjustment, - }); - - // Simulate the change to quantity - prop.quantity -= prop.adjustment; - - // Log the item name as a heading if it's not silent and has child properties to apply - if (!prop.silent && propertyForest.length) { - actionContext.addLog({ - name: prop.name || 'Ammo', - inline: false, - }); - } - applyNodeTriggers(node, 'after', actionContext); - - // Apply the item's children - propertyForest.forEach(node => applyProperty(node, actionContext)); - applyNodeTriggers(node, 'afterChildren', actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyNote.js b/app/imports/api/engine/actions/applyPropertyByType/applyNote.js deleted file mode 100644 index 127b5c84..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyNote.js +++ /dev/null @@ -1,25 +0,0 @@ -import recalculateInlineCalculations from './shared/recalculateInlineCalculations'; -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; - -export default async function applyNote(node, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const prop = node.doc - - // Log Name, summary - let content = { name: prop.name }; - if (prop.summary?.text) { - recalculateInlineCalculations(prop.summary, actionContext); - content.value = prop.summary.value; - } - if (content.name || content.value) { - actionContext.addLog(content); - } - // Log description - if (prop.description?.text) { - recalculateInlineCalculations(prop.description, actionContext); - actionContext.addLog({ value: prop.description.value }); - } - // Apply children - await applyChildren(node, actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js b/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js deleted file mode 100644 index 63185db0..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/applyRoll.js +++ /dev/null @@ -1,56 +0,0 @@ -import applyChildren from '/imports/api/engine/actions/applyPropertyByType/shared/applyChildren'; -import logErrors from './shared/logErrors'; -import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'; -import resolve, { toString } from '/imports/parser/resolve'; -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; - -export default function applyRoll(node, actionContext) { - applyNodeTriggers(node, 'before', actionContext); - const prop = node.doc - - if (prop.roll?.calculation) { - const logValue = []; - - // roll the dice only and store that string - recalculateCalculation(prop.roll, actionContext, undefined, 'compile'); - const { result: rolled, context } = resolve('roll', prop.roll.valueNode, actionContext.scope); - if (rolled.parseType !== 'constant') { - logValue.push(toString(rolled)); - } - logErrors(context.errors, actionContext); - - // Reset the errors so we don't log the same errors twice - context.errors = []; - - // Resolve the roll to a final value - const { result: reduced } = resolve('reduce', rolled, actionContext.scope, context); - logErrors(context.errors, actionContext); - - // Store the result - if (reduced.parseType === 'constant') { - prop.roll.value = reduced.value; - } else if (reduced.parseType === 'error') { - prop.roll.value = null; - } else { - prop.roll.value = toString(reduced); - } - - // If we didn't end up with a constant or a number of finite value, give up - if (reduced?.parseType !== 'constant' || (reduced.valueType === 'number' && !isFinite(reduced.value))) { - return applyChildren(node, actionContext); - } - const value = reduced.value; - - actionContext.scope[prop.variableName] = { value }; - logValue.push(`**${value}**`); - - if (!prop.silent) { - actionContext.addLog({ - name: prop.name, - value: logValue.join('\n'), - inline: true, - }); - } - } - return applyChildren(node, actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/applyChildren.js b/app/imports/api/engine/actions/applyPropertyByType/shared/applyChildren.js deleted file mode 100644 index 4d0aae6b..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/applyChildren.js +++ /dev/null @@ -1,10 +0,0 @@ -import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers'; -import applyProperty from '/imports/api/engine/actions/applyProperty'; - -export default async function applyChildren(node, actionContext) { - applyNodeTriggers(node, 'after', actionContext); - for (const child of node.children) { - await applyProperty(child, actionContext); - } - applyNodeTriggers(node, 'afterChildren', actionContext); -} diff --git a/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js b/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js deleted file mode 100644 index 9ea760e6..00000000 --- a/app/imports/api/engine/actions/applyPropertyByType/shared/logErrors.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function logErrors(errors, actionContext){ - errors?.forEach(error => { - if (error.type !== 'info'){ - actionContext.addLog({name: 'Error', value: error.message}); - } - }); -} diff --git a/app/imports/api/engine/actions/applyTriggers.testFn.js b/app/imports/api/engine/actions/applyTriggers.testFn.js deleted file mode 100644 index ad484232..00000000 --- a/app/imports/api/engine/actions/applyTriggers.testFn.js +++ /dev/null @@ -1,67 +0,0 @@ -import { triggerMatchTags } from '/imports/api/engine/actions/applyTriggers'; -import clean from '/imports/api/engine/computation/utility/cleanProp.testFn'; -import { assert } from 'chai'; - -export default function () { - const prop = clean({ - id: 'propWithTags', - type: 'action', - tags: ['yes1', 'notUsed', 'no1', 'yes2', 'no2', 'or1', 'or2'], - }); - const positiveProp = clean({ - id: 'propWithTags', - type: 'action', - tags: ['yes1', 'notUsed', 'yes2', 'or1', 'or2'], - }); - assert.isTrue( - triggerMatchTags(clean({ - type: 'trigger', - targetTags: ['yes1'], - }), prop), - 'Trigger matches on a single target tag' - ); - assert.isTrue( - triggerMatchTags(clean({ - type: 'trigger', - targetTags: ['yes1', 'yes2'], - }), prop), - 'Trigger matches on a multiple target tags' - ); - assert.isFalse( - triggerMatchTags(clean({ - type: 'trigger', - targetTags: ['yes1'], - extraTags: [{ operation: 'NOT', tags: ['no1'] }] - }), prop), - 'Trigger correctly fails to match when not tags are present' - ); - assert.isFalse( - triggerMatchTags(clean({ - type: 'trigger', - extraTags: [{ operation: 'NOT', tags: ['no1'] }] - }), prop), - 'Trigger correctly fails to match when only not tags are present' - ); - assert.isTrue( - triggerMatchTags(clean({ - type: 'trigger', - extraTags: [{ operation: 'NOT', tags: ['no1'] }] - }), positiveProp), - 'Trigger matches when only not tags are present' - ); - assert.isTrue( - triggerMatchTags(clean({ - type: 'trigger', - extraTags: [{ operation: 'OR', tags: ['or1'] }] - }), positiveProp), - 'Trigger matches when OR tags are present' - ); - assert.isTrue( - triggerMatchTags(clean({ - type: 'trigger', - targetTags: ['missing1'], - extraTags: [{ operation: 'OR', tags: ['or1'] }] - }), positiveProp), - 'Trigger matches when only OR tags are present' - ); -} \ No newline at end of file diff --git a/app/imports/api/engine/actions/applyTriggers.ts b/app/imports/api/engine/actions/applyTriggers.ts deleted file mode 100644 index 143ea5af..00000000 --- a/app/imports/api/engine/actions/applyTriggers.ts +++ /dev/null @@ -1,114 +0,0 @@ -import recalculateCalculation from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateCalculation'; -import recalculateInlineCalculations from '/imports/api/engine/actions/applyPropertyByType/shared/recalculateInlineCalculations'; -import { getPropertyDescendants } from '/imports/api/engine/loadCreatures'; -import { TreeNode, docsToForest as nodeArrayToTree } from '/imports/api/parenting/parentingFunctions'; -import applyProperty from '/imports/api/engine/actions/applyProperty'; -import { difference, intersection } from 'lodash'; -import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags'; - -export async function applyNodeTriggers(node: TreeNode, timing, actionContext) { - const prop = node.doc; - const type = prop.type; - const triggers = actionContext.triggers?.doActionProperty?.[type]?.[timing]; - if (triggers) { - for (const trigger of triggers) { - await applyTrigger(trigger, prop, actionContext); - } - } -} - -export async function applyTriggers(triggers = [], prop, actionContext) { - // Apply the triggers - for (const trigger of triggers) { - await applyTrigger(trigger, prop, actionContext) - } -} - -export async function applyTrigger(trigger, prop, actionContext) { - // If there is a prop we are applying the trigger from, - // don't fire if the tags don't match - if (prop && !triggerMatchTags(trigger, prop)) { - return; - } - - // Prevent trigger from firing if it's inactive - if (trigger.inactive) { - return; - } - - // Prevent triggers from firing if their condition is false - if (trigger.condition?.parseNode) { - recalculateCalculation(trigger.condition, actionContext); - if (!trigger.condition.value?.value) return; - } - - // Prevent triggers from firing themselves in a loop - if (trigger.firing) { - /* - log.content.push({ - name: trigger.name || 'Trigger', - value: 'Trigger can\'t fire itself', - inline: true, - }); - */ - return; - } - trigger.firing = true; - - // Fire the trigger - const content = { - name: trigger.name || 'Trigger', - value: trigger.description, - inline: false, - } - if (trigger.description?.text) { - recalculateInlineCalculations(trigger.description, actionContext); - content.value = trigger.description.value; - } - if (!trigger.silent) actionContext.addLog(content); - - // Get all the trigger's properties and apply them - const properties = getPropertyDescendants(actionContext.creature._id, trigger._id); - properties.sort((a, b) => a.order - b.order); - const propertyForest = nodeArrayToTree(properties); - for (const node of propertyForest) { - await applyProperty(node, actionContext); - } - - trigger.firing = false; -} - -export function triggerMatchTags(trigger, prop) { - let matched = false; - const propTags = getEffectivePropTags(prop); - // Check the target tags - if ( - !trigger.targetTags?.length || - difference(trigger.targetTags, propTags).length === 0 - ) { - matched = true; - } - // Check the extra tags - if (trigger.extraTags) { - for (const extra of trigger.extraTags) { - if (extra.operation === 'OR') { - if (matched) break; - if ( - !extra.tags.length || - difference(extra.tags, propTags).length === 0 - ) { - matched = true; - } - } else if (extra.operation === 'NOT') { - if ( - extra.tags.length && - intersection(extra.tags, propTags).length > 0 - ) { - matched = false; - break; - } - } - } - } - return matched; -} diff --git a/app/imports/api/engine/actions/doAction.js b/app/imports/api/engine/actions/doAction.js deleted file mode 100644 index 4a7c5b06..00000000 --- a/app/imports/api/engine/actions/doAction.js +++ /dev/null @@ -1,111 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions'; -import { docsToForest } from '/imports/api/parenting/parentingFunctions'; -import { - getPropertyAncestors, getPropertyDescendants -} from '/imports/api/engine/loadCreatures'; -import Creatures from '/imports/api/creature/creatures/Creatures'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import applyProperty from './applyProperty'; -import ActionContext from '/imports/api/engine/actions/ActionContext'; - -const doAction = new ValidatedMethod({ - name: 'creatureProperties.doAction', - validate: new SimpleSchema({ - actionId: SimpleSchema.RegEx.Id, - targetIds: { - type: Array, - defaultValue: [], - maxCount: 20, - optional: true, - }, - 'targetIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - scope: { - type: Object, - blackbox: true, - optional: true, - }, - invocationId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - optional: true, - } - }).validator(), - applyOptions: { - throwStubExceptions: false, - }, - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 10, - timeInterval: 5000, - }, - async run({ actionId, targetIds = [], scope, invocationId }) { - console.log('do Action running'); - // Get action context - const action = CreatureProperties.findOne(actionId); - if (!action) throw new Meteor.Error('not-found', 'The action was not found'); - const creatureId = action.root.id; - const actionContext = new ActionContext(creatureId, targetIds, this); - - // Check permissions - assertEditPermission(actionContext.creature, this.userId); - actionContext.targets.forEach(target => { - assertEditPermission(target, this.userId); - }); - - const ancestors = getPropertyAncestors(creatureId, action._id); - ancestors.sort((a, b) => a.order - b.order); - - const properties = getPropertyDescendants(creatureId, action._id); - properties.push(action); - properties.sort((a, b) => a.order - b.order); - - // Do the action - await doActionWork({ properties, ancestors, actionContext, methodScope: scope }); - - // Recompute all involved creatures - if (Meteor.isServer) { - Creatures.updateAsync({ - _id: { $in: [creatureId, ...targetIds] } - }, { - $set: { dirty: true }, - }); - } - }, -}); - -export default doAction; - -export async function doActionWork({ - properties, ancestors, actionContext, methodScope = {}, -}) { - // get the docs - const ancestorScope = getAncestorScope(ancestors); - const propertyForest = docsToForest(properties); - if (propertyForest.length !== 1) { - throw new Meteor.Error(`The action has ${propertyForest.length} top level properties, expected 1`); - } - - // Include the ancestry and method scope in the context scope - Object.assign(actionContext.scope, ancestorScope, methodScope); - - // Apply the top level property, it is responsible for applying its children - // recursively - await applyProperty(propertyForest[0], actionContext); - // Insert the log - actionContext.writeLog(); -} - -// Assumes ancestors are in tree order already -function getAncestorScope(ancestors) { - const scope = {}; - ancestors.forEach(prop => { - scope[`#${prop.type}`] = prop; - }); - return scope; -} diff --git a/app/imports/api/engine/actions/doAction.test.js b/app/imports/api/engine/actions/doAction.test.js deleted file mode 100644 index 8b28545c..00000000 --- a/app/imports/api/engine/actions/doAction.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import '/imports/api/simpleSchemaConfig'; -//import testTypes from './testTypes/index'; -import applyTriggers from '/imports/api/engine/actions/applyTriggers.testFn'; -import { doActionWork } from './doAction'; -import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import Creatures from '/imports/api/creature/creatures/Creatures'; - -function cleanProp(prop) { - let schema = CreatureProperties.simpleSchema(prop); - return schema.clean(prop); -} - -function cleanCreature(creature) { - let schema = Creatures.simpleSchema(creature); - return schema.clean(creature); -} - -// Fake ActionContext to test actions with -const creatureId = 'actionTestCreatureId'; -const creatureName = 'Action Test Creature'; -const testActionContext = { - creature: cleanCreature({ - _id: creatureId, - }), - log: CreatureLogSchema.clean({ - creatureId: creatureId, - creatureName: creatureName, - }), - scope: {}, - addLog(content) { - if (content.name || content.value) { - this.log.content.push(content); - } - }, - writeLog: () => { }, -} - -const action = cleanProp({ - type: 'action', -}); -const actionAncestors = []; - -describe('Do Action', function () { - it('Does an empty action', function () { - doActionWork({ - properties: [action], - ancestors: actionAncestors, - actionContext: testActionContext, - methodScope: {}, - }); - }); - //testTypes.forEach(test => it(test.text, test.fn)); -}); - -describe('Action utility functions', function () { - it('Triggers match tags', applyTriggers); -}) diff --git a/app/imports/api/engine/actions/getUserInput.js b/app/imports/api/engine/actions/getUserInput.js deleted file mode 100644 index c9a7134c..00000000 --- a/app/imports/api/engine/actions/getUserInput.js +++ /dev/null @@ -1,90 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import { set } from 'lodash'; - -// Reminder: throwStubExceptions: true is the default, and only -// possible when run() is not async -// For async run() stub exceptions never stop the client from sending -// the call to the server - -// Dict of invocationId: {steps: {earlyAnswers, resolve, reject}} -// either resolve functions waiting for the user's input or early answers that were provided -// before the resolves could be set up -let userInputRequests = {}; -let provideUserInput; - -if (Meteor.isClient) { - provideUserInput = function (invocationId, step, answers, callback) { - Meteor.call('answerUserInputRequest', { invocationId, step, answers }, callback); - // Do the same work on the client without using a stub - answerInputRequestWork({ invocationId, step, answers }); - } -} - -export { userInputRequests, provideUserInput }; - -export default async function getUserInput(questions, actionContext) { - // get the invocation details from the action context - const invocationId = actionContext.invocationId; - const step = actionContext.userInputStep; - actionContext.userInputStep += 1; // increment userInput step every time - - // If the answers are already waiting, just return them - if (userInputRequests[invocationId]?.[step]?.earlyAnswers) { - return userInputRequests[invocationId][step].earlyAnswers; - } - // On the client, store the questions to be answered - if (Meteor.isClient) { - set(userInputRequests, `${invocationId}[${step}]`, { questions }); - } - // Create a place for the answers to go when they are provided - return new Promise((resolve, reject) => { - set(userInputRequests, `${invocationId}[${step}]`, { resolve, reject }); - }); -} - -function answerInputRequestWork({ invocationId, step, answers }) { - console.log('running answerUserInputRequest'); - const invocation = userInputRequests[invocationId]; - if (!invocation) { - // Call order on the server is guaranteed, so the invocation must have been created - // Before we can update it - throw new Meteor.Error('Not found', 'The method this answer is updating does not exist'); - } - if (invocation[step]?.resolve) { - // If there is a resolve waiting for this response, provide it - invocation[step].resolve(answers); - } else { - // Otherwise just store the response as early answers - invocation[step] = { - earlyAnswers: answers - }; - } -} - -if (Meteor.isServer) { - // This function is not defined on the client so that it has no stub function - // This allows it to be called while still simulating an awaited async method - // See https://guide.meteor.com/2.8-migration.html#the-limitations - new ValidatedMethod({ - name: 'answerUserInputRequest', - validate: new SimpleSchema({ - invocationId: SimpleSchema.RegEx.Id, - step: SimpleSchema.Integer, - answers: { - type: Object, - blackbox: true, - }, - }).validator(), - applyOptions: { - throwStubExceptions: false, - }, - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 20, - timeInterval: 5000, - }, - run: answerInputRequestWork, - }); -} diff --git a/app/imports/api/engine/actions/index.js b/app/imports/api/engine/actions/index.js deleted file mode 100644 index 91e94fcf..00000000 --- a/app/imports/api/engine/actions/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './doCastSpell'; -import './doCheck'; diff --git a/app/imports/api/engine/loadCreatures.ts b/app/imports/api/engine/loadCreatures.ts index 175c4831..d23dcdd1 100644 --- a/app/imports/api/engine/loadCreatures.ts +++ b/app/imports/api/engine/loadCreatures.ts @@ -163,12 +163,13 @@ export function getPropertyDescendants(creatureId, propertyId) { if ( prop.left > property.left && prop.right < property.right + && prop.removed !== true ) { props.push(prop); } } - const cloneProps = EJSON.clone(props); - return cloneProps + const cloneProps = EJSON.clone(props).sort((a, b) => a.left - b.left); + return cloneProps; } else { return CreatureProperties.find({ ...getFilter.descendants(property), diff --git a/app/imports/api/parenting/parentingFunctions.ts b/app/imports/api/parenting/parentingFunctions.ts index 1563379d..1623961a 100644 --- a/app/imports/api/parenting/parentingFunctions.ts +++ b/app/imports/api/parenting/parentingFunctions.ts @@ -315,13 +315,13 @@ export function renewDocIds({ docArray, collectionMap = {}, idMap = {} }) { // Give new ids and map the changes as {oldId: newId} docArray.forEach(doc => { const oldId = doc._id; - const newId = idMap[oldId] || randomSrc.id(); + const newId = oldId in idMap ? idMap[oldId] : randomSrc.id(); doc._id = newId; idMap[oldId] = newId; }); // Get the id from the map if it exists, leave unchanged otherwise - const remap = id => idMap[id] || id + const remap = id => id in idMap ? idMap[id] : id // If there are references by id that need to be maintained when copying from // a library, here is where we would update them diff --git a/app/imports/api/utility/getPropertyTitle.ts b/app/imports/api/utility/getPropertyTitle.ts new file mode 100644 index 00000000..e76f9098 --- /dev/null +++ b/app/imports/api/utility/getPropertyTitle.ts @@ -0,0 +1,6 @@ +import { getPropertyName } from '/imports/constants/PROPERTIES'; + +export default function getPropertyTitle(prop) { + if (prop.name) return prop.name; + return getPropertyName(prop.type); +} diff --git a/app/imports/client/ui/properties/components/actions/ActionCard.vue b/app/imports/client/ui/properties/components/actions/ActionCard.vue index 1ce3c0c1..5b682afb 100644 --- a/app/imports/client/ui/properties/components/actions/ActionCard.vue +++ b/app/imports/client/ui/properties/components/actions/ActionCard.vue @@ -117,7 +117,7 @@ \ No newline at end of file +../../../../../api/engine/action/methods/doCastSpell \ No newline at end of file diff --git a/app/imports/client/ui/properties/components/skills/SkillListTile.vue b/app/imports/client/ui/properties/components/skills/SkillListTile.vue index f3a6766e..82955b53 100644 --- a/app/imports/client/ui/properties/components/skills/SkillListTile.vue +++ b/app/imports/client/ui/properties/components/skills/SkillListTile.vue @@ -138,3 +138,4 @@ export default { color: rgba(0, 0, 0, 0.54) !important; } +../../../../../api/engine/action/methods/doCheck \ No newline at end of file diff --git a/app/imports/client/ui/properties/viewers/ActionViewer.vue b/app/imports/client/ui/properties/viewers/ActionViewer.vue index 012475c0..1ced60f3 100644 --- a/app/imports/client/ui/properties/viewers/ActionViewer.vue +++ b/app/imports/client/ui/properties/viewers/ActionViewer.vue @@ -119,7 +119,7 @@