Started implementing spells in action engine

This commit is contained in:
ThaumRystra
2024-10-27 12:51:48 +02:00
parent 01df7898cc
commit 804c5f3aee
9 changed files with 131 additions and 147 deletions

View File

@@ -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"
]
}
}

View File

@@ -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<void> {
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);
}

View File

@@ -35,6 +35,10 @@ type InputProvider = {
* Get the details of a check or save
*/
check(suggestedParams: CheckParams): Promise<CheckParams>;
/**
* Get the details of casting a spell
*/
castSpell(suggestedParams: CastSpellParams): Promise<CastSpellParams>;
}
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;

View File

@@ -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;
}

View File

@@ -41,7 +41,10 @@ const inputProviderForTests: InputProvider = {
},
async check(suggestedParams) {
return suggestedParams;
}
},
async castSpell(suggestedParams) {
return suggestedParams;
},
}
export default inputProviderForTests;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
});
}