diff --git a/lang/en.json b/lang/en.json index a764ec5cf8..f345048b52 100644 --- a/lang/en.json +++ b/lang/en.json @@ -444,6 +444,7 @@ "DND5E.Concentration": "Concentration", "DND5E.ConcentrationAbbr": "C", "DND5E.ConcentrationBreak": "Break Concentration", +"DND5E.ConcentrationBreakWarning": "Breaking concentration on an effect that is active on other creatures requires an active GM to be present.", "DND5E.ConcentrationBonus": "Concentration Bonus", "DND5E.ConcentrationDuration": "Concentration, up to {duration}", "DND5E.ConcentratingOn": "You are maintaining concentration on the effects of the '{name}' {type}.", @@ -668,7 +669,8 @@ "DND5E.Effect": "Effect", "DND5E.Effects": "Effects", "DND5E.EffectsApplyTokens": "Apply to selected tokens", -"DND5E.EffectApplyWarning": "Effects cannot be applied to tokens you are not the owner of.", +"DND5E.EffectApplyWarningConcentration": "Applying an effect that is being concentrated on by another character requires GM permissions.", +"DND5E.EffectApplyWarningOwnership": "Effects cannot be applied to tokens you are not the owner of.", "DND5E.EffectsSearch": "Search effects", "DND5E.Environment": "Environment", "DND5E.EquipmentBonus": "Magical Bonus", diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 56ca1e3941..5f22402c4d 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -339,6 +339,26 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ + /** @inheritDoc */ + async _preDelete(options, user) { + const dependents = this.getDependents(); + if ( dependents.length && !game.users.activeGM ) { + ui.notifications.warn("DND5E.ConcentrationBreakWarning", { localize: true }); + return false; + } + return super._preDelete(options, user); + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _onDelete(options, userId) { + super._onDelete(options, userId); + if ( game.user === game.users.activeGM ) this.getDependents().forEach(e => e.delete()); + } + + /* -------------------------------------------- */ + /** * Prepare effect favorite data. * @returns {Promise} @@ -531,6 +551,33 @@ export default class ActiveEffect5e extends ActiveEffect { }); } + /* -------------------------------------------- */ + + /** + * Record another effect as a dependent of this one. + * @param {ActiveEffect5e} dependent The dependent effect. + * @returns {Promise} + */ + addDependent(dependent) { + const dependents = this.getFlag("dnd5e", "dependents") ?? []; + dependents.push({ uuid: dependent.uuid }); + return this.setFlag("dnd5e", "dependents", dependents); + } + + /* -------------------------------------------- */ + + /** + * Retrieve a list of dependent effects. + * @returns {ActiveEffect5e[]} + */ + getDependents() { + return (this.getFlag("dnd5e", "dependents") || []).reduce((arr, { uuid }) => { + const effect = fromUuidSync(uuid); + if ( effect ) arr.push(effect); + return arr; + }, []); + } + /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ diff --git a/module/documents/item.mjs b/module/documents/item.mjs index b3c36b4a2f..887d9f38cc 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1022,21 +1022,23 @@ export default class Item5e extends SystemDocumentMixin(Item) { if ( !foundry.utils.isEmpty(actorUpdates) ) await this.actor.update(actorUpdates); if ( !foundry.utils.isEmpty(resourceUpdates) ) await this.actor.updateEmbeddedDocuments("Item", resourceUpdates); - // Prepare card data & display it if options.createMessage is true - const cardData = await item.displayCard(options); - - // Initiate or concentration. + // Initiate or end concentration. const effects = []; if ( config.beginConcentrating ) { const effect = await item.actor.beginConcentrating(item); - if ( effect ) effects.push(effect); - + 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 ) { @@ -2019,11 +2021,14 @@ export default class Item5e extends SystemDocumentMixin(Item) { const li = button.closest("li.effect"); let effect = item.effects.get(li.dataset.effectId); if ( !effect ) effect = await fromUuid(li.dataset.uuid); - let warn = false; + const concentration = actor.effects.get(message.getFlag("dnd5e", "use.concentrationId")); for ( const token of canvas.tokens.controlled ) { - if ( await this._applyEffectToToken(effect, token) === false ) warn = true; + try { + await this._applyEffectToToken(effect, token, { concentration }); + } catch(err) { + Hooks.onError("Item5e._applyEffectToToken", err, { notify: "warn", log: "warn" }); + } } - if ( warn ) ui.notifications.warn("DND5E.EffectApplyWarning", { localize: true }); break; case "attack": await item.rollAttack({ @@ -2084,16 +2089,23 @@ export default class Item5e extends SystemDocumentMixin(Item) { /** * Handle applying an Active Effect to a Token. - * @param {ActiveEffect5e} effect The effect. - * @param {Token5e} token The token. - * @returns {Promise|false} + * @param {ActiveEffect5e} effect The effect. + * @param {Token5e} token The token. + * @param {object} [options] + * @param {ActiveEffect5e} [options.concentration] An optional concentration effect to act as the applied effect's + * origin instead. + * @returns {Promise} + * @throws {Error} If the effect could not be applied. * @protected */ - static _applyEffectToToken(effect, token) { - if ( !game.user.isGM && !token.actor?.isOwner ) return false; + static async _applyEffectToToken(effect, token, { concentration }={}) { + const origin = concentration ?? effect; + if ( !game.user.isGM && !token.actor?.isOwner ) { + throw new Error(game.i18n.localize("DND5E.EffectApplyWarningOwnership")); + } // Enable an existing effect on the target if it originated from this effect - const existingEffect = token.actor?.effects.find(e => e.origin === effect.uuid); + const existingEffect = token.actor?.effects.find(e => e.origin === origin.uuid); if ( existingEffect ) { return existingEffect.update({ ...effect.constructor.getInitialDuration(), @@ -2101,13 +2113,19 @@ export default class Item5e extends SystemDocumentMixin(Item) { }); } + if ( !game.user.isGM && concentration && !concentration.actor?.isOwner ) { + throw new Error(game.i18n.localize("DND5E.EffectApplyWarningConcentration")); + } + // Otherwise, create a new effect on the target const effectData = foundry.utils.mergeObject(effect.toObject(), { disabled: false, transfer: false, - origin: effect.uuid + origin: origin.uuid }); - return ActiveEffect.implementation.create(effectData, { parent: token.actor }); + const applied = await ActiveEffect.implementation.create(effectData, { parent: token.actor }); + if ( concentration ) await concentration.addDependent(applied); + return applied; } /* -------------------------------------------- */