From a04af366df3548da2c17c7dbf7dc628b5f158dad Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Jul 2024 16:23:34 -0700 Subject: [PATCH 1/2] [#1964] Move use method from item to activity Moves the functionality from `Item5e#use` and related helper methods into the activity mixin. Activities now have the `use` and `consume` public methods that act like they do on items. The old methods on the items will now forward calls along to the matching method on the first activity on the item. If no activity is found, then calling `Item5e#use` will just post the item's description to chat. Some changes have been made to the usage configuration objects to match the new rolling API configuration methods a bit better. So there is `usageConfig`, `dialogConfig`, and `messageConfig`, and a number of the options in usage config have been moved into objects to create a more clear separation. These methods introduce a new series of activity usage hooks and deprecate the old item usage hooks. An attempt has been made to continue calling those old hooks if necessary and translate between the old configuration objects and the new ones. Note: This PR does not yet implement scaling, consumption, or the usage dialog. --- .../activity/activity-usage-dialog.mjs | 26 + module/data/item/feat.mjs | 6 +- module/documents/activity/mixin.mjs | 581 +++++++++++++++++- module/documents/item.mjs | 220 ++----- templates/chat/activity-card.hbs | 66 ++ templates/chat/item-card.hbs | 135 ---- 6 files changed, 711 insertions(+), 323 deletions(-) create mode 100644 module/applications/activity/activity-usage-dialog.mjs create mode 100644 templates/chat/activity-card.hbs diff --git a/module/applications/activity/activity-usage-dialog.mjs b/module/applications/activity/activity-usage-dialog.mjs new file mode 100644 index 0000000000..89e88f8655 --- /dev/null +++ b/module/applications/activity/activity-usage-dialog.mjs @@ -0,0 +1,26 @@ +import Application5e from "../api/application.mjs"; + +/** + * Dialog for configuring the usage of an activity. + */ +export default class ActivityUsageDialog extends Application5e { + + // TODO: Implement activation dialog + + /* -------------------------------------------- */ + /* Factory Methods */ + /* -------------------------------------------- */ + + /** + * Display the activity usage dialog. + * @param {Activity} activity Activity to use. + * @param {ActivityUseConfiguration} config Configuration data for the usage. + * @param {object} options Additional options for the application. + * @returns {Promise} Form data object with results of the activation. + */ + static async create(activity, config, options) { + return new Promise(resolve => { + resolve({}); + }); + } +} diff --git a/module/data/item/feat.mjs b/module/data/item/feat.mjs index 87588117bf..d66e4a0ced 100644 --- a/module/data/item/feat.mjs +++ b/module/data/item/feat.mjs @@ -106,7 +106,8 @@ export default class FeatData extends ItemDataModel.mixin( { since: "DnD5e 4.0", until: "DnD5e 4.4" } ); return uses.period === "recharge" ? Number(uses.formula) : null; - } + }, + configurable: true }); Object.defineProperty(this.recharge, "charged", { get() { @@ -116,7 +117,8 @@ export default class FeatData extends ItemDataModel.mixin( { since: "DnD5e 4.0", until: "DnD5e 4.4" } ); return uses.value > 0; - } + }, + configurable: true }); } diff --git a/module/documents/activity/mixin.mjs b/module/documents/activity/mixin.mjs index 804638fd99..431867311c 100644 --- a/module/documents/activity/mixin.mjs +++ b/module/documents/activity/mixin.mjs @@ -1,3 +1,4 @@ +import ActivityUsageDialog from "../../applications/activity/activity-usage-dialog.mjs"; import PseudoDocumentMixin from "../mixins/pseudo-document.mjs"; /** @@ -10,9 +11,12 @@ export default Base => class extends PseudoDocumentMixin(Base) { * Configuration information for Activities. * * @typedef {PseudoDocumentsMetadata} ActivityMetadata - * @property {string} type Type name of this activity. - * @property {string} img Default icon. - * @property {string} title Default title. + * @property {string} type Type name of this activity. + * @property {string} img Default icon. + * @property {string} title Default title. + * @property {object} usage + * @property {string} usage.chatCard Template used to render the chat card. + * @property {typeof ActivityUsageDialog} usage.dialog Default usage prompt. */ /** @@ -20,13 +24,29 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @type {PseudoDocumentsMetadata} */ static metadata = Object.freeze({ - name: "Activity" + name: "Activity", + usage: { + chatCard: "systems/dnd5e/templates/chat/activity-card.hbs", + dialog: ActivityUsageDialog + } }); /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ + /** + * Is scaling possible with this activity? + * @type {boolean} + */ + get canScale() { + if ( !this.consumption.scaling.allowed ) return false; + if ( !this.isSpell ) return true; + return this.requiresSpellSlot; + } + + /* -------------------------------------------- */ + /** * Is this activity on a spell? * @type {boolean} @@ -37,6 +57,17 @@ export default Base => class extends PseudoDocumentMixin(Base) { /* -------------------------------------------- */ + /** + * Does activating this activity consume a spell slot? + * @type {boolean} + */ + get requiresSpellSlot() { + if ( !this.isSpell || !this.actor?.system.spellcasting ) return false; + return this.item.system.level > 0; + } + + /* -------------------------------------------- */ + /** * Consumption targets that can be use for this activity. * @type {Set} @@ -47,6 +78,548 @@ export default Base => class extends PseudoDocumentMixin(Base) { return types; } + /* -------------------------------------------- */ + /* Activation */ + /* -------------------------------------------- */ + + /** + * Configuration data for an activity usage being prepared. + * + * @typedef {object} ActivityUseConfiguration + * @property {object|false} create + * @property {boolean} create.measuredTemplate Should this item create a template? + * @property {object} concentration + * @property {boolean} concentration.begin Should this usage initiate concentration? + * @property {string|null} concentration.end ID of an active effect to end concentration on. + * @property {object|false} consume + * @property {boolean|string[]} consume.resources Set to `true` or `false` to enable or disable all resource + * consumption or provide a list of consumption type keys defined in + * `CONFIG.DND5E.activityConsumptionTypes` to only enable those types. + * @property {boolean} consume.spellSlot Should this spell consumes a spell slot? + * @property {Event} event The browser event which triggered the item usage, if any. + * @property {boolean|number} scaling Number of steps above baseline to scale this usage, or `false` if + * scaling is not allowed. + * @property {object} spell + * @property {number} spell.slot The spell slot to consume. + */ + + /** + * Data for the activity activation configuration dialog. + * + * @typedef {object} ActivityDialogConfiguration + * @property {boolean} [configure=true] Display a configuration dialog for the item usage, if applicable? + * @property {typeof ActivityActivationDialog} [applicationClass] Alternate activation dialog to use. + */ + + /** + * Message configuration for activity usage. + * + * @typedef {object} ActivityMessageConfiguration + * @property {boolean} [create=true] Whether to automatically create a chat message (if true) or simply return + * the prepared chat message data (if false). + * @property {object} [data={}] Additional data used when creating the message. + * @property {string} [rollMode] The roll display mode with which to display (or not) the card. + */ + + /** + * Details of final changes performed by the usage. + * + * @typedef {object} ActivityUsageResults + * @property {ActiveEffect5e[]} effects Active effects that were created or deleted. + * @property {ChatMessage5e|ActivityMessageConfiguration} message The chat message created for the activation, + * or the message data if create was `false`. + * @property {MeasuredTemplateDocument[]} templates Created measured templates. + * @property {ActivityUsageUpdates} updates Updates to the actor & items. + */ + + /** + * Activate this activity. + * @param {ActivityUseConfiguration} usage Configuration info for the activation. + * @param {ActivityDialogConfiguration} dialog Configuration info for the usage dialog. + * @param {ActivityMessageConfiguration} message Configuration info for the created chat message. + * @returns {Promise} Details on the usage process if not canceled. + */ + async use(usage={}, dialog={}, message={}) { + if ( !this.item.isOwner ) { + ui.notifications.error("DND5E.DocumentUseWarn", { localize: true }); + return; + } + + // Create an item clone to work with throughout the rest of the process + let item = this.item.clone({}, { keepId: true }); + item.prepareData(); + item.prepareFinalAttributes(); + let activity = item.system.activities.get(this.id); + + const usageConfig = activity._prepareUsageConfig(usage); + + const dialogConfig = foundry.utils.mergeObject({ + configure: true, + applicationClass: this.metadata.usage.dialog + }, dialog); + + const messageConfig = foundry.utils.mergeObject({ + create: true, + data: { + flags: { + dnd5e: { + activityType: this.type, activityId: this.id, activityUuid: this.uuid, + type: this.item.type, itemId: this.item.id, itemUuid: this.item.uuid + } + } + } + }, message); + + /** + * A hook event that fires before an activity usage is configured. + * @function dnd5e.preUseActivity + * @memberof hookEvents + * @param {Activity} activity Activity being used. + * @param {ActivityUseConfiguration} usageConfig Configuration info for the activation. + * @param {ActivityDialogConfiguration} dialogConfig Configuration info for the usage dialog. + * @param {ActivityMessageConfiguration} messageConfig Configuration info for the created chat message. + * @returns {boolean} Explicitly return `false` to prevent activity from being used. + */ + if ( Hooks.call("dnd5e.preUseActivity", activity, usageConfig, dialogConfig, messageConfig) === false ) return; + + if ( "dnd5e.preUseItem" in Hooks.events ) { + foundry.utils.logCompatibilityWarning( + "The `dnd5e.preUseItem` hook has been deprecated and replaced with `dnd5e.preActivateActivity`.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + const { config, options } = this._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig); + if ( Hooks.call("dnd5e.preUseItem", item, config, options) === false ) return; + this._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options); + } + + // Display configuration window if necessary + if ( dialogConfig.configure && activity._requiresConfigurationDialog(usageConfig) ) { + try { + await dialogConfig.applicationClass.create(activity, usageConfig, dialogConfig.options); + } catch(err) { + return; + } + } + + // Handle scaling + activity._prepareUsageScaling(usageConfig, messageConfig, item); + activity = item.system.activities.get(this.id); + + // Handle consumption + const updates = await activity.consume(usageConfig, messageConfig); + if ( updates === false ) return; + const results = { effects: [], templates: [], updates }; + + // Create concentration effect & end previous effects + if ( usageConfig.concentration?.begin ) { + const effect = await item.actor.beginConcentrating(item); + if ( effect ) { + results.effects ??= []; + results.effects.push(effect); + foundry.utils.setProperty(messageConfig.data, "flags.dnd5e.use.concentrationId", effect.id); + } + if ( usageConfig.concentration?.end ) { + const deleted = await item.actor.endConcentration(usageConfig.concentration.end); + results.effects.push(...deleted); + } + } + + // Create chat message + messageConfig.data.rolls = (messageConfig.data.rolls ?? []).concat(updates.rolls); + results.message = await activity._createUsageMessage(messageConfig); + + // Perform any final usage steps + await activity._finalizeUsage(usageConfig, results); + + /** + * A hook event that fires when an activity is activated. + * @function dnd5e.postUseActivity + * @memberof hookEvents + * @param {Activity} activity Activity being activated. + * @param {ActivityUseConfiguration} usageConfig Configuration data for the activation. + * @param {ActivityUsageResults} results Final details on the activation. + */ + Hooks.callAll("dnd5e.postUseActivity", activity, usageConfig, results); + + if ( "dnd5e.useItem" in Hooks.events ) { + foundry.utils.logCompatibilityWarning( + "The `dnd5e.useItem` hook has been deprecated and replaced with `dnd5e.postUseActivity`.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + const { config, options } = this._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig); + Hooks.callAll("dnd5e.itemUsageConsumption", item, config, options, results.templates, results.effects, null); + } + + return results; + } + + /* -------------------------------------------- */ + + /** + * Consume this activation's usage. + * @param {ActivityUseConfiguration} usageConfig Usage configuration. + * @param {ActivityMessageConfiguration} messageConfig Configuration data for the chat message. + * @returns {ActivityUsageUpdates|false} + */ + async consume(usageConfig, messageConfig) { + /** + * A hook event that fires before an item's resource consumption is calculated. + * @function dnd5e.preActivityConsumption + * @memberof hookEvents + * @param {Activity} activity Activity being activated. + * @param {ActivityUseConfiguration} usageConfig Configuration data for the activation. + * @param {ActivityMessageConfiguration} messageConfig Configuration info for the created chat message. + * @returns {boolean} Explicitly return `false` to prevent activity from being activated. + */ + if ( Hooks.call("dnd5e.preActivityConsumption", this, usageConfig, messageConfig) === false ) return; + + if ( "dnd5e.preItemUsageConsumption" in Hooks.events ) { + foundry.utils.logCompatibilityWarning( + "The `dnd5e.preItemUsageConsumption` hook has been deprecated and replaced with `dnd5e.preActivityConsumption`.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + const { config, options } = this._createDeprecatedConfigs(usageConfig, {}, messageConfig); + if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return; + this._applyDeprecatedConfigs(usageConfig, {}, messageConfig, config, options); + } + + const updates = await this._prepareUsageUpdates(usageConfig); + if ( !updates ) return false; + + foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.consumed", usageConfig.consume); + + /** + * A hook event that fires after an item's resource consumption is calculated, but before an updates are performed. + * @function dnd5e.activityConsumption + * @memberof hookEvents + * @param {Activity} activity Activity being activated. + * @param {ActivityUseConfiguration} usageConfig Configuration data for the activation. + * @param {ActivityMessageConfiguration} messageConfig Configuration info for the created chat message. + * @param {ActivityUsageUpdates} updates Updates to apply to the actor and other documents. + * @returns {boolean} Explicitly return `false` to prevent activity from being activated. + */ + if ( Hooks.call("dnd5e.activityConsumption", this, usageConfig, messageConfig, updates) === false ) return; + + if ( "dnd5e.itemUsageConsumption" in Hooks.events ) { + foundry.utils.logCompatibilityWarning( + "The `dnd5e.itemUsageConsumption` hook has been deprecated and replaced with `dnd5e.activityConsumption`.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + const { config, options } = this._createDeprecatedConfigs(usageConfig, {}, messageConfig); + const usage = { + actorUpdates: updates.actor, + deleteIds: updates.delete, + itemUpdates: updates.item.find(i => i._id === this.item.id), + resourceUpdates: updates.item.filter(i => i._id !== this.item.id) + }; + if ( Hooks.call("dnd5e.itemUsageConsumption", item, config, options, usage) === false ) return; + this._applyDeprecatedConfigs(usageConfig, {}, messageConfig, config, options); + updates.actor = usage.actorUpdates; + updates.delete = usage.deleteIds; + updates.item = usage.resourceUpdates; + if ( !foundry.utils.isEmpty(usage.itemUpdates) ) updates.item.push({ _id: this.item.id, ...usage.itemUpdates }); + } + + // Merge activity changes into the item updates + if ( !foundry.utils.isEmpty(updates.activity) ) { + const itemIndex = updates.item.findIndex(i => i._id === this.item.id); + const keyPath = `system.activities.${this.id}`; + const activityUpdates = foundry.utils.expandObject(updates.activity); + if ( itemIndex === -1 ) updates.item.push({ _id: this.item.id, [keyPath]: activityUpdates }); + else updates.item[itemIndex][keyPath] = activityUpdates; + } + + // Update documents with consumption + if ( !foundry.utils.isEmpty(updates.actor) ) await this.actor.update(updates.actor); + if ( !foundry.utils.isEmpty(updates.delete) ) await this.actor.deleteEmbeddedDocuments("Item", updates.delete); + if ( !foundry.utils.isEmpty(updates.item) ) await this.actor.updateEmbeddedDocuments("Item", updates.item); + + /** + * A hook event that fires after an item's resource consumption is calculated and applied. + * @function dnd5e.postActivityConsumption + * @memberof hookEvents + * @param {Activity} activity Activity being activated. + * @param {ActivityUseConfiguration} usageConfig Configuration data for the activation. + * @param {ActivityMessageConfiguration} messageConfig Configuration info for the created chat message. + * @param {ActivityUsageUpdates} updates Applied updates to the actor and other documents. + * @returns {boolean} Explicitly return `false` to prevent activity from being activated. + */ + if ( Hooks.call("dnd5e.postActivityConsumption", this, usageConfig, messageConfig, updates) === false ) return; + + return updates; + } + + /* -------------------------------------------- */ + + /** + * Translate new config objects back into old config objects for deprecated hooks. + * @param {ActivityUseConfiguration} usageConfig + * @param {ActivityDialogConfiguration} dialogConfig + * @param {ActivityMessageConfiguration} messageConfig + * @returns {{ config: ItemUseConfiguration, options: ItemUseOptions }} + * @internal + */ + _createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig) { + return { + config: { + createMeasuredTemplate: usageConfig.create?.measuredTemplate ?? null, + consumeResource: usageConfig.consume?.resources !== false ?? null, + consumeSpellSlot: usageConfig.consume?.spellSlot !== false ?? null, + consumeUsage: (usageConfig.consume?.resources.includes("itemUses") + || usageConfig.consume?.resources.includes("activityUses")) ?? null, + slotLevel: usageConfig.spell?.slot ?? null, + resourceAmount: usageConfig.scaling ?? null, + beginConcentrating: usageConfig.concentration?.begin ?? false, + endConcentration: usageConfig.concentration?.end ?? null + }, + options: { + configureDialog: dialogConfig.configure, + rollMode: messageConfig.rollMode, + createMessage: messageConfig.create, + flags: messageConfig.data?.flags, + event: usageConfig.event + } + }; + } + + /* -------------------------------------------- */ + + /** + * Apply changes from old config objects back onto new config objects. + * @param {ActivityUseConfiguration} usageConfig + * @param {ActivityDialogConfiguration} dialogConfig + * @param {ActivityMessageConfiguration} messageConfig + * @param {ItemUseConfiguration} config + * @param {ItemUseOptions} options + * @internal + */ + _applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options) { + const usageTypes = ["activityUses", "itemUses"]; + let resources; + if ( config.consumeResource && config.consumeUsage ) resources = true; + else if ( config.consumeResource && (config.consumeUsage === false) ) { + resources = Array.from(Object.keys(CONFIG.DND5E.activityConsumptionTypes)).filter(k => !usageTypes.includes(k)); + } + else if ( (config.consumeResource === false) && config.consumeUsage ) resources = usageTypes; + + foundry.utils.mergeObject(usageConfig, { + create: { + measuredTemplate: config.createMeasuredTemplate + }, + concentration: { + begin: config.beginConcentrating, + end: config.endConcentration + }, + consume: { + resources, + spellSlot: config.consumeSpellSlot + }, + scaling: config.resourceAmount, + spell: { + slot: config.slotLevel + } + }); + foundry.utils.mergeObject(dialogConfig, { + configure: options.configureDialog + }); + foundry.utils.mergeObject(messageConfig, { + create: options.createMessage, + rollMode: options.rollMode, + data: { + flags: options.flags + } + }); + } + + /* -------------------------------------------- */ + + /** + * Prepare usage configuration with the necessary defaults. + * @param {ActivityUseConfiguration} config Configuration object passed to the `use` method. + * @returns {ActivityUseConfiguration} + * @protected + */ + _prepareUsageConfig(config) { + config = foundry.utils.deepClone(config); + + if ( config.create !== false ) { + config.create ??= {}; + config.create.measuredTemplate ??= !!this.target.template.type; + // TODO: Re-implement `system.target.prompt` from item data + // TODO: Handle permissions checks in `ActivityUsageDialog` + } + + if ( config.consume !== false ) { + config.consume ??= {}; + config.consume.resources ??= this.consumption.targets.length > 0; + config.consume.spellSlot ??= this.requiresSpellSlot; + } + + if ( this.canScale ) config.scaling ??= 0; + else config.scaling = false; + + if ( this.isSpell ) { + const mode = this.item.system.preparation.mode; + config.spell ??= {}; + config.spell.slot ??= (mode in this.actor.system.spells) ? mode : `spell${this.item.system.level}`; + } + + if ( this.item.requiresConcentration && !game.settings.get("dnd5e", "disableConcentration") ) { + config.concentration ??= {}; + config.concentration.begin ??= true; + const { effects } = this.actor.concentration; + const limit = this.actor.system.attributes?.concentration?.limit ?? 0; + if ( limit && (limit <= effects.size) ) config.endConcentration = effects.find(e => { + const data = e.flags.dnd5e?.itemData ?? {}; + return (data === this.id) || (data._id === this.id); + })?.id ?? effects.first()?.id ?? null; + } + + return config; + } + + /* -------------------------------------------- */ + + /** + * Determine scaling values and update item clone if necessary. + * @param {ActivityUseConfiguration} usageConfig Configuration data for the activation. + * @param {ActivityMessageConfiguration} messageConfig Configuration data for the chat message. + * @param {Item5e} item Clone of the item that contains this activity. + * @protected + */ + _prepareUsageScaling(usageConfig, messageConfig, item) { + // TODO: Implement scaling + } + + /* -------------------------------------------- */ + + /** + * Calculate changes to actor, items, & this activity based on resource consumption. + * @param {ActivityUseConfiguration} config Usage configuration. + * @returns {ActivityUsageUpdates} + * @protected + */ + async _prepareUsageUpdates(config) { + const updates = { activity: {}, actor: {}, delete: [], item: [], rolls: [] }; + // TODO: Handle consumption + return updates; + } + + /* -------------------------------------------- */ + + /** + * Determine if the configuration dialog is required based on the configuration options. Does not guarantee a dialog + * is shown if the dialog is suppressed in the activation dialog configuration. + * @param {ActivityUseConfiguration} config + * @returns {boolean} + * @protected + */ + _requiresConfigurationDialog(config) { + if ( (foundry.utils.getType(config.consume) === "object") && Object.values(config.consume).some(v => v) ) { + return true; + } + if ( config.scaling !== false ) return true; + return false; + } + + /* -------------------------------------------- */ + + /** + * Prepare the context used to render the usage chat card. + * @returns {object} + * @protected + */ + async _usageChatContext() { + const data = await this.item.system.getCardData(); + const properties = [...(data.tags ?? []), ...(data.properties ?? [])]; + const supplements = []; + if ( (this.activation.type === "reaction") && this.activation.condition ) { + supplements.push(`${game.i18n.localize("DND5E.Reaction")} ${this.activation.condition}`); + } + if ( data.materials?.value ) { + supplements.push(`${game.i18n.localize("DND5E.Materials")} ${data.materials.value}`); + } + return { + activity: this, + actor: this.item.actor, + item: this.item, + token: this.item.actor?.token, + buttons: null, + description: data.description.chat, + properties: properties.length ? properties : null, + subtitle: data.subtitle ?? this.item.system.chatFlavor, + supplements + }; + } + + /* -------------------------------------------- */ + + /** + * Display a chat message for this usage. + * @param {ActivityMessageConfiguration} message Configuration info for the created message. + * @returns {Promise} + * @protected + */ + async _createUsageMessage(message) { + const context = await this._usageChatContext(); + const messageConfig = foundry.utils.mergeObject({ + rollMode: game.settings.get("core", "rollMode"), + data: { + content: await renderTemplate(this.metadata.usage.chatCard, context), + speaker: ChatMessage.getSpeaker({ actor: this.item.actor }), + flags: { + core: { canPopout: true } + } + } + }, message); + + /** + * A hook event that fires before an activity usage card is created. + * @function dnd5e.preCreateUsageMessage + * @memberof hookEvents + * @param {Activity} activity Activity for which the card will be created. + * @param {ActivityMessageConfiguration} message Configuration info for the created message. + */ + Hooks.callAll("dnd5e.preCreateUsageMessage", this, messageConfig); + + ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode); + const card = messageConfig.create === false ? messageConfig : await ChatMessage.create(messageConfig.data); + + /** + * A hook event that fires after an activity usage card is created. + * @function dnd5e.postCreateUsageMessage + * @memberof hookEvents + * @param {Activity} activity Activity for which the card was created. + * @param {ChatMessage5e|ActivityMessageConfiguration} card Created card or configuration data if not created. + */ + Hooks.callAll("dnd5e.postCreateUsageMessage", this, card); + + return card; + } + + /* -------------------------------------------- */ + + /** + * Perform any final steps of the activation including creating measured templates. + * @param {ActivityUseConfiguration} config Configuration data for the activation. + * @param {ActivityUsageResults} results Final details on the activation. + * @protected + */ + async _finalizeUsage(config, results) { + results.templates = []; + if ( config.create?.measuredTemplate ) { + try { + results.templates = await (dnd5e.canvas.AbilityTemplate.fromItem(this.item))?.drawPreview(); + } catch(err) { + Hooks.onError("Activity#use", err, { + msg: game.i18n.localize("DND5E.PlaceTemplateError"), + log: "error", + notify: "error" + }); + } + } + } + /* -------------------------------------------- */ /* Helpers */ /* -------------------------------------------- */ diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 05270bb712..8f35db8cae 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -902,52 +902,27 @@ export default class Item5e extends SystemDocumentMixin(Item) { * @param {ItemUseOptions} [options] Options used for configuring item usage. * @returns {Promise} Chat message if options.createMessage is true, message data if it is * false, and nothing if the roll wasn't performed. + * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4 */ async use(config={}, options={}) { - if ( !this.isOwner ) { - ui.notifications.error("DND5E.DocumentUseWarn", { localize: true }); - return null; - } - let item = this; - const is = item.system; - const as = item.actor.system; - - // Ensure the options object is ready - options = foundry.utils.mergeObject({ - configureDialog: true, - createMessage: true, - "flags.dnd5e.use": {type: this.type, itemId: this.id, itemUuid: this.uuid} - }, options); - - // Define follow-up actions resulting from the item usage - if ( config.consumeSlotLevel ) { - console.warn("You are passing 'consumeSlotLevel' to the ItemUseConfiguration object, which now expects a key as 'slotLevel'."); - config.slotLevel = config.consumeSlotLevel; - delete config.consumeSlotLevel; - } - config = foundry.utils.mergeObject(this._getUsageConfig(), config); - - /** - * A hook event that fires before an item usage is configured. - * @function dnd5e.preUseItem - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. - * @param {ItemUseOptions} options Additional options used for configuring item usage. - * @returns {boolean} Explicitly return `false` to prevent item from being used. - */ - if ( Hooks.call("dnd5e.preUseItem", item, config, options) === false ) return; - - // Are any default values necessitating a prompt? - const needsConfiguration = Object.values(config).includes(true); - - // Display configuration dialog - if ( (options.configureDialog !== false) && needsConfiguration ) { - const configuration = await AbilityUseDialog.create(item, config); - if ( !configuration ) return; - foundry.utils.mergeObject(config, configuration); + foundry.utils.logCompatibilityWarning( + "The `Item5e#use` method has been deprecated and should now be called directly on the activity to be used.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + if ( this.system.activities ) { + const activity = Array.from(this.system.activities.values())[0]; + if ( activity ) { + const usageConfig = {}; + const dialogConfig = {}; + const messageConfig = {}; + activity._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options); + return activity.use(usageConfig, dialogConfig, messageConfig); + } } + if ( this.actor ) return this.displayCard(); + else return; + // TODO: Remove this reference code once EnchantmentActivity and SummoningActivity are created and scaling is added // Store selected enchantment profile in flag if ( config.enchantmentProfile ) { foundry.utils.setProperty(options.flags, "dnd5e.use.enchantmentProfile", config.enchantmentProfile); @@ -980,37 +955,6 @@ export default class Item5e extends SystemDocumentMixin(Item) { // Calculate and consume item consumption if ( await this.consume(item, config, options) === false ) return; - // Initiate or end concentration. - const effects = []; - if ( config.beginConcentrating ) { - const effect = await item.actor.beginConcentrating(item); - if ( effect ) { - effects.push(effect); - foundry.utils.setProperty(options.flags, "dnd5e.use.concentrationId", effect.id); - } - if ( config.endConcentration ) { - const deleted = await item.actor.endConcentration(config.endConcentration); - effects.push(...deleted); - } - } - - // Prepare card data & display it if options.createMessage is true - const cardData = await item.displayCard(options); - - // Initiate measured template creation - let templates; - if ( config.createMeasuredTemplate ) { - try { - templates = await (dnd5e.canvas.AbilityTemplate.fromItem(item))?.drawPreview(); - } catch(err) { - Hooks.onError("Item5e#use", err, { - msg: game.i18n.localize("DND5E.PlaceTemplateError"), - log: "error", - notify: "error" - }); - } - } - // Initiate summons creation let summoned; if ( config.createSummons ) { @@ -1020,21 +964,6 @@ export default class Item5e extends SystemDocumentMixin(Item) { Hooks.onError("Item5e#use", err, { log: "error", notify: "error" }); } } - - /** - * A hook event that fires when an item is used, after the measured template has been created if one is needed. - * @function dnd5e.useItem - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the roll. - * @param {ItemUseOptions} options Additional options for configuring item usage. - * @param {MeasuredTemplateDocument[]|null} templates The measured templates if they were created. - * @param {ActiveEffect5e[]} effects The active effects that were created or deleted. - * @param {TokenDocument5e[]|null} summoned Summoned tokens if they were created. - */ - Hooks.callAll("dnd5e.useItem", item, config, options, templates ?? null, effects, summoned ?? null); - - return cardData; } /* -------------------------------------------- */ @@ -1045,117 +974,44 @@ export default class Item5e extends SystemDocumentMixin(Item) { * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. * @param {ItemUseOptions} options Additional options used for configuring item usage. * @returns {false|void} Returns `false` if any further usage should be canceled. + * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4 */ async consume(item, config, options) { - /** - * A hook event that fires before an item's resource consumption has been calculated. - * @function dnd5e.preItemUsageConsumption - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. - * @param {ItemUseOptions} options Additional options used for configuring item usage. - * @returns {boolean} Explicitly return `false` to prevent item from being used. - */ - if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return false; - - // Determine whether the item can be used by testing the chosen values of the config. - const usage = item._getUsageUpdates(config); - if ( !usage ) return false; - - options.flags ??= {}; - if ( config.consumeUsage ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedUsage", true); - if ( config.consumeResource ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedResource", true); - if ( config.consumeSpellSlot ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedSpellSlot", true); - - /** - * A hook event that fires after an item's resource consumption has been calculated but before any - * changes have been made. - * @function dnd5e.itemUsageConsumption - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. - * @param {ItemUseOptions} options Additional options used for configuring item usage. - * @param {object} usage - * @param {object} usage.actorUpdates Updates that will be applied to the actor. - * @param {object} usage.itemUpdates Updates that will be applied to the item being used. - * @param {object[]} usage.resourceUpdates Updates that will be applied to other items on the actor. - * @param {Set} usage.deleteIds Item ids for those which consumption will delete. - * @returns {boolean} Explicitly return `false` to prevent item from being used. - */ - if ( Hooks.call("dnd5e.itemUsageConsumption", item, config, options, usage) === false ) return false; - - // Commit pending data updates - const { actorUpdates, itemUpdates, resourceUpdates, deleteIds } = usage; - if ( !foundry.utils.isEmpty(itemUpdates) ) await item.update(itemUpdates); - if ( !foundry.utils.isEmpty(deleteIds) ) await this.actor.deleteEmbeddedDocuments("Item", [...deleteIds]); - if ( !foundry.utils.isEmpty(actorUpdates) ) await this.actor.update(actorUpdates); - if ( !foundry.utils.isEmpty(resourceUpdates) ) await this.actor.updateEmbeddedDocuments("Item", resourceUpdates); + foundry.utils.logCompatibilityWarning( + "The `Item5e#consume` method has been deprecated and should now be called directly on the activity.", + { since: "DnD5e 4.0", until: "DnD5e 4.4" } + ); + if ( this.system.activities ) { + const activity = Array.from(this.system.activities.values())[0]; + if ( activity ) { + const usageConfig = {}; + const dialogConfig = {}; + const messageConfig = {}; + activity._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options); + return activity.consume(usageConfig, messageConfig); + } + } + return false; } /* -------------------------------------------- */ - /** - * Prepare an object of possible and default values for item usage. A value that is `null` is ignored entirely. - * @returns {ItemUseConfiguration} Configuration data for the roll. - */ + /** @deprecated */ _getUsageConfig() { - const { consume, uses, summons, target, level, preparation } = this.system; - - const config = { - createMeasuredTemplate: null, - createSummons: null, - consumeResource: null, - consumeSpellSlot: null, - consumeUsage: null, - enchantmentProfile: null, - promptEnchantment: null, - slotLevel: null, - summonsProfile: null, - resourceAmount: null, - beginConcentrating: null, - endConcentration: null - }; + return; + + // TODO: Remove this reference code once EnchantmentActivity & SummoningActivity are created - const scaling = this.usageScaling; - if ( scaling === "slot" ) { - const spells = this.actor.system.spells ?? {}; - config.consumeSpellSlot = true; - config.slotLevel = (preparation?.mode in spells) ? preparation.mode : `spell${level}`; - } else if ( scaling === "resource" ) { - config.resourceAmount = consume.amount || 1; - } - if ( this.hasLimitedUses ) config.consumeUsage = uses.prompt; - if ( this.hasResource ) { - config.consumeResource = true; - // Do not suggest consuming your own uses if also consuming them through resources. - if ( consume.target === this.id ) config.consumeUsage = null; - } - if ( game.user.can("TEMPLATE_CREATE") && this.hasAreaTarget && canvas.scene ) { - config.createMeasuredTemplate = target.prompt; - } if ( this.system.isEnchantment ) { const availableEnchantments = EnchantmentData.availableEnchantments(this); config.promptEnchantment = availableEnchantments.length > 1; config.enchantmentProfile = availableEnchantments[0]?.id; } + if ( this.system.hasSummoning && this.system.summons.canSummon && canvas.scene ) { config.createSummons = summons.prompt; config.summonsProfile = this.system.summons.profiles[0]._id; } - if ( this.requiresConcentration && !game.settings.get("dnd5e", "disableConcentration") ) { - config.beginConcentrating = true; - const { effects } = this.actor.concentration; - const limit = this.actor.system.attributes?.concentration?.limit ?? 0; - if ( limit && (limit <= effects.size) ) { - const id = effects.find(e => { - const data = e.flags.dnd5e?.itemData ?? {}; - return (data === this.id) || (data._id === this.id); - })?.id ?? effects.first()?.id ?? null; - config.endConcentration = id; - } - } - - return config; } /* -------------------------------------------- */ diff --git a/templates/chat/activity-card.hbs b/templates/chat/activity-card.hbs new file mode 100644 index 0000000000..1a3f3b9e2f --- /dev/null +++ b/templates/chat/activity-card.hbs @@ -0,0 +1,66 @@ +
+
+
+ {{ item.name }} +
+ {{ item.name }} + {{#if subtitle}} + {{{ subtitle }}} + {{/if}} +
+ +
+
+
{{{ description }}}
+
+
+ + {{#if buttons}} +
+ {{#each buttons}} + + {{/each}} +
+ {{/if}} + + {{#each supplements}} +

{{{ this }}}

+ {{/each}} + + {{#if properties}} + + {{/if}} + + {{!-- TODO + + --}} +
diff --git a/templates/chat/item-card.hbs b/templates/chat/item-card.hbs index 00747f5b8a..e289b0e00c 100644 --- a/templates/chat/item-card.hbs +++ b/templates/chat/item-card.hbs @@ -27,115 +27,6 @@ - {{!-- Item Actions --}} - {{#if hasButtons}} -
- - {{!-- Attacks --}} - {{#if hasAttack}} - - {{/if}} - - {{!-- Damage / Healing --}} - {{#if hasDamage}} - - {{/if}} - - {{!-- Versatile --}} - {{#if isVersatile}} - - {{/if}} - - {{!-- Saving Throw --}} - {{#if hasSave}} - {{#with item.system.save}} - - {{/with}} - {{/if}} - - {{!-- Other Formula --}} - {{#if item.system.formula}} - - {{/if}} - - {{!-- Templates --}} - {{#if hasAreaTarget}} - - {{/if}} - - {{!-- Summoning --}} - {{#if item.system.hasSummoning}} - - {{/if}} - - {{!-- Tool Check --}} - {{#if isTool}} - - {{/if}} - - {{!-- Ability Check --}} - {{#if hasAbilityCheck}} - - {{/if}} - - {{!-- Consume Use --}} - {{#if consumeUsage}} - - {{/if}} - - {{!-- Consume Resource --}} - {{#if consumeResource}} - - {{/if}} -
- {{/if}} - - {{!-- Reactions --}} - {{#if (and data.activation.condition (eq data.activation.type "reaction"))}} -

{{ localize "DND5E.Reaction" }}{{ data.activation.condition }}

- {{/if}} - {{!-- Materials --}} {{#if data.materials.value}}

{{ localize "DND5E.Materials" }}{{ data.materials.value }}

@@ -156,30 +47,4 @@ {{/each}} {{/if}} - - {{!-- Applicable Effects --}} - From 7cdc26045f05a8142f8b9a02fa2ef6802212ddd8 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 31 Jul 2024 17:24:01 -0700 Subject: [PATCH 2/2] [#1964] Adjust canScale logic, fix various other issues Adjusted the logic of `canScale` on activities so now scaling is controlled entirely by `allowScaling` for non-spells, and that checkbox is ignored entirely on spells and scaling is simeply handled by whether the spell is a cantrip or not. --- .../applications/activity/activity-sheet.mjs | 3 +- module/documents/activity/mixin.mjs | 59 +++++++++++-------- module/documents/item.mjs | 4 +- .../activity/parts/activity-consumption.hbs | 2 + 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/module/applications/activity/activity-sheet.mjs b/module/applications/activity/activity-sheet.mjs index 646d308a25..643e038134 100644 --- a/module/applications/activity/activity-sheet.mjs +++ b/module/applications/activity/activity-sheet.mjs @@ -199,8 +199,7 @@ export default class ActivitySheet extends Application5e { }; // Consumption targets - const canScale = this.activity.consumption.scaling.allowed - || (this.item.type === "spell" && this.item.system.level !== 0); + const canScale = this.activity.canScale; const consumptionTypeOptions = Array.from(this.activity.validConsumptionTypes).map(value => ({ value, label: CONFIG.DND5E.activityConsumptionTypes[value].label diff --git a/module/documents/activity/mixin.mjs b/module/documents/activity/mixin.mjs index 431867311c..623af7b885 100644 --- a/module/documents/activity/mixin.mjs +++ b/module/documents/activity/mixin.mjs @@ -40,9 +40,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @type {boolean} */ get canScale() { - if ( !this.consumption.scaling.allowed ) return false; - if ( !this.isSpell ) return true; - return this.requiresSpellSlot; + return this.consumption.scaling.allowed || (this.isSpell && this.item.system.level > 0); } /* -------------------------------------------- */ @@ -62,7 +60,8 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @type {boolean} */ get requiresSpellSlot() { - if ( !this.isSpell || !this.actor?.system.spellcasting ) return false; + if ( !this.isSpell || !this.actor?.system.spells ) return false; + // TODO: Check against specific preparation modes here return this.item.system.level > 0; } @@ -95,7 +94,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @property {boolean|string[]} consume.resources Set to `true` or `false` to enable or disable all resource * consumption or provide a list of consumption type keys defined in * `CONFIG.DND5E.activityConsumptionTypes` to only enable those types. - * @property {boolean} consume.spellSlot Should this spell consumes a spell slot? + * @property {boolean} consume.spellSlot Should this spell consume a spell slot? * @property {Event} event The browser event which triggered the item usage, if any. * @property {boolean|number} scaling Number of steps above baseline to scale this usage, or `false` if * scaling is not allowed. @@ -109,6 +108,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @typedef {object} ActivityDialogConfiguration * @property {boolean} [configure=true] Display a configuration dialog for the item usage, if applicable? * @property {typeof ActivityActivationDialog} [applicationClass] Alternate activation dialog to use. + * @property {object} [options] Options passed through to the dialog. */ /** @@ -125,11 +125,11 @@ export default Base => class extends PseudoDocumentMixin(Base) { * Details of final changes performed by the usage. * * @typedef {object} ActivityUsageResults - * @property {ActiveEffect5e[]} effects Active effects that were created or deleted. - * @property {ChatMessage5e|ActivityMessageConfiguration} message The chat message created for the activation, - * or the message data if create was `false`. - * @property {MeasuredTemplateDocument[]} templates Created measured templates. - * @property {ActivityUsageUpdates} updates Updates to the actor & items. + * @property {ActiveEffect5e[]} effects Active effects that were created or deleted. + * @property {ChatMessage5e|object} message The chat message created for the activation, or the message data + * if `create` in ActivityMessageConfiguration was `false`. + * @property {MeasuredTemplateDocument[]} templates Created measured templates. + * @property {ActivityUsageUpdates} updates Updates to the actor & items. */ /** @@ -163,8 +163,8 @@ export default Base => class extends PseudoDocumentMixin(Base) { data: { flags: { dnd5e: { - activityType: this.type, activityId: this.id, activityUuid: this.uuid, - type: this.item.type, itemId: this.item.id, itemUuid: this.item.uuid + activity: { type: this.type, id: this.id, uuid: this.uuid }, + item: { type: this.item.type, id: this.item.id, uuid: this.item.uuid } } } } @@ -184,7 +184,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { if ( "dnd5e.preUseItem" in Hooks.events ) { foundry.utils.logCompatibilityWarning( - "The `dnd5e.preUseItem` hook has been deprecated and replaced with `dnd5e.preActivateActivity`.", + "The `dnd5e.preUseItem` hook has been deprecated and replaced with `dnd5e.preUseItem`.", { since: "DnD5e 4.0", until: "DnD5e 4.4" } ); const { config, options } = this._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig); @@ -289,7 +289,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.consumed", usageConfig.consume); /** - * A hook event that fires after an item's resource consumption is calculated, but before an updates are performed. + * A hook event that fires after an item's resource consumption is calculated, but before any updates are performed. * @function dnd5e.activityConsumption * @memberof hookEvents * @param {Activity} activity Activity being activated. @@ -469,7 +469,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { config.concentration.begin ??= true; const { effects } = this.actor.concentration; const limit = this.actor.system.attributes?.concentration?.limit ?? 0; - if ( limit && (limit <= effects.size) ) config.endConcentration = effects.find(e => { + if ( limit && (limit <= effects.size) ) config.concentration.end = effects.find(e => { const data = e.flags.dnd5e?.itemData ?? {}; return (data === this.id) || (data._id === this.id); })?.id ?? effects.first()?.id ?? null; @@ -493,6 +493,17 @@ export default Base => class extends PseudoDocumentMixin(Base) { /* -------------------------------------------- */ + /** + * Update data produced by activity usage. + * + * @typedef {object} ActivityUsageUpdates + * @property {object} activity Updates applied to activity that performed the activation. + * @property {object} actor Updates applied to the actor that performed the activation. + * @property {string[]} delete IDs of items to be deleted from the actor. + * @property {object[]} item Updates applied to items on the actor that performed the activation. + * @property {Roll[]} rolls Any rolls performed as part of the activation. + */ + /** * Calculate changes to actor, items, & this activity based on resource consumption. * @param {ActivityUseConfiguration} config Usage configuration. @@ -515,11 +526,11 @@ export default Base => class extends PseudoDocumentMixin(Base) { * @protected */ _requiresConfigurationDialog(config) { - if ( (foundry.utils.getType(config.consume) === "object") && Object.values(config.consume).some(v => v) ) { - return true; - } - if ( config.scaling !== false ) return true; - return false; + const checkObject = obj => (foundry.utils.getType(obj) === "Object") && Object.values(obj).some(v => v); + return config.concentration?.begin === true + || checkObject(config.create) + || checkObject(config.consume) + || (config.scaling !== false); } /* -------------------------------------------- */ @@ -557,7 +568,7 @@ export default Base => class extends PseudoDocumentMixin(Base) { /** * Display a chat message for this usage. * @param {ActivityMessageConfiguration} message Configuration info for the created message. - * @returns {Promise} + * @returns {Promise} * @protected */ async _createUsageMessage(message) { @@ -583,14 +594,14 @@ export default Base => class extends PseudoDocumentMixin(Base) { Hooks.callAll("dnd5e.preCreateUsageMessage", this, messageConfig); ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode); - const card = messageConfig.create === false ? messageConfig : await ChatMessage.create(messageConfig.data); + const card = messageConfig.create === false ? messageConfig.data : await ChatMessage.create(messageConfig.data); /** * A hook event that fires after an activity usage card is created. * @function dnd5e.postCreateUsageMessage * @memberof hookEvents - * @param {Activity} activity Activity for which the card was created. - * @param {ChatMessage5e|ActivityMessageConfiguration} card Created card or configuration data if not created. + * @param {Activity} activity Activity for which the card was created. + * @param {ChatMessage5e|object} card Created card or configuration data if not created. */ Hooks.callAll("dnd5e.postCreateUsageMessage", this, card); diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 8f35db8cae..6ecb15f04e 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -910,7 +910,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { { since: "DnD5e 4.0", until: "DnD5e 4.4" } ); if ( this.system.activities ) { - const activity = Array.from(this.system.activities.values())[0]; + const activity = this.system.activities.contents[0]; if ( activity ) { const usageConfig = {}; const dialogConfig = {}; @@ -982,7 +982,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { { since: "DnD5e 4.0", until: "DnD5e 4.4" } ); if ( this.system.activities ) { - const activity = Array.from(this.system.activities.values())[0]; + const activity = this.system.activities.contents[0]; if ( activity ) { const usageConfig = {}; const dialogConfig = {}; diff --git a/templates/activity/parts/activity-consumption.hbs b/templates/activity/parts/activity-consumption.hbs index 9b67690189..cab09f198e 100644 --- a/templates/activity/parts/activity-consumption.hbs +++ b/templates/activity/parts/activity-consumption.hbs @@ -29,6 +29,7 @@ + {{#unless activity.isSpell}} {{#with fields.consumption.fields.scaling.fields as |fields|}}
{{ localize "DND5E.ACTIVITY.FIELDS.consumption.scaling.label" }} @@ -38,6 +39,7 @@ {{/if}}
{{/with}} + {{/unless}} {{> "systems/dnd5e/templates/shared/uses-values.hbs" }} {{> "systems/dnd5e/templates/shared/uses-recovery.hbs" }}