diff --git a/app/imports/api/engine/action/EngineActions.ts b/app/imports/api/engine/action/EngineActions.ts index 68749b55..46a7e7c0 100644 --- a/app/imports/api/engine/action/EngineActions.ts +++ b/app/imports/api/engine/action/EngineActions.ts @@ -10,7 +10,7 @@ export interface EngineAction { _stepThrough?: boolean; _decisions?: any[], creatureId: string; - rootPropId: string; + rootPropId?: string; targetIds?: string[]; results: TaskResult[]; taskCount: number; diff --git a/app/imports/api/engine/action/applyProperties/applyActionProperty.ts b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts index fa3b372e..3f85a36b 100644 --- a/app/imports/api/engine/action/applyProperties/applyActionProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applyActionProperty.ts @@ -13,6 +13,7 @@ import numberToSignedString from '/imports/api/utility/numberToSignedString'; import { getNumberFromScope } from '/imports/api/creature/creatures/CreatureVariables'; import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; import { CalculatedField } from '/imports/api/properties/subSchemas/computedField'; +import applyResetTask from '/imports/api/engine/action/tasks/applyResetTask'; export default async function applyActionProperty( task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider @@ -72,7 +73,12 @@ export default async function applyActionProperty( await applyChildren(action, prop, targetIds, userInput); } if (prop.actionType === 'event' && prop.variableName) { - resetProperties(action, prop, result, userInput); + await applyResetTask({ + subtaskFn: 'reset', + prop, + eventName: prop.variableName, + targetIds: [action.creatureId], + }, action, result, userInput); } // Finish @@ -244,46 +250,3 @@ function applyCrits(value, scope, resultPushScope) { } return { criticalHit, criticalMiss }; } - -async function resetProperties(action: EngineAction, prop: any, result: TaskResult, userInput: InputProvider) { - 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, - ...prop.silent && { silenced: true }, - }] - }); - } -} \ No newline at end of file diff --git a/app/imports/api/engine/action/tasks/Task.ts b/app/imports/api/engine/action/tasks/Task.ts index 18328870..5239c9c0 100644 --- a/app/imports/api/engine/action/tasks/Task.ts +++ b/app/imports/api/engine/action/tasks/Task.ts @@ -1,6 +1,6 @@ import { CheckParams } from '/imports/api/engine/action/functions/userInput/InputProvider'; -type Task = PropTask | DamagePropTask | ItemAsAmmoTask | CheckTask; +type Task = PropTask | DamagePropTask | ItemAsAmmoTask | CheckTask | ResetTask; export default Task; @@ -38,3 +38,10 @@ export type ItemAsAmmoTask = BaseTask & { export type CheckTask = BaseTask & CheckParams & { subtaskFn: 'check'; } + +export type ResetTask = BaseTask & { + subtaskFn: 'reset', + eventName: string; + // One and only one target + targetIds: [string]; +} diff --git a/app/imports/api/engine/action/tasks/applyResetTask.ts b/app/imports/api/engine/action/tasks/applyResetTask.ts new file mode 100644 index 00000000..580a8e36 --- /dev/null +++ b/app/imports/api/engine/action/tasks/applyResetTask.ts @@ -0,0 +1,172 @@ +import { EngineAction } from '/imports/api/engine/action/EngineActions'; +import InputProvider from '/imports/api/engine/action/functions/userInput/InputProvider'; +import { ResetTask } 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 { getCreature, getPropertiesByFilter, getPropertiesOfType } from '/imports/api/engine/loadCreatures'; +import getPropertyTitle from '/imports/api/utility/getPropertyTitle'; + +export default async function applyResetTask( + task: ResetTask, action: EngineAction, result: TaskResult, userInput: InputProvider +): Promise { + // Event name must be defined + if (!task.eventName) return; + + // This task can only be applied to a single target + if (task.targetIds.length !== 1) { + throw new Meteor.Error('wrong-number-of-targets', `Must reset the properties of a single creature at a time, ${task.targetIds.length} targets were provided`) + } + + // Print a title for the event + let name: string; + switch (task.eventName) { + case 'shortRest': + name = 'Short Rest'; + break; + case 'longRest': + name = 'Long Rest'; + break; + default: + name = task.eventName; + break; + } + result.appendLog({ name }, task.targetIds); + + // Reset the properties by this event name + await resetProperties(task, action, result, userInput); + + // Reset hit dice on a long rest, starting with the highest dice + if (task.eventName === 'longRest') { + await resetHitDice(task, action, result, userInput); + } +} + +export async function resetProperties(task: ResetTask, action: EngineAction, result: TaskResult, userInput: InputProvider) { + const creatureId = task.targetIds[0]; + + // Long rests reset short rest properties as well + let mongoFilter: Mongo.Selector + if (task.eventName === 'longRest') { + mongoFilter = { reset: { $in: ['shortRest', 'longRest'] } } + } else { + mongoFilter = { reset: task.eventName }; + } + + const filterFn = (prop) => { + if (task.eventName === 'longRest') { + if (prop.reset !== 'longRest' && prop.reset !== 'shortRest') return false; + } else { + if (prop.reset !== task.eventName) return false; + } + return true; + } + + // Attributes + + const attributeFilter: Mongo.Selector = { + ...mongoFilter, + type: 'attribute', + damage: { $nin: [0, undefined] }, + } + + const attributeFilterFunction = (att) => { + if (att.type !== 'attribute') return false; + if (!filterFn(att)) return false; + if (att.damage === 0 || att.damage === undefined) return false; + return true; + } + + const attributes = getPropertiesByFilter(creatureId, attributeFilterFunction, attributeFilter); + + 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, + targetProp: prop, + }, + }, userInput); + } + + // Action-like properties + + const actionFilter = { + ...mongoFilter, + type: { + $in: ['action', 'spell'] + }, + usesUsed: { $nin: [0, undefined] }, + }; + + const actionFilterFunction = (prop) => { + if (prop.type !== 'action' && prop.type !== 'spell') return false; + if (!filterFn(prop)) return false; + if (prop.usesUsed === 0 || prop.usesUsed === undefined) return false; + return true; + } + + const actionProps = getPropertiesByFilter(creatureId, actionFilterFunction, actionFilter); + + for (const prop of actionProps) { + result.mutations.push({ + targetIds: [creatureId], + updates: [{ + propId: prop._id, + type: prop.type, + set: { usesUsed: 0 }, + }], + contents: [{ + name: prop.name, + value: prop.usesUsed >= 0 ? `Restored ${prop.usesUsed} uses` : `Removed ${-prop.usesUsed} uses` + }], + }); + } +} + +async function resetHitDice(task: ResetTask, action: EngineAction, result: TaskResult, userInput: InputProvider) { + const creatureId = task.targetIds[0]; + + const hitDice = getPropertiesOfType(creatureId, 'hitDice'); + + // Use a collator to do sorting in natural order + const collator = new Intl.Collator('en', { + numeric: true, sensitivity: 'base' + }); + + // Get the hit dice in decending order of hitDiceSize + const compare = (a, b) => collator.compare(b.hitDiceSize, a.hitDiceSize) + hitDice.sort(compare); + + // Get the total number of hit dice that can be recovered this rest + const totalHd = hitDice.reduce((sum, hd) => sum + (hd.total || 0), 0); + const creature = getCreature(creatureId); + const resetMultiplier = creature.settings.hitDiceResetMultiplier || 0.5; + let recoverableHd = Math.max(Math.floor(totalHd * resetMultiplier), 1); + + // recover each hit dice in turn until the recoverable amount is used up + let amountToRecover; + for (const hd of hitDice) { + if (!recoverableHd) return; + amountToRecover = Math.min(recoverableHd, hd.damage ?? 0); + if (!amountToRecover) return; + recoverableHd -= amountToRecover; + + // Apply the damage prop task + await applyTask(action, { + prop: task.prop || hd, + targetIds: [creatureId], + subtaskFn: 'damageProp', + params: { + title: getPropertyTitle(hd), + operation: 'increment', + value: -amountToRecover, + targetProp: hd, + }, + }, userInput); + + } +} diff --git a/app/imports/api/engine/action/tasks/applyTask.ts b/app/imports/api/engine/action/tasks/applyTask.ts index f8223e12..b3a809ff 100644 --- a/app/imports/api/engine/action/tasks/applyTask.ts +++ b/app/imports/api/engine/action/tasks/applyTask.ts @@ -7,6 +7,7 @@ import { getSingleProperty } from '/imports/api/engine/loadCreatures'; 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'; // DamagePropTask promises a number of actual damage done export default async function applyTask( @@ -45,6 +46,10 @@ export default async function applyTask( return applyItemAsAmmoTask(task, action, result, inputProvider); case 'check': return applyCheckTask(task, action, result, inputProvider); + case 'reset': + return applyResetTask(task, action, result, inputProvider); + default: + throw 'No case defined for the given subtaskFn'; } } else { // Get property diff --git a/app/imports/api/engine/loadCreatures.ts b/app/imports/api/engine/loadCreatures.ts index d8ac1cab..21339b1a 100644 --- a/app/imports/api/engine/loadCreatures.ts +++ b/app/imports/api/engine/loadCreatures.ts @@ -94,6 +94,36 @@ export function getPropertiesOfType(creatureId, propType) { return props; } +/** + * Get the properties of a creature that matches the filters given + * @param creatureId The id of the creature + * @param filterFn A function that returns true if the given prop matches the filter + * @param mongoFilter A mongo selector that is exactly equal to the above function + */ +export function getPropertiesByFilter(creatureId, filterFn: (any) => boolean, mongoFilter: Mongo.Selector) { + const creature = loadedCreatures.get(creatureId); + if (creature) { + const props: CreatureProperty[] = [] + for (const prop of creature.properties.values()) { + if (filterFn(prop)) { + props.push(prop); + } + } + props.sort((a, b) => a.left - b.left); + return EJSON.clone(props); + } + // console.time(`Cache miss on creature properties: ${creatureId}`) + const props = CreatureProperties.find({ + 'root.id': creatureId, + 'removed': { $ne: true }, + ...mongoFilter + }, { + sort: { left: 1 }, + }).fetch(); + // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); + return props; +} + export function getCreature(creatureId: string) { const loadedCreature = loadedCreatures.get(creatureId); const loadedCreatureDoc = loadedCreature?.creature; diff --git a/app/imports/client/ui/creature/RestButton.vue b/app/imports/client/ui/creature/RestButton.vue index 64244a43..a977899f 100644 --- a/app/imports/client/ui/creature/RestButton.vue +++ b/app/imports/client/ui/creature/RestButton.vue @@ -3,6 +3,7 @@ :loading="loading" :disabled="context.editPermission === false" outlined + :data-id="`rest-btn-${type}`" style="width: 160px;" @click="rest" > @@ -14,7 +15,7 @@ - -