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

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

@@ -0,0 +1,400 @@
<template lang="html">
<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
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>
<split-list-layout>
<template slot="left">
<div
key="slot-title"
class="text-h6 my-3"
>
Slot
</div>
<v-list-item-group
key="slot-list"
v-model="selectedSlotId"
>
<v-list-item
key="cantrip-dummy-slot"
class="spell-slot-list-tile"
:class="{ 'primary--text': selectedSlotId === 'no-slot' }"
value="no-slot"
:disabled="!canCastSpellWithSlot(selectedSpell, 'no-slot')"
@click="selectedSlotId = 'no-slot'"
>
<v-list-item-content>
<v-list-item-title>
Cast without spell slot
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
key="ritual-dummy-slot"
class="spell-slot-list-tile"
:class="{ 'primary--text': selectedSlotId === 'ritual' }"
value="ritual"
:disabled="!canCastSpellWithSlot(selectedSpell, 'ritual')"
@click="selectedSlotId = 'ritual'"
>
<v-list-item-content>
<v-list-item-title>
Cast as ritual
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<spell-slot-list-tile
v-for="spellSlot in spellSlots"
:key="spellSlot._id"
:model="spellSlot"
:class="{ 'primary--text': selectedSlotId === spellSlot._id }"
:value="spellSlot._id"
:disabled="!canCastSpellWithSlot(selectedSpell, spellSlot._id, spellSlot)"
hide-cast-button
@click="selectedSlotId = spellSlot._id"
/>
</v-list-item-group>
</template>
<template slot="right">
<div
key="spell-title-right"
class="text-h6 my-3"
>
Spell
</div>
<v-list-item-group
key="slot-list-right"
v-model="selectedSpellId"
>
<template v-for="spell in computedSpells">
<v-subheader
v-if="spell.isSubheader"
:key="`${spell.level}-header`"
class="item"
>
{{ spell.level === 0 ? 'Cantrips' : `Level ${spell.level}` }}
</v-subheader>
<spell-list-tile
v-else
:key="spell._id"
hide-handle
show-info-button
:model="spell"
:value="spell._id"
:class="{ 'primary--text': selectedSpellId === spell._id }"
:disabled="!canCastSpellWithSlot(spell, selectedSlotId, selectedSlot)"
@show-info="spellDialog(spell._id)"
/>
</template>
</v-list-item-group>
</template>
</split-list-layout>
<v-btn
text
:disabled="!canCast"
class="mx-2 px-4"
color="primary"
@click="cast"
>
Cast
</v-btn>
</div>
</template>
<script lang="js">
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';
import SpellSlotListTile from '/imports/client/ui/properties/components/attributes/SpellSlotListTile.vue';
import SpellListTile from '/imports/client/ui/properties/components/spells/SpellListTile.vue';
import { find } from 'lodash';
import { getFilter } from '/imports/api/parenting/parentingFunctions';
const slotFilter = {
type: 'attribute',
attributeType: 'spellSlot',
removed: { $ne: true },
inactive: { $ne: true },
overridden: { $ne: true },
'spellSlotLevel.value': { $gte: 1 },
};
export default {
components: {
SplitListLayout,
SpellSlotListTile,
SpellListTile,
},
props: {
creatureId: {
type: String,
required: true,
},
slotId: {
type: String,
default: undefined,
},
value: {
type: Object,
required: true,
},
spellId: {
type: String,
default: undefined,
},
},
data() {
return {
searchString: undefined,
selectedSlotId: this.value.slotId,
selectedSpellId: this.value.spellId,
selectedSlot: undefined,
selectedSpell: undefined,
searchValue: undefined,
searchError: undefined,
filterMenuOpen: false,
booleanFilters: {
verbal: { name: 'Verbal', enabled: false, value: true },
somatic: { name: 'Somatic', enabled: false, value: true },
material: { name: 'Material', enabled: false, value: true },
concentration: { name: 'Concentration', enabled: false, value: true },
ritual: { name: 'Ritual', enabled: false, value: true },
},
}
},
computed: {
computedSpells() {
return spellsWithSubheaders(this.spells);
},
canCast() {
if (!this.selectedSpell || !this.selectedSlotId) return false;
return this.canCastSpellWithSlot(
this.selectedSpell, this.selectedSlotId, this.selectedSlot
);
},
filtersApplied() {
for (let key in this.booleanFilters) {
if (this.booleanFilters[key].enabled) {
return true;
}
}
return false;
},
},
watch: {
selectedSpellId: {
handler(spellId) {
this.selectedSpell = CreatureProperties.findOne(spellId);
this.$emit('input', { ...this.value, spellId });
},
immediate: true
},
selectedSpell: {
handler(spell) {
if (!spell) return;
if (this.selectedSlotId && this.canCastSpellWithSlot(
spell, this.selectedSlotId, this.selectedSlot
)) return;
if (
(spell.level === 0 || spell.castWithoutSpellSlots)
) {
this.selectedSlotId = 'no-slot';
} else {
const newSlot = find(
CreatureProperties.find({
'ancestors.id': this.creatureId,
...slotFilter
}, {
sort: { 'spellSlotLevel.value': 1, order: 1 },
}).fetch(),
slot => {
return this.canCastSpellWithSlot(spell, slot._id, slot)
}
);
if (newSlot) {
this.selectedSlotId = newSlot._id;
} else if (spell.ritual) {
this.selectedSlotId = 'ritual';
}
}
},
immediate: true,
},
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
},
selectedSlot: {
handler(slot) {
if (!slot) return;
if (!this.selectedSpell) return;
if (this.selectedSpell.level > slot.spellSlotLevel.value) {
this.selectedSpellId = undefined;
}
},
immediate: true,
},
},
mounted() {
if (this.selectedSpellId) {
this.$vuetify.goTo('.spell.v-list-item--active', { container: '.right' });
}
},
methods: {
clearBooleanFilters() {
for (let key in this.booleanFilters) {
this.booleanFilters[key].enabled = false;
}
},
spellDialog(_id) {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `spell-info-btn-${_id}`,
data: { _id },
});
},
searchChanged(val, ack) {
this.searchValue = val;
setTimeout(ack, 200);
},
canCastSpellWithSlot(spell, slotId, slot) {
if (slot && !slot.value) return false;
if (!spell) return true;
if (!slotId) return true;
if (
spell.castWithoutSpellSlots &&
spell.insufficientResources
) return false;
if (spell.ritual && slotId === 'ritual') return true;
if (!spell.level || spell.castWithoutSpellSlots) {
// Cantrips and no-slot spells
return slotId && slotId === 'no-slot'
} else {
// Levelled spells
return slotId !== 'no-slot' && slot && spell && (
spell.level <= slot.spellSlotLevel.value
);
}
},
cast() {
this.$emit('continue');
}
},
meteor: {
spells() {
let filter = {
...getFilter.descendantsOfRoot(this.creatureId),
removed: { $ne: true },
inactive: { $ne: true },
$or: [
{ prepared: true },
{ alwaysPrepared: true },
],
};
// Apply the filters from the filter menu
for (let key in this.booleanFilters) {
if (this.booleanFilters[key].enabled) {
let value = this.booleanFilters[key].value;
if (key === 'material') {
filter[key] = { $exists: this.booleanFilters[key].value };
} else {
filter[key] = value ? true : { $ne: true };
}
}
}
// Apply the search string to the name field
if (this.searchValue) {
filter.name = {
$regex: this.searchValue.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: 'i'
};
}
return CreatureProperties.find(filter, {
sort: { left: 1 }
});
},
spellSlots() {
return CreatureProperties.find({
'ancestors.id': this.creatureId,
...slotFilter
}, {
sort: { 'spellSlotLevel.value': 1, order: 1 },
});
},
},
}
</script>
<style lang="css" scoped>
.v-list {
flex-basis: 200px;
}
.v-list.spells {
flex-grow: 1;
}
</style>

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) => {