From 804c5f3aee08ac5587284d4cb5b9571f8d0becf3 Mon Sep 17 00:00:00 2001 From: ThaumRystra Date: Sun, 27 Oct 2024 12:51:48 +0200 Subject: [PATCH] Started implementing spells in action engine --- .vscode/settings.json | 6 +- .../applyProperties/applySpellProperty.ts | 82 +++++++++++ .../functions/userInput/InputProvider.ts | 10 ++ .../getReplayChoicesInputProvider.ts | 3 + .../userInput/inputProviderForTests.testFn.ts | 5 +- .../api/engine/action/methods/doCastSpell.js | 138 ------------------ .../api/engine/action/methods/runAction.ts | 23 ++- app/imports/api/properties/Spells.ts | 6 +- .../spells/CastSpellWithSlotDialog.vue | 5 +- 9 files changed, 131 insertions(+), 147 deletions(-) create mode 100644 app/imports/api/engine/action/applyProperties/applySpellProperty.ts delete mode 100644 app/imports/api/engine/action/methods/doCastSpell.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ec4381c..e211d1a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,9 @@ "armor", "autorun", "blackbox", + "cantrip", + "Cantrips", + "Crit", "Crits", "cyrb", "denormalize", @@ -19,6 +22,7 @@ "Ruleset", "snackbars", "Spellcasting", + "Subheaders", "thumbhash", "uncomputed", "untarget", @@ -26,4 +30,4 @@ "Vuex", "walkdown" ] -} \ No newline at end of file +} diff --git a/app/imports/api/engine/action/applyProperties/applySpellProperty.ts b/app/imports/api/engine/action/applyProperties/applySpellProperty.ts new file mode 100644 index 00000000..2d467d55 --- /dev/null +++ b/app/imports/api/engine/action/applyProperties/applySpellProperty.ts @@ -0,0 +1,82 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import { PropTask } from '/imports/api/engine/action/tasks/Task'; +import TaskResult from '../tasks/TaskResult'; +import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; +import { getPropertiesOfType, getSingleProperty } from '/imports/api/engine/loadCreatures'; +import applyTask from '/imports/api/engine/action/tasks/applyTask'; +import applyActionProperty from './applyActionProperty'; + +export default async function applyFolderProperty( + task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider +): Promise { + let prop = task.prop; + // Ask the user how this spell is being cast + const castOptions = await userInput.castSpell({ + spellId: prop?._id, + slotId: prop?.castWithoutSpellSlots + ? undefined + : getSuggestedSpellSlotId(action.creatureId, prop), + ritual: false, + }); + // If the user changed the spell they are casting, use that as the prop + prop = getSingleProperty(action.creatureId, castOptions.spellId); + let slotLevel = prop.level || 0; + // Get the slot being cast with + const slot = castOptions.slotId && getSingleProperty(action.creatureId, castOptions.slotId); + // Log casting method + logCastingMessage(slot?.spellSlotLevel?.value, castOptions, result, prop, task.targetIds); + // Spend the spell slot and change the spell's casting level if a slot is used + if (slot) { + await spendSpellSlot(action, prop, castOptions, userInput); + slotLevel = slot.spellSlotLevel?.value || 0; + } + // Add the slot level to the scope + result.pushScope = { + '~slotLevel': { value: slotLevel }, + 'slotLevel': { value: slotLevel }, + }; + // Run the rest of the spell as if it were an action + return applyActionProperty(task, action, result, userInput); +} + +function getSuggestedSpellSlotId(creatureId, prop) { + if (!prop) return; + const slots = getPropertiesOfType(creatureId, 'spellSlot') + .sort((a, b) => a.spellSlotLevel?.value - b.spellSlotLevel?.value) + .filter(slot => slot.spellSlotLevel.value > prop.level); + return slots[0]?._id; +} + +function logCastingMessage(slotLevel: number, castOptions, result: TaskResult, prop, targetIds: string[]) { + let message = ''; + // Determine which message to post + if (slotLevel) { + message = `Casting using a level ${slotLevel} spell slot` + } else if (prop.level) { + if (castOptions.ritual) { + message = `Ritual casting at level ${slotLevel}` + } else { + message = `Casting at level ${slotLevel}` + } + } + // Post the message + if (message) { + result.appendLog({ + name: `Casting at level ${slotLevel}` + }, targetIds); + } +} + +function spendSpellSlot(action, prop, castOptions, userInput) { + const slot = getSingleProperty(action.creatureId, castOptions.slotId); + return applyTask(action, { + prop, + targetIds: [action.creatureId], + subtaskFn: 'damageProp', + params: { + operation: 'increment', + value: 1, + targetProp: slot, + }, + }, userInput); +} diff --git a/app/imports/api/engine/action/functions/userInput/InputProvider.ts b/app/imports/api/engine/action/functions/userInput/InputProvider.ts index 9d564b7e..8a5366fc 100644 --- a/app/imports/api/engine/action/functions/userInput/InputProvider.ts +++ b/app/imports/api/engine/action/functions/userInput/InputProvider.ts @@ -35,6 +35,10 @@ type InputProvider = { * Get the details of a check or save */ check(suggestedParams: CheckParams): Promise; + /** + * Get the details of casting a spell + */ + castSpell(suggestedParams: CastSpellParams): Promise; } export type Advantage = 0 | 1 | -1; @@ -49,4 +53,10 @@ export type CheckParams = { targetAbilityVariableName?: string; } +export type CastSpellParams = { + spellId: string, + slotId: string | undefined, + ritual: boolean, +} + export default InputProvider; diff --git a/app/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider.ts b/app/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider.ts index ae6a706b..f67de791 100644 --- a/app/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider.ts +++ b/app/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider.ts @@ -28,6 +28,9 @@ export default function getReplayChoicesInputProvider(actionId: string, decision check() { return Promise.resolve(decisionStack.pop()); }, + castSpell() { + return Promise.resolve(decisionStack.pop()); + }, } return replaySavedInput; } diff --git a/app/imports/api/engine/action/functions/userInput/inputProviderForTests.testFn.ts b/app/imports/api/engine/action/functions/userInput/inputProviderForTests.testFn.ts index c70e31c8..dec48a5d 100644 --- a/app/imports/api/engine/action/functions/userInput/inputProviderForTests.testFn.ts +++ b/app/imports/api/engine/action/functions/userInput/inputProviderForTests.testFn.ts @@ -41,7 +41,10 @@ const inputProviderForTests: InputProvider = { }, async check(suggestedParams) { return suggestedParams; - } + }, + async castSpell(suggestedParams) { + return suggestedParams; + }, } export default inputProviderForTests; diff --git a/app/imports/api/engine/action/methods/doCastSpell.js b/app/imports/api/engine/action/methods/doCastSpell.js deleted file mode 100644 index f15696cb..00000000 --- a/app/imports/api/engine/action/methods/doCastSpell.js +++ /dev/null @@ -1,138 +0,0 @@ -import SimpleSchema from 'simpl-schema'; -import { ValidatedMethod } from 'meteor/mdg:validated-method'; -import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; -import Creatures from '/imports/api/creature/creatures/Creatures'; -import { - getPropertyAncestors, getPropertyDescendants -} from '/imports/api/engine/loadCreatures'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; -import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions'; - -// TODO Migrate this to the new action engine - -const doAction = new ValidatedMethod({ - name: 'creatureProperties.doCastSpell', - validate: new SimpleSchema({ - spellId: SimpleSchema.RegEx.Id, - slotId: { - type: String, - regEx: SimpleSchema.RegEx.Id, - optional: true, - }, - ritual: { - type: Boolean, - optional: true, - }, - targetIds: { - type: Array, - defaultValue: [], - maxCount: 20, - optional: true, - }, - 'targetIds.$': { - type: String, - regEx: SimpleSchema.RegEx.Id, - }, - scope: { - type: Object, - blackbox: true, - optional: true, - }, - }).validator(), - mixins: [RateLimiterMixin], - rateLimit: { - numRequests: 10, - timeInterval: 5000, - }, - run({ spellId, slotId, ritual, targetIds = [], scope = {} }) { - console.warn('Do cast spell not implemented'); - return; - // Get action context - let spell = CreatureProperties.findOne(spellId); - const creatureId = spell.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, spell._id); - ancestors.sort((a, b) => a.order - b.order); - - const properties = getPropertyDescendants(creatureId, spell._id); - properties.push(spell); - properties.sort((a, b) => a.order - b.order); - - // Spend the appropriate slot - let slotLevel = spell.level || 0; - let slot; - - // If a spell requires a slot, make sure a slot is spent - if (spell.level && !spell.castWithoutSpellSlots && !(ritual && spell.ritual)) { - slot = CreatureProperties.findOne(slotId); - if (!slot) { - throw new Meteor.Error('No slot', - 'Slot not found to cast spell'); - } - if (!slot.value) { - throw new Meteor.Error('No slot', - 'Slot depleted'); - } - if (slot.attributeType !== 'spellSlot') { - throw new Meteor.Error('Not a slot', - 'The given property is not a valid spell slot'); - } - if (!slot.spellSlotLevel?.value) { - throw new Meteor.Error('No slot level', - 'Slot does not have a spell slot level'); - } - if (slot.spellSlotLevel.value < spell.level) { - throw new Meteor.Error('Slot too small', - 'Slot is not large enough to cast spell'); - } - slotLevel = slot.spellSlotLevel.value; - damagePropertyWork({ - prop: slot, - operation: 'increment', - value: 1, - actionContext, - }); - } - - // Post the slot level spent to the log - if (slot?.spellSlotLevel?.value) { - actionContext.addLog({ - name: `Casting using a level ${slotLevel} spell slot` - }); - } else if (slotLevel) { - if (ritual) { - actionContext.addLog({ - name: `Ritual casting at level ${slotLevel}` - }); - } else { - actionContext.addLog({ - name: `Casting at level ${slotLevel}` - }); - } - } - - actionContext.scope['slotLevel'] = { value: slotLevel }; - actionContext.scope['~slotLevel'] = { value: slotLevel }; - - // Do the action - doActionWork({ - properties, ancestors, actionContext, methodScope: scope, - }); - - // Force the characters involved to recalculate - Creatures.update({ - _id: { $in: [creatureId, ...targetIds] } - }, { - $set: { dirty: true }, - }); - }, -}); - -export default doAction; diff --git a/app/imports/api/engine/action/methods/runAction.ts b/app/imports/api/engine/action/methods/runAction.ts index 460c54b1..eb4be184 100644 --- a/app/imports/api/engine/action/methods/runAction.ts +++ b/app/imports/api/engine/action/methods/runAction.ts @@ -7,10 +7,31 @@ import applyAction from '/imports/api/engine/action/functions/applyAction'; import writeActionResults from '../functions/writeActionResults'; import getReplayChoicesInputProvider from '/imports/api/engine/action/functions/userInput/getReplayChoicesInputProvider'; import Task from '/imports/api/engine/action/tasks/Task'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; export const runAction = new ValidatedMethod({ name: 'actions.runAction', - validate: null, //TODO validate this + validate: new SimpleSchema({ + actionId: String, + decisions: { + type: Array, + optional: true, + }, + 'decisions.$': { + type: Object, + blackbox: true, + }, + task: { + type: Object, + optional: true, + blackbox: true, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 10, + timeInterval: 5000, + }, run: async function ({ actionId, decisions = [], task }: { actionId: string, decisions?: any[], task?: Task }) { // Get the action const action = await EngineActions.findOneAsync(actionId); diff --git a/app/imports/api/properties/Spells.ts b/app/imports/api/properties/Spells.ts index d378f2ff..1822aab6 100644 --- a/app/imports/api/properties/Spells.ts +++ b/app/imports/api/properties/Spells.ts @@ -37,7 +37,7 @@ const magicSchools = [ 'transmutation', ]; -let SpellSchema = new SimpleSchema({}) +const SpellSchema = new SimpleSchema({}) .extend(ActionSchema) .extend({ name: { @@ -115,10 +115,10 @@ let SpellSchema = new SimpleSchema({}) }, }); -const ComputedOnlySpellSchema = new SimpleSchema() +const ComputedOnlySpellSchema = new SimpleSchema({}) .extend(ComputedOnlyActionSchema); -const ComputedSpellSchema = new SimpleSchema() +const ComputedSpellSchema = new SimpleSchema({}) .extend(SpellSchema) .extend(ComputedOnlySpellSchema); diff --git a/app/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue b/app/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue index 7c9954b5..2fa78ad3 100644 --- a/app/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue +++ b/app/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue @@ -340,20 +340,19 @@ export default { // Cantrips and no-slot spells return slotId && slotId === 'no-slot' } else { - // Leveled spells + // Levelled spells return slotId !== 'no-slot' && slot && spell && ( spell.level <= slot.spellSlotLevel.value ); } }, - cast({ advantage }) { + cast() { let selectedSlotId = this.selectedSlotId; const ritual = selectedSlotId === 'ritual'; if (selectedSlotId === 'no-slot' || selectedSlotId === 'ritual') selectedSlotId = undefined; this.$store.dispatch('popDialogStack', { spellId: this.selectedSpellId, slotId: selectedSlotId, - advantage, ritual, }); }