Refactored actions, 'cast a spell' task now works

This commit is contained in:
Thaum Rystra
2024-10-28 12:28:36 +02:00
parent 804c5f3aee
commit 8f8c9c28aa
39 changed files with 423 additions and 399 deletions

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import TaskResult from './tasks/TaskResult';
import LogContentSchema from '/imports/api/creature/log/LogContentSchema';
import Task from './tasks/Task';
const EngineActions = new Mongo.Collection<EngineAction>('actions');
@@ -9,10 +10,9 @@ export interface EngineAction {
_isSimulation?: boolean;
_stepThrough?: boolean;
_decisions?: any[],
task: Task;
creatureId: string;
rootPropId?: string;
tabletopId?: string;
targetIds?: string[];
results: TaskResult[];
taskCount: number;
}
@@ -21,27 +21,25 @@ const ActionSchema = new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
// @ts-expect-error index not defined
index: 1,
},
rootPropId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
// @ts-expect-error index not defined
index: 1,
},
targetIds: {
type: Array,
optional: true,
task: {
type: Object,
blackbox: true,
},
'targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// Applied properties
results: {
type: Array,

View File

@@ -74,7 +74,6 @@ export default async function applyActionProperty(
if (prop.actionType === 'event' && prop.variableName) {
await applyResetTask({
subtaskFn: 'reset',
prop,
eventName: prop.variableName,
targetIds: [action.creatureId],
}, action, result, userInput);

View File

@@ -49,7 +49,6 @@ export default async function applyAdjustmentProperty(
return;
}
await applyTask(action, {
prop,
targetIds: damageTargetIds,
subtaskFn: 'damageProp',
params: {

View File

@@ -298,7 +298,6 @@ async function dealDamage(
if (damageLeft === 0) return;
// Do the damage
const damageAdded = await applyTask(action, {
prop,
targetIds: [targetId],
subtaskFn: 'damageProp',
params: {

View File

@@ -1,9 +1,7 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask'
import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider';
import saveInputChoices from './userInput/saveInputChoices';
import Task from '/imports/api/engine/action/tasks/Task';
// TODO create a function to get the effective value of a property,
// simulating all the result updates in the action so far
@@ -16,11 +14,9 @@ import Task from '/imports/api/engine/action/tasks/Task';
* @param action The action to apply
* @param userInput The input provider
* @param { Object } options
* @param { Task } options.task If provided, the action will start with this task instead of
* applying the root property of the action
*/
export default async function applyAction(action: EngineAction, userInput: InputProvider, options?: {
simulate?: boolean, stepThrough?: boolean, task?: Task,
simulate?: boolean, stepThrough?: boolean,
}) {
const { simulate, stepThrough } = options || {};
if (!simulate && stepThrough) throw 'Cannot step through unless simulating';
@@ -39,29 +35,19 @@ export default async function applyAction(action: EngineAction, userInput: Input
action._stepThrough = stepThrough;
action._isSimulation = simulate;
action.taskCount = 0;
let task = options?.task;
if (!task) {
const prop = await getSingleProperty(action.creatureId, action.rootPropId);
if (!prop) throw new Meteor.Error('Not found', 'Root action property could not be found');
// If the target ids weren't already set, get them from the user
if (
!action.targetIds
&& action.tabletopId
&& (
prop.target === 'singleTarget' ||
prop.target === 'multipleTargets'
)
) {
action.targetIds = await (userInput.targetIds(prop.target));
}
task = {
prop,
targetIds: action.targetIds || [],
}
// Get the target Ids from the user input if they are expected and not found
if (
!action.task.targetIds?.length
&& action.tabletopId
&& 'prop' in action.task
&& (
action.task.prop?.target === 'singleTarget' ||
action.task.prop?.target === 'multipleTargets'
)
) {
action.task.targetIds = await (userInput.targetIds(action.task.prop.target));
}
await applyTask(action, task, userInput);
await applyTask(action, action.task, userInput);
return action;
}

View File

@@ -38,7 +38,7 @@ type InputProvider = {
/**
* Get the details of casting a spell
*/
castSpell(suggestedParams: CastSpellParams): Promise<CastSpellParams>;
castSpell(suggestedParams: Partial<CastSpellParams>): Promise<CastSpellParams>;
}
export type Advantage = 0 | 1 | -1;

View File

@@ -22,7 +22,7 @@ export const insertAction = new ValidatedMethod({
action.tabletopId = creature.tabletopId;
// Ensure that all the targeted creatures exist and share a tabletop
if (action.targetIds) for (const targetId of action.targetIds) {
if (action.task.targetIds) for (const targetId of action.task.targetIds) {
const target = getCreature(targetId);
if (!target) {
throw new Meteor.Error('not-found', 'Target creature does not exist');

View File

@@ -6,33 +6,17 @@ import { getCreature } from '/imports/api/engine/loadCreatures';
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: new SimpleSchema({
actionId: String,
decisions: {
type: Array,
optional: true,
},
'decisions.$': {
type: Object,
blackbox: true,
},
task: {
type: Object,
optional: true,
blackbox: true,
},
}).validator(),
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run: async function ({ actionId, decisions = [], task }: { actionId: string, decisions?: any[], task?: Task }) {
run: async function ({ actionId, decisions = [] }: { actionId: string, decisions?: any[] }) {
// Get the action
const action = await EngineActions.findOneAsync(actionId);
if (!action) throw new Meteor.Error('not-found', 'Action not found');
@@ -44,7 +28,7 @@ export const runAction = new ValidatedMethod({
const userInput = getReplayChoicesInputProvider(actionId, decisions);
// Apply the action
await applyAction(action, userInput, { task });
await applyAction(action, userInput);
// Persist changes
const writePromise = writeActionResults(action);

View File

@@ -2,6 +2,7 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method';
import EngineActions from '/imports/api/engine/action/EngineActions';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { getCreature } from '/imports/api/engine/loadCreatures';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
export const updateAction = new ValidatedMethod({
name: 'actions.updateAction',
@@ -11,6 +12,11 @@ export const updateAction = new ValidatedMethod({
if (path !== 'targetIds') throw new Meteor.Error('Can only update target ids');
if (!Array.isArray(value)) throw new Meteor.Error('TargetIds must be an array');
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run: async function ({ _id, path, value }: { _id: string, path: 'targetIds', value: string[] }) {
const action = await EngineActions.findOneAsync(_id);
if (!action) {

View File

@@ -1,16 +1,24 @@
import { CheckParams } from '/imports/api/engine/action/functions/userInput/InputProvider';
type Task = PropTask | DamagePropTask | ItemAsAmmoTask | CheckTask | ResetTask;
type Task = PropTask | DamagePropTask | ItemAsAmmoTask | CheckTask | ResetTask | CastSpellTask;
export default Task;
type BaseTask = {
prop: { type: string, [key: string]: any };
targetIds: string[];
silent?: boolean | undefined;
}
type Prop = {
_id: string;
type: string;
[key: string]: any,
}
export type PropTask = BaseTask & {
subtaskFn?: undefined,
prop: Prop;
subtaskFn?: undefined;
silent?: undefined;
}
export type DamagePropTask = BaseTask & {
@@ -22,12 +30,14 @@ export type DamagePropTask = BaseTask & {
title?: string;
operation: 'increment' | 'set';
value: number;
targetProp: any;
targetProp: Prop;
};
}
export type ItemAsAmmoTask = BaseTask & {
subtaskFn: 'consumeItemAsAmmo';
prop: Prop;
silent?: undefined;
params: {
value: number;
item: any;
@@ -40,8 +50,17 @@ export type CheckTask = BaseTask & CheckParams & {
}
export type ResetTask = BaseTask & {
subtaskFn: 'reset',
subtaskFn: 'reset';
eventName: string;
// One and only one target
targetIds: [string];
}
export type CastSpellTask = BaseTask & {
prop?: Prop | undefined;
silent?: undefined;
subtaskFn: 'castSpell';
params: {
spellId: string | undefined;
};
}

View File

@@ -6,7 +6,6 @@ import Context from '../../../../parser/types/Context';
* 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;
@@ -21,8 +20,7 @@ export default class TaskResult {
// properties can be found on variable.previous
pushScope?: any;
mutations: Mutation[];
constructor(propId: string, targetIds: string[]) {
this.propId = propId;
constructor(targetIds: string[]) {
this.targetIds = targetIds;
this.mutations = [];
this.scope = {};

View File

@@ -1,25 +1,40 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from '../tasks/TaskResult';
import { CastSpellTask } from '/imports/api/engine/action/tasks/Task';
import TaskResult from './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';
import applyActionProperty from '../applyProperties/applyActionProperty';
export default async function applyFolderProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider
export default async function applySpellProperty(
task: CastSpellTask, 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,
spellId: task.params.spellId,
slotId: prop?.castWithoutSpellSlots
? undefined
: getSuggestedSpellSlotId(action.creatureId, prop),
ritual: false,
});
if (!castOptions.spellId) {
result.appendLog({
name: 'Error casting spell',
value: 'No spell was selected',
}, [action.creatureId]);
return;
}
// If the user changed the spell they are casting, use that as the prop
prop = getSingleProperty(action.creatureId, castOptions.spellId);
if (!prop) {
result.appendLog({
name: 'Error casting spell',
value: 'The chosen spell was not found',
}, [action.creatureId]);
return;
}
let slotLevel = prop.level || 0;
// Get the slot being cast with
const slot = castOptions.slotId && getSingleProperty(action.creatureId, castOptions.slotId);
@@ -27,7 +42,7 @@ export default async function applyFolderProperty(
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);
await spendSpellSlot(action, castOptions, userInput);
slotLevel = slot.spellSlotLevel?.value || 0;
}
// Add the slot level to the scope
@@ -36,7 +51,10 @@ export default async function applyFolderProperty(
'slotLevel': { value: slotLevel },
};
// Run the rest of the spell as if it were an action
return applyActionProperty(task, action, result, userInput);
return applyActionProperty({
prop,
targetIds: task.targetIds,
}, action, result, userInput);
}
function getSuggestedSpellSlotId(creatureId, prop) {
@@ -67,10 +85,9 @@ function logCastingMessage(slotLevel: number, castOptions, result: TaskResult, p
}
}
function spendSpellSlot(action, prop, castOptions, userInput) {
function spendSpellSlot(action, castOptions, userInput) {
const slot = getSingleProperty(action.creatureId, castOptions.slotId);
return applyTask(action, {
prop,
targetIds: [action.creatureId],
subtaskFn: 'damageProp',
params: {

View File

@@ -14,7 +14,6 @@ import TaskResult from '/imports/api/engine/action/tasks/TaskResult';
export default async function applyCheckTask(
task: CheckTask, action: EngineAction, result: TaskResult, userInput: InputProvider
): Promise<void> {
const prop = task.prop;
const targetIds = task.targetIds;
if (task.contest) {
@@ -45,7 +44,7 @@ export default async function applyCheckTask(
if (skill || ability) {
// Create a new result after before triggers have run
result = new TaskResult(task.prop._id, task.targetIds);
result = new TaskResult(task.targetIds);
action.results.push(result);
}
@@ -75,7 +74,7 @@ export default async function applyCheckTask(
name: checkName,
inline: true,
...dc !== null && { value: `DC **${dc}**` },
...prop?.silent && { silenced: prop.silent }
...task?.silent && { silenced: task.silent }
}, [targetId]);
// Roll the dice
@@ -109,7 +108,7 @@ export default async function applyCheckTask(
name: rollName,
value: `${resultPrefix}\n**${totalValue}**`,
inline: true,
...prop?.silent && { silenced: prop.silent }
...task?.silent && { silenced: task.silent }
}, [targetId]);
// After check triggers

View File

@@ -10,8 +10,6 @@ import numberToSignedString from '/imports/api/utility/numberToSignedString';
export default async function applyDamagePropTask(
task: DamagePropTask, action: EngineAction, result: TaskResult, userInput
): Promise<number> {
const prop = task.prop;
if (task.targetIds.length > 1) {
throw 'This subtask can only be called on a single target';
}
@@ -45,7 +43,7 @@ export default async function applyDamagePropTask(
await applyTriggers(action, targetProp, [targetId], 'damageTriggerIds.before', userInput);
// Create a new result after triggers have run
result = new TaskResult(task.prop._id, task.targetIds);
result = new TaskResult(task.targetIds);
action.results.push(result);
// Refetch the scope properties
@@ -75,7 +73,7 @@ export default async function applyDamagePropTask(
value: `${statName}${operation === 'set' ? ' set to' : ''}` +
` ${value}`,
inline: true,
...prop.silent && { silenced: true },
...task.silent && { silenced: true },
}, task.targetIds);
}
@@ -106,7 +104,7 @@ export default async function applyDamagePropTask(
name: title,
value: `${getPropertyTitle(targetProp)} set from ${targetProp.value} to ${value}`,
inline: true,
...prop.silent && { silenced: true },
...task.silent && { silenced: true },
}]
});
if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage);
@@ -132,7 +130,7 @@ export default async function applyDamagePropTask(
name: increment >= 0 ? 'Attribute damaged' : 'Attribute restored',
value: `${numberToSignedString(-increment)} ${getPropertyTitle(targetProp)}`,
inline: true,
...prop.silent && { silenced: true },
...task.silent && { silenced: true },
}]
});
if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage);
@@ -159,4 +157,4 @@ function setScope(result, targetProp, newValue, damage) {
value: newValue,
damage,
};
}
}

View File

@@ -25,7 +25,7 @@ export default async function applyItemAsAmmoTask(task: ItemAsAmmoTask, action:
await applyTriggers(action, item, task.targetIds, 'ammoTriggerIds.before', userInput);
// Create a new result after before triggers have run
result = new TaskResult(task.prop._id, task.targetIds);
result = new TaskResult(task.targetIds);
action.results.push(result);
// Refetch the scope properties
@@ -51,7 +51,7 @@ export default async function applyItemAsAmmoTask(task: ItemAsAmmoTask, action:
contents: [{
name: getPropertyTitle(item) || 'Ammo',
inline: false,
silenced: prop.silent,
...prop?.silent && { silenced: true },
}]
},
});
@@ -64,4 +64,4 @@ export default async function applyItemAsAmmoTask(task: ItemAsAmmoTask, action:
await applyDefaultAfterPropTasks(action, item, task.targetIds, userInput);
}
return applyTriggers(action, item, task.targetIds, 'ammoTriggerIds.afterChildren', userInput);
}
}

View File

@@ -75,13 +75,12 @@ export async function resetProperties(task: ResetTask, action: EngineAction, res
for (const prop of attributes) {
await applyTask(action, {
prop: task.prop || prop,
targetIds: [action.creatureId],
subtaskFn: 'damageProp',
params: {
title: getPropertyTitle(prop),
operation: 'increment',
value: -prop.damage ?? 0,
value: -prop.damage || 0,
targetProp: prop,
},
}, userInput);
@@ -152,7 +151,6 @@ async function resetHitDice(task: ResetTask, action: EngineAction, result: TaskR
// Apply the damage prop task
await applyTask(action, {
prop: task.prop || hd,
targetIds: [creatureId],
subtaskFn: 'damageProp',
params: {

View File

@@ -8,6 +8,7 @@ import applyProperties from '/imports/api/engine/action/applyProperties';
import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider';
import applyCheckTask from '/imports/api/engine/action/tasks/applyCheckTask';
import applyResetTask from '/imports/api/engine/action/tasks/applyResetTask';
import applyCastSpellTask from '/imports/api/engine/action/tasks/applyCastSpellTask';
// DamagePropTask promises a number of actual damage done
export default async function applyTask(
@@ -37,7 +38,7 @@ export default async function applyTask(
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);
const result = new TaskResult(task.targetIds);
action.results.push(result);
switch (task.subtaskFn) {
case 'damageProp':
@@ -48,6 +49,8 @@ export default async function applyTask(
return applyCheckTask(task, action, result, inputProvider);
case 'reset':
return applyResetTask(task, action, result, inputProvider);
case 'castSpell':
return applyCastSpellTask(task, action, result, inputProvider);
default:
throw 'No case defined for the given subtaskFn';
}
@@ -71,11 +74,11 @@ export default async function applyTask(
}
// 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);
const result = new TaskResult(task.targetIds);
result.scope[`#${prop.type}`] = { _propId: prop._id };
action.results.push(result);
// Apply the property
return applyProperties[prop.type]?.(task, action, result, inputProvider);
}
}
}

View File

@@ -37,15 +37,15 @@ export default {
methods: {
rest(){
this.loading = true;
const emptyProp = {
_id: this.creatureId,
root: { id: this.creatureId },
};
doAction(emptyProp, this.$store, `rest-btn-${this.type}`, {
subtaskFn: 'reset',
prop: emptyProp,
targetIds: [this.creatureId],
eventName: this.type,
doAction({
creatureId: this.creatureId,
$store: this.$store,
elementId: `rest-btn-${this.type}`,
task: {
subtaskFn: 'reset',
targetIds: [this.creatureId],
eventName: this.type,
},
}).catch(e => {
console.error(e);
}).finally(() => {

View File

@@ -41,38 +41,6 @@
</div>
</div>
</div>
<v-card-actions>
<v-btn
text
@click="cancel"
>
Cancel
</v-btn>
<v-spacer slot="actions" />
<v-btn
v-show="!actionDone"
text
:disabled="!userInputReady || !resumeActionFn"
@click="stepAction"
>
Step
</v-btn>
<v-btn
v-if="actionDone"
text
@click="finishAction"
>
{{ 'Apply Results' }}
</v-btn>
<v-btn
v-else
text
:disabled="actionBusy"
@click="startAction"
>
{{ 'Start' }}
</v-btn>
</v-card-actions>
</div>
</template>
@@ -88,6 +56,7 @@ import EngineActions from '/imports/api/engine/action/EngineActions';
import LogContent from '/imports/client/ui/log/LogContent.vue';
//import RollInput from '/imports/client/ui/creature/actions/input/RollInput.vue';
import TargetsInput from '/imports/client/ui/creature/actions/input/TargetsInput.vue';
import CastSpellInput from '/imports/client/ui/creature/actions/input/CastSpellInput.vue';
export default {
components: {
@@ -98,6 +67,7 @@ export default {
LogContent,
//RollInput,
TargetsInput,
CastSpellInput,
},
props: {
actionId: {
@@ -161,7 +131,7 @@ export default {
taskCount: undefined,
};
applyAction(
this.actionResult, this, { simulate: true, stepThrough, task: this.task}
this.actionResult, this, { simulate: true, stepThrough}
).then(() => {
this.actionDone = true;
// If we aren't stepping through close the dialog and apply the action
@@ -194,6 +164,7 @@ export default {
this.activeInput = undefined;
this.activeInputParams = {};
this.userInputReady = false;
console.log({savedInput})
resolve(savedInput);
}
});
@@ -248,6 +219,16 @@ export default {
this.activeInput = 'check-input';
return this.promiseInput();
},
async castSpell(suggestedParams) {
this.userInput = suggestedParams;
console.log(this.action);
console.log(this.action.root);
this.activeInputParams = {
creatureId: this.action.creatureId,
};
this.activeInput = 'cast-spell-input';
return this.promiseInput();
},
}
};
</script>

View File

@@ -6,32 +6,46 @@ import InputProvider from '/imports/api/engine/action/functions/userInput/InputP
import applyAction from '/imports/api/engine/action/functions/applyAction';
import { runAction } from '/imports/api/engine/action/methods/runAction';
import getDeterministicDiceRoller from '/imports/api/engine/action/functions/userInput/getDeterministicDiceRoller';
import { getSingleProperty } from '../../../../api/engine/loadCreatures';
type BaseDoActionParams = {
creatureId: string;
$store: Store<any>;
elementId: string;
}
type DoTaskParams = BaseDoActionParams & {
task: Task;
propId?: undefined;
}
type DoActionParams = BaseDoActionParams & {
propId: string;
task?: undefined;
}
/**
* Apply an action on the client that first creates the action on both the client and server, then
* simulates the action, opening the action dialog if necessary to get input from the user, saving
* the decisions the user makes, then applying the action as a method call to the server with the
* saved decisions, which will persist the action results.
*
* @param prop The property initializing the action, if no task is applied the property will be
* applied as the starting point of the action
* @param $store The Vuex store instance that has the dialog stack
* @param elementId The element to animate the dialog from if a dialog needs to open
* @param task The task to apply instead of applying the property itself
*/
export default async function doAction(
prop: { _id: string, root: { id: string } },
$store: Store<any>,
elementId: string,
task?: Task,
) {
export default async function doAction({ propId, creatureId, $store, elementId, task }: DoActionParams | DoTaskParams) {
if (!task) {
if (!propId) throw new Meteor.Error('no-prop-id', 'Either propId or task must be provided');
task = {
prop: getSingleProperty(creatureId, propId),
targetIds: [],
};
}
// Create the action
const actionId = insertAction.call({
action: {
creatureId: prop.root.id,
rootPropId: prop._id,
creatureId,
task,
results: [],
taskCount: 0,
_decisions: [],
}
});
@@ -45,9 +59,9 @@ export default async function doAction(
// Either way, call the action method afterwards
try {
const finishedAction = await applyAction(
action, getErrorOnInputRequestProvider(action._id), { simulate: true, task }
action, getErrorOnInputRequestProvider(action._id), { simulate: true }
);
return callActionMethod(finishedAction, task);
return callActionMethod(finishedAction);
} catch (e) {
if (e !== 'input-requested') throw e;
return new Promise(resolve => {
@@ -60,7 +74,7 @@ export default async function doAction(
},
callback(action: EngineAction) {
if (!action) return;
resolve(callActionMethod(action, task));
resolve(callActionMethod(action));
return elementId;
},
});
@@ -68,10 +82,9 @@ export default async function doAction(
}
}
const callActionMethod = (action: EngineAction, task?: Task) => {
const callActionMethod = (action: EngineAction) => {
if (!action._id) throw new Meteor.Error('type-error', 'Action must have and _id');
//@ts-expect-error callAsync not defined in types
return runAction.callAsync({ actionId: action._id, decisions: action._decisions, task });
return runAction.callAsync({ actionId: action._id, decisions: action._decisions });
}
const throwInputRequestedError = () => {
@@ -86,6 +99,7 @@ function getErrorOnInputRequestProvider(actionId) {
choose: throwInputRequestedError,
advantage: throwInputRequestedError,
check: throwInputRequestedError,
castSpell: throwInputRequestedError,
}
return errorOnInputRequest;
}

View File

@@ -1,12 +0,0 @@
/**
* Apply an action on the client that first creates the action on both the client and server, then
* simulates the action, opening the action dialog if necessary to get input from the user, saving
* the decisions the user makes, then applying the action as a method call to the server with the
* saved decisions, which will persist the action results.
*/
import Task from '/imports/api/engine/action/tasks/Task';
export default function doClientAction(propIdOrTask: string | Task) {
}

View File

@@ -1,70 +1,64 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title>
Cast a Spell
</v-toolbar-title>
<v-spacer />
<text-field
ref="focusFirst"
label="Name"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"
:error-messages="searchError"
:debounce="200"
@change="searchChanged"
/>
<v-menu
v-model="filterMenuOpen"
left
:close-on-content-click="false"
>
<template #activator="{ on }">
<div>
<text-field
ref="focusFirst"
label="Name"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"
:error-messages="searchError"
:debounce="200"
@change="searchChanged"
/>
<v-menu
v-model="filterMenuOpen"
left
:close-on-content-click="false"
>
<template #activator="{ on }">
<v-btn
icon
:class="{'primary--text': filtersApplied}"
v-on="on"
>
<v-icon>mdi-filter</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="filter in booleanFilters"
:key="filter.name"
style="height: 52px;"
>
<v-checkbox
v-model="filter.enabled"
style="flex-grow: 0; margin-right: 8px;"
/>
<v-switch
v-model="filter.value"
:disabled="!filter.enabled"
:label="filter.name"
/>
</v-list-item>
<div class="layout">
<v-btn
icon
:class="{'primary--text': filtersApplied}"
v-on="on"
text
@click="clearBooleanFilters"
>
<v-icon>mdi-filter</v-icon>
Clear
</v-btn>
</template>
<v-list>
<v-list-item
v-for="filter in booleanFilters"
:key="filter.name"
style="height: 52px;"
<v-spacer />
<v-btn
text
class="primary--text"
@click="filterMenuOpen = false"
>
<v-checkbox
v-model="filter.enabled"
style="flex-grow: 0; margin-right: 8px;"
/>
<v-switch
v-model="filter.value"
:disabled="!filter.enabled"
:label="filter.name"
/>
</v-list-item>
<div class="layout">
<v-btn
text
@click="clearBooleanFilters"
>
Clear
</v-btn>
<v-spacer />
<v-btn
text
class="primary--text"
@click="filterMenuOpen = false"
>
Done
</v-btn>
</div>
</v-list>
</v-menu>
</template>
Done
</v-btn>
</div>
</v-list>
</v-menu>
<split-list-layout>
<template slot="left">
<div
@@ -151,29 +145,19 @@
</v-list-item-group>
</template>
</split-list-layout>
<template slot="actions">
<v-spacer />
<v-btn
text
@click="$store.dispatch('popDialogStack')"
>
Cancel
</v-btn>
<v-btn
text
:disabled="!canCast"
class="mx-2 px-4"
color="primary"
@click="cast"
>
Cast
</v-btn>
</template>
</dialog-base>
<v-btn
text
:disabled="!canCast"
class="mx-2 px-4"
color="primary"
@click="cast"
>
Cast
</v-btn>
</div>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import SplitListLayout from '/imports/client/ui/properties/components/attributes/SplitListLayout.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import spellsWithSubheaders from '/imports/client/ui/properties/components/spells/spellsWithSubheaders';
@@ -193,7 +177,6 @@ const slotFilter = {
export default {
components: {
DialogBase,
SplitListLayout,
SpellSlotListTile,
SpellListTile,
@@ -207,6 +190,10 @@ export default {
type: String,
default: undefined,
},
value: {
type: Object,
required: true,
},
spellId: {
type: String,
default: undefined,
@@ -215,8 +202,8 @@ export default {
data() {
return {
searchString: undefined,
selectedSlotId: this.slotId,
selectedSpellId: this.spellId,
selectedSlotId: this.value.slotId,
selectedSpellId: this.value.spellId,
selectedSlot: undefined,
selectedSpell: undefined,
searchValue: undefined,
@@ -253,7 +240,8 @@ export default {
watch: {
selectedSpellId: {
handler(spellId) {
this.selectedSpell = CreatureProperties.findOne(spellId)
this.selectedSpell = CreatureProperties.findOne(spellId);
this.$emit('input', { ...this.value, spellId });
},
immediate: true
},
@@ -291,6 +279,11 @@ export default {
selectedSlotId: {
handler(slotId) {
this.selectedSlot = CreatureProperties.findOne(slotId);
if (slotId === 'ritual') {
this.$emit('input', { ...this.value, slotId: undefined, ritual: true });
} else {
this.$emit('input', { ...this.value, slotId, ritual: false });
}
},
immediate: true
},
@@ -347,14 +340,7 @@ export default {
}
},
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,
ritual,
});
this.$emit('continue');
}
},
meteor: {

View File

@@ -615,16 +615,20 @@ export default {
incrementChange(_id, { type, value, ack }) {
const model = CreatureProperties.findOne(_id);
if (type === 'increment') value = -value;
doAction(model, this.$store, model._id, {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: type,
value,
targetProp: model,
}
doAction({
creatureId: model.root.id,
$store: this.$store,
elementId: `${model._id}-${type}`,
task: {
subtaskFn: 'damageProp',
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: type,
value,
targetProp: model,
},
},
}).then(() =>{
ack?.();
}).catch((error) => {

View File

@@ -180,16 +180,21 @@ export default {
},
damage({operation, value, ack}){
const model = this.model;
doAction(model, this.$store, model._id, {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: operation,
value,
targetProp: model,
}
doAction({
creatureId: model.root.id,
$store: this.$store,
elementId: '??',
task: {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: operation,
value,
targetProp: model,
}
},
}).then(() =>{
ack?.();
}).catch((error) => {

View File

@@ -1,6 +1,5 @@
// Load commonly used dialogs immediately
import ActionDialog from '/imports/client/ui/creature/actions/ActionDialog.vue';
import CastSpellWithSlotDialog from '/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CharacterCreationDialog from '/imports/client/ui/creature/character/CharacterCreationDialog.vue';
import CharacterSheetDialog from '/imports/client/ui/tabletop/CharacterSheetDialog.vue';
import CreatureFormDialog from '/imports/client/ui/creature/CreatureFormDialog.vue';
@@ -42,7 +41,6 @@ const UsernameDialog = () => import('/imports/client/ui/user/UsernameDialog.vue'
export default {
ActionDialog,
ArchiveDialog,
CastSpellWithSlotDialog,
CharacterCreationDialog,
CharacterImportDialog,
CharacterSheetDialog,

View File

@@ -11,6 +11,7 @@
outlined
style="font-size: 16px; letter-spacing: normal;"
class="mr-2"
data-id="do-action-button"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission || !!targetingError"
@@ -224,7 +225,12 @@ export default {
},
doAction() {
this.doActionLoading = true;
doAction(this.model, this.$store, this.model._id).catch((e) => {
doAction({
propId: this.model._id,
creatureId: this.model.root.id,
$store: this.$store,
elementId: 'do-action-button',
}).catch((e) => {
console.error(e);
}).finally(() => {
this.doActionLoading = false;

View File

@@ -23,6 +23,7 @@
<script lang="js">
import doAction from '/imports/client/ui/creature/actions/doAction';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
export default {
components: {
@@ -39,10 +40,22 @@ export default {
},
data(){return {
hovering: false,
loading: false,
}},
methods: {
doAction() {
doAction(this.model, this.$store, `event-btn-${this.model._id}`);
async doAction() {
this.loading = true;
doAction({
propId: this.model._id,
creatureId: this.model.root.id,
$store: this.$store,
elementId: `event-btn-${this.model._id}`,
}).catch(error => {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);
}).finally(() => {
this.loading = false;
});
},
}
}

View File

@@ -92,14 +92,19 @@ export default {
},
check() {
this.checkLoading = true;
doAction(this.model, this.$store, `check-btn-${this.model._id}`, {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: undefined,
abilityVariableName: this.model.variableName,
dc: null,
doAction({
creatureId: this.model.root.id,
$store: this.$store,
elementId: `check-btn-${this.model._id}`,
task: {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: undefined,
abilityVariableName: this.model.variableName,
dc: null,
},
}).catch(error => {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);

View File

@@ -77,14 +77,19 @@ export default {
signed: numberToSignedString,
check(){
this.checkLoading = true;
doAction(this.model, this.$store, `check-btn-${this.model._id}`, {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: this.model.variableName,
abilityVariableName: this.model.ability,
dc: null,
doAction({
creatureId: this.model.root.id,
$store: this.$store,
elementId: `check-btn-${this.model._id}`,
task: {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: this.model.variableName,
abilityVariableName: this.model.ability,
dc: null,
},
}).catch(error => {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);

View File

@@ -22,6 +22,7 @@
color="accent"
style="width: 100%;"
outlined
data-id="cast-spell-btn"
@click="castSpell"
>
Cast a spell
@@ -32,8 +33,8 @@
<script lang="js">
import SpellSlotListTile from '/imports/client/ui/properties/components/attributes/SpellSlotListTile.vue';
import doAction from '/imports/client/ui/creature/actions/doAction';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
import doCastSpell from '/imports/api/engine/action/methods/doCastSpell';
export default {
components: {
@@ -50,38 +51,31 @@ export default {
default: () => [],
},
},
data(){return {
castSpellLoading: false,
}},
methods: {
castSpell() {
this.$store.commit('pushDialogStack', {
component: 'cast-spell-with-slot-dialog',
elementId: 'spell-slot-card',
data: {
creatureId: this.creatureId,
},
callback({ spellId, slotId, advantage, ritual } = {}) {
if (!spellId) return;
doCastSpell.call({
spellId,
slotId,
ritual,
scope: {
'~attackAdvantage': { value: advantage },
},
}, error => {
if (!error) return;
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);
});
},
this.castSpellLoading = true;
doAction({
creatureId: this.model.root.id,
propId: this.model._id,
$store: this.$store,
elementId: `spell-slot-card-${this.model._id}`
}).catch(error => {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);
}).finally(() => {
this.castSpellLoading = false;
});
},
clickProperty({ _id }) {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `spell-slot-card-${_id}`,
elementId: 'cast-spell-btn',
data: { _id },
});
},
}
}
</script>../../../../../api/engine/action/methods/doCastSpell
</script>

View File

@@ -1,6 +1,7 @@
<template lang="html">
<v-list-item
:key="model._id"
:data-id="`spell-slot-list-tile-${model._id}`"
class="spell-slot-list-tile"
v-bind="$attrs"
v-on="hasClickListener ? {click} : {}"
@@ -91,16 +92,15 @@ export default {
this.$emit('click', e);
},
disabled(i) {
if (!this.context.editPermission) return true;
// Use these if only the next filled or empty slot can be clicked
// if (this.model.value === i) return false;
// if (this.model.value === i - 1) return false;
// return true
return false;
return !this.context.editPermission;
},
damageProperty({ type, value, ack }) {
const model = this.model;
doAction(model, this.$store, model._id, {
doAction({
creatureId: model.root.id,
$store: this.$store,
elementId: `spell-slot-list-tile-${model._id}`,
task: {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
@@ -110,7 +110,7 @@ export default {
value,
targetProp: model,
}
}).then(() =>{
}}).then(() =>{
ack?.();
}).catch((error) => {
if (ack) {

View File

@@ -95,17 +95,22 @@ export default {
damageProperty({value, type, ack}) {
const model = this.model;
if (type === 'increment') value = -value;
doAction(model, this.$store, model._id, {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: type,
value,
targetProp: model,
doAction({
creatureId: model.root.id,
$store: this.$store,
elementId: this.dataId,
task: {
subtaskFn: 'damageProp',
prop: model,
targetIds: [model.root.id],
params: {
title: getPropertyTitle(model),
operation: type,
value,
targetProp: model,
}
}
}).then(() =>{
}).then(() => {
ack?.();
}).catch((error) => {
if (ack) {
@@ -127,4 +132,4 @@ export default {
.pointer {
cursor: pointer;
}
</style>
</style>

View File

@@ -104,14 +104,19 @@ export default {
},
check() {
this.checkLoading = true;
doAction(this.model, this.$store, `check-btn-${this.model._id}`, {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: this.model.variableName,
abilityVariableName: this.model.ability,
dc: null,
doAction({
creatureId: this.model.root.id,
$store: this.$store,
elementId: `check-btn-${this.model._id}`,
task: {
subtaskFn: 'check',
prop: this.model,
targetIds: [this.model.root.id],
advantage: this.model.advantage,
skillVariableName: this.model.variableName,
abilityVariableName: this.model.ability,
dc: null,
},
}).catch(error => {
snackbar({ text: error.reason || error.message || error.toString() });
console.error(error);
@@ -135,4 +140,4 @@ export default {
.v-icon.theme--light {
color: rgba(0, 0, 0, 0.54) !important;
}
</style>
</style>

View File

@@ -119,13 +119,11 @@
<script lang="js">
import propertyViewerMixin from '/imports/client/ui/properties/viewers/shared/propertyViewerMixin';
//TODO import doAction from '/imports/api/engine/actions/doAction';
import ActionConditionView from '/imports/client/ui/properties/components/actions/ActionConditionView.vue';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import updateCreatureProperty from '/imports/api/creature/creatureProperties/methods/updateCreatureProperty';
import doCastSpell from '/imports/api/engine/action/methods/doCastSpell.js';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
import doAction from '/imports/client/ui/creature/actions/doAction';
@@ -190,7 +188,12 @@ export default {
methods: {
doAction() {
this.doActionLoading = true;
doAction(this.model, this.$store, this.model._id).catch((e) => {
doAction({
creatureId: this.model.root.id,
$store: this.$store,
propId: this.model._id,
elementId: 'do-action-button',
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });
}).finally(() => {

View File

@@ -16,6 +16,7 @@
:style="{
fontSize: '24px'
}"
data-id="do-action-button"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission"
@@ -217,7 +218,12 @@ export default {
doAction() {
this.doActionLoading = true;
this.$emit('close-menu')
doAction(this.model, this.$store, this.model._id).catch((e) => {
doAction({
propId: this.model._id,
creatureId: this.model.root.id,
$store: this.$store,
elementId: 'do-action-button',
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });
}).finally(() => {

View File

@@ -57,8 +57,6 @@
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import numberToSignedString from '/imports/api/utility/numberToSignedString.js';
import doAction from '/imports/client/ui/creature/actions/doAction';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
@@ -69,8 +67,6 @@ import { some } from 'lodash';
export default {
components: {
AttributeConsumedView,
ItemConsumedView,
MarkdownText,
PropertyIcon,
TreeNodeList,
@@ -175,7 +171,12 @@ export default {
doAction() {
this.doActionLoading = true;
this.$emit('close-menu')
doAction(this.model, this.$store, this.model._id).catch((e) => {
doAction({
propId: this.model._id,
creatureId: this.model.root.id,
$store: this.$store,
elementId: 'do-action-button',
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });
}).finally(() => {

View File

@@ -166,7 +166,9 @@ export default {
actions(){
return getProperties(this.activeCreatureId, { type: 'action', actionType: { $ne: 'event'} });
},
moreTargets(){
moreTargets() {
// Disable portrait targeting for now, they aren't used by the action engine yet
return false;
const activeAction = CreatureProperties.findOne(this.activeActionId);
if (!activeAction) return;
if (activeAction.target === 'singleTarget') {

View File

@@ -52,6 +52,7 @@
<v-card
v-else-if="activeIcon && activeIcon.tab"
style="width: 300px"
data-id="tabletop-standard-card"
>
<v-card-title>
<v-icon left>
@@ -167,33 +168,15 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
import TabletopActionCard from '/imports/client/ui/tabletop/TabletopActionCard.vue';
import TabletopBuffCard from '/imports/client/ui/tabletop/TabletopBuffCard.vue';
import CreatureBarIcon from '/imports/client/ui/tabletop/selectedCreatureBar/CreatureBarIcon.vue';
import { compact } from 'lodash';
//import TabletopPortrait from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopPortrait.vue';
//import TabletopBuffIcons from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopBuffIcons.vue';
//import TabletopActions from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopActions.vue';
//import TabletopGroupedFolders from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopGroupedFolders.vue';
//import TabletopResources from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopResources.vue';
//import TabletopCreatureSheetTabs from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopCreatureSheetTabs.vue';
//import TabletopDetailPopover from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopDetailPopover.vue';
import { compact, chunk } from 'lodash';
import doAction from '../../creature/actions/doAction';
function splitToNChunks(inputArray, n) {
let result = [];
const array = [...inputArray] // Create shallow copy, because splice mutates array
for (let i = n; i > 0; i--) {
result.push(array.splice(0, Math.ceil(array.length / i)));
}
return result;
return chunk(inputArray, Math.ceil(inputArray.length / n));
}
export default {
components: {
//TabletopPortrait,
//TabletopBuffIcons,
//TabletopActions,
//TabletopGroupedFolders,
//TabletopResources,
//TabletopCreatureSheetTabs,
CreatureBarIcon,
TabletopActionCard,
TabletopBuffCard,
@@ -261,6 +244,7 @@ export default {
}
if (icon.actionName) {
this.openStandardAction(icon.standardId)
return;
}
if (this.selectedIcon === icon) {
this.selectedIcon = undefined;
@@ -285,6 +269,7 @@ export default {
return outside;
},
openCharacterSheet(tab, elementId) {
this.menuOpen = false;
this.$store.commit(
'setTabForCharacterSheet',
{ id: this.creatureId, tab }
@@ -296,13 +281,26 @@ export default {
creatureId: this.creatureId,
},
});
this.menuOpen = false;
},
openStandardAction(standardId) {
// TODO standard action dialogs
this.menuOpen = false;
if (standardId === 'cast-spell') {
doAction({
creatureId: this.creatureId,
$store: this.$store,
elementId: standardId,
task: {
subtaskFn: 'castSpell',
targetIds: [],
params: {
spellId: undefined,
},
},
});
}
},
openPropertyDetails(elementId) {
this.menuOpen = false;
const propId = this.selectedProp._id;
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
@@ -310,8 +308,6 @@ export default {
data: { _id: propId },
callback: () => propId
});
// Close the menu while the dialog is open
this.menuOpen = false;
},
},
meteor: {
@@ -344,7 +340,7 @@ export default {
// Get the folders that could hide a property
const folderGroupsById = {};
CreatureProperties.find({
CreatureProperties.find({
'root.id': this.creatureId,
type: 'folder',
groupStats: true,
@@ -475,4 +471,4 @@ export default {
.tabletop-prop-menu.rows-4 {
bottom: 212px;
}
</style>
</style>

View File

@@ -3,7 +3,11 @@
"module": "esNext",
"moduleResolution": "Node",
"target": "es2018",
"lib": ["esnext", "dom"],
"lib": [
"esnext",
"dom",
"dom.iterable"
],
"jsx": "preserve",
"strict": true,
"strictNullChecks": true,
@@ -44,4 +48,4 @@
"node_modules/@types/meteor/globals/*"
]
}
}
}