From 781556ac96f8d619f81f69a7267b472e65f98928 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sat, 18 Jul 2026 11:51:50 -0700 Subject: [PATCH] Add expiry events to `DurationData`, apply duration to enchantments Adds `duration.expiry` to activity and spell data. Does not hide the duration field for duration-less expiry events because you may need to keep the duration data (for example, Shield has a duration of 1 Round and the expiry event of "Start of Source's Next Turn"). Changes how duration overrides are handled for applied effects by moving the logic into the activity itself, to give activities a chance to fully modify the effect being applied. In the process this also applies those same changes to applied enchantments. --- lang/en.json | 91 ++++++++++--------- .../active-effect/active-effect-sheet.mjs | 15 +-- .../active-effect/effect-change-config.mjs | 4 +- .../applications/activity/activity-sheet.mjs | 1 + .../components/effect-application.mjs | 21 ++--- module/config.mjs | 35 +++++-- module/data/item/spell.mjs | 1 + module/data/shared/_types.mjs | 1 + module/data/shared/duration-field.mjs | 27 +++--- module/documents/active-effect.mjs | 43 +++++++-- module/documents/activity/enchant.mjs | 4 +- module/documents/activity/mixin.mjs | 19 ++++ templates/activity/parts/activity-time.hbs | 2 +- templates/items/details/details-spell.hbs | 2 +- templates/shared/fields/field-duration.hbs | 3 + 15 files changed, 163 insertions(+), 106 deletions(-) diff --git a/lang/en.json b/lang/en.json index a1bab845da..bcbfc6c8c6 100644 --- a/lang/en.json +++ b/lang/en.json @@ -288,48 +288,6 @@ } }, -"DND5E.ACTIVEEFFECT": { - "AttributeKeyTooltip": "For a list of common keys and their accepted values, see the wiki.", - "ChangeType": { - "Advantage": { - "Label": "Advantage Mode" - }, - "Bonus": { - "Label": "Bonus" - }, - "Group": { - "Custom": "Custom", - "Rules": "Rules", - "Standard": "Standard" - }, - "Maximum": { - "Label": "Roll Maximum" - }, - "Minimum": { - "Label": "Roll Minimum" - } - }, - "Expired": "Expired", - "Expiry": { - "GROUPS": { - "General": "General", - "Specific": "Specific" - }, - "SourceStart": "Start of Source's Next Turn", - "SourceEnd": "End of Source's Next Turn", - "TargetStart": "Start of Target's Next Turn", - "TargetEnd": "End of Target's Next Turn", - "YourStart": "Start of Your Next Turn", - "YourEnd": "End of Your Next Turn" - }, - "FIELDS": { - "statuses": { - "label": "Status Conditions", - "hint": "While affected by this Active Effect, the target will be treated as having these additional status conditions." - } - } -}, - "DND5E.ACTIVITY": { "Action": { "Create": "Create Activity", @@ -2582,6 +2540,10 @@ "label": "Concentration", "hint": "Creature must maintain concentration while active." }, + "expiry": { + "label": "Expiry Event", + "hint": "Event that triggers the end of the usage." + }, "override": { "label": "Override Duration", "hint": "Use these duration values instead of the item's when using this activity." @@ -2637,6 +2599,7 @@ "Ownership": "Effects cannot be applied to tokens you are not the owner of." } }, + "AttributeKeyTooltip": "For a list of common keys and their accepted values, see the wiki.", "BASE": { "FIELDS": { "changes": { @@ -2681,14 +2644,52 @@ }, "Change": { "Configuration": "Change Configuration", + "Group": { + "Custom": "Custom", + "Rules": "Rules", + "Standard": "Standard" + }, "Label": "Change", - "Title": "{effect} Change #{number}" + "Title": "{effect} Change #{number}", + "Type": { + "Advantage": { + "Label": "Advantage Mode" + }, + "Bonus": { + "Label": "Bonus" + }, + "Maximum": { + "Label": "Roll Maximum" + }, + "Minimum": { + "Label": "Roll Minimum" + } + } }, "DropHint": "Drop an Active Effect here", "Empty": "No associated effects. Use the button above to create one, or select an existing effect from the drop-down.", + "Expired": "Expired", "Expiry": { - "LongRest": "End of Long Rest", - "ShortRest": "End of Short Rest" + "Group": { + "General": "General", + "Specific": "Specific" + }, + "Type": { + "LongRest": "End of Long Rest", + "ShortRest": "End of Short Rest", + "SourceEnd": "End of Source's Next Turn", + "SourceStart": "Start of Source's Next Turn", + "TargetEnd": "End of Target's Next Turn", + "TargetStart": "Start of Target's Next Turn", + "YourEnd": "End of Your Next Turn", + "YourStart": "Start of Your Next Turn" + } + }, + "FIELDS": { + "statuses": { + "label": "Status Conditions", + "hint": "While affected by this Active Effect, the target will be treated as having these additional status conditions." + } }, "Label": "Available Effects", "New": "New Effect", diff --git a/module/applications/active-effect/active-effect-sheet.mjs b/module/applications/active-effect/active-effect-sheet.mjs index 44bc6b7e23..b500893633 100644 --- a/module/applications/active-effect/active-effect-sheet.mjs +++ b/module/applications/active-effect/active-effect-sheet.mjs @@ -113,18 +113,9 @@ export default class ActiveEffectSheet5e extends ApplicationV2Mixin(ActiveEffect * @protected */ async _prepareDurationContext(context, options) { - const general = _loc("DND5E.ACTIVEEFFECT.Expiry.GROUPS.General"); - const specific = _loc("DND5E.ACTIVEEFFECT.Expiry.GROUPS.Specific"); - - for ( const [expiry, label] of Object.entries(context.expiryEvents) ) { - context.expiryEvents[expiry] = { group: general, label }; - } - for ( const expiry of dnd5e.documents.ActiveEffect5e.PSEUDO_EXPIRIES ) { - context.expiryEvents[expiry] = { - group: specific, - label: _loc(`DND5E.ACTIVEEFFECT.Expiry.${expiry.capitalize()}`) - }; - } + context.expiryEvents = Object.fromEntries( + ActiveEffect.implementation.expiryOptions.map(({ value, ...data }) => [value, data]) + ); return context; } diff --git a/module/applications/active-effect/effect-change-config.mjs b/module/applications/active-effect/effect-change-config.mjs index 2f717429e6..7efa767aef 100644 --- a/module/applications/active-effect/effect-change-config.mjs +++ b/module/applications/active-effect/effect-change-config.mjs @@ -110,7 +110,7 @@ export default class EffectChangeConfig extends DocumentSheet5e { context.defaultPriority = ActiveEffect.CHANGE_TYPES[context.source?.type]?.defaultPriority; context.fields = this.effect.system.schema.fields.changes.element.fields; - context.hintText = _loc("DND5E.ACTIVEEFFECT.AttributeKeyTooltip", { + context.hintText = _loc("DND5E.EFFECT.AttributeKeyTooltip", { url: this.effect.type === "enchantment" ? "https://github.com/foundryvtt/dnd5e/wiki/Enchantment" : "https://github.com/foundryvtt/dnd5e/wiki/Active-Effect-Guide" @@ -119,7 +119,7 @@ export default class EffectChangeConfig extends DocumentSheet5e { context.typeOptions = Object.entries(ActiveEffect.CHANGE_TYPES) .map(([value, { group, label }]) => ({ value, label: _loc(label), group: _loc( CONFIG.ActiveEffect.changeTypes[value]?.group - ?? `DND5E.ACTIVEEFFECT.ChangeType.Group.${value in CONST.ACTIVE_EFFECT_CHANGE_TYPES ? "Standard" : "Custom"}` + ?? `DND5E.EFFECT.Change.Group.${value in CONST.ACTIVE_EFFECT_CHANGE_TYPES ? "Standard" : "Custom"}` ) })) .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); diff --git a/module/applications/activity/activity-sheet.mjs b/module/applications/activity/activity-sheet.mjs index 12e264165b..96ed8a7478 100644 --- a/module/applications/activity/activity-sheet.mjs +++ b/module/applications/activity/activity-sheet.mjs @@ -182,6 +182,7 @@ export default class ActivitySheet extends PseudoDocumentSheet { value, label, group: _loc("DND5E.RangeDistance") })) ]; + context.expiryOptions = ActiveEffect.implementation.expiryOptions; // Consumption targets const canScale = this.activity.canConfigureScaling; diff --git a/module/applications/components/effect-application.mjs b/module/applications/components/effect-application.mjs index 961c5ca3f8..9e676063cd 100644 --- a/module/applications/components/effect-application.mjs +++ b/module/applications/components/effect-application.mjs @@ -195,7 +195,10 @@ export default class EffectApplicationElement extends TargetedApplicationMixin(C throw new Error(_loc("DND5E.EFFECT.Application.Warning.Ownership")); } - const effectFlags = { + // Get any effect changes provided by the activity & add flags + const changes = this.chatMessage.getAssociatedActivity({ scaled: true }) + ?.getAppliedEffectChanges(effect, { chatMessage: this.chatMessage, target: actor }) ?? {}; + foundry.utils.mergeObject(changes, { flags: { dnd5e: { dependentOn: concentration?.uuid, @@ -203,25 +206,16 @@ export default class EffectApplicationElement extends TargetedApplicationMixin(C spellLevel: this.chatMessage.system.spellLevel } } - }; - - // Inherit the activity's duration only when the applied effect has a duration expiry and no explicit duration of - // its own. - let durationOverride = {}; - if ( !Number.isFinite(effect.duration.value) && effect.expirySupportsDuration() ) { - const effectDuration = this.chatMessage.getAssociatedActivity({ scaled: true })?.duration.getEffectData(); - if ( !foundry.utils.isEmpty(effectDuration) ) durationOverride = { duration: effectDuration }; - } + }); // Enable an existing effect on the target if it originated from this effect const existingEffect = effect.inCompendium ? actor.effects.find(e => e._stats.compendiumSource === effect.uuid) : actor.effects.find(e => e.origin === origin.uuid); if ( existingEffect ) { return existingEffect.update(foundry.utils.mergeObject({ - ...durationOverride, disabled: false, start: effect.constructor.getEffectStart() - }, effectFlags)); + }, changes)); } if ( !game.user.isGM && concentration && !concentration.isOwner ) { @@ -231,7 +225,6 @@ export default class EffectApplicationElement extends TargetedApplicationMixin(C // Otherwise, create a new effect on the target const effectData = foundry.utils.mergeObject({ ...effect.toObject(), - ...durationOverride, disabled: false, transfer: false, origin: origin.uuid, @@ -239,7 +232,7 @@ export default class EffectApplicationElement extends TargetedApplicationMixin(C [effect.inCompendium ? "compendiumSource" : "duplicateSource"]: effect.uuid, [effect.inCompendium ? "duplicateSource" : "compendiumSource"]: null } - }, effectFlags); + }, changes); return await ActiveEffect.implementation.create(effectData, { parent: actor }); } diff --git a/module/config.mjs b/module/config.mjs index 607c40234c..36d81919d6 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -3924,31 +3924,31 @@ DND5E.bloodied = { */ DND5E.activeEffectChangeTypes = Object.freeze({ "dnd5e.advantage": { - label: "DND5E.ACTIVEEFFECT.ChangeType.Advantage.Label", + label: "DND5E.EFFECT.Change.Type.Advantage.Label", defaultPriority: 100, handler: ActiveEffect5e._applyChangeRule, - group: "DND5E.ACTIVEEFFECT.ChangeType.Group.Rules", + group: "DND5E.EFFECT.Change.Group.Rules", skipConditions: true }, "dnd5e.bonus": { - label: "DND5E.ACTIVEEFFECT.ChangeType.Bonus.Label", + label: "DND5E.EFFECT.Change.Type.Bonus.Label", defaultPriority: 100, handler: ActiveEffect5e._applyChangeRule, - group: "DND5E.ACTIVEEFFECT.ChangeType.Group.Rules", + group: "DND5E.EFFECT.Change.Group.Rules", skipConditions: true }, "dnd5e.maximum": { - label: "DND5E.ACTIVEEFFECT.ChangeType.Maximum.Label", + label: "DND5E.EFFECT.Change.Type.Maximum.Label", defaultPriority: 100, handler: ActiveEffect5e._applyChangeRule, - group: "DND5E.ACTIVEEFFECT.ChangeType.Group.Rules", + group: "DND5E.EFFECT.Change.Group.Rules", skipConditions: true }, "dnd5e.minimum": { - label: "DND5E.ACTIVEEFFECT.ChangeType.Minimum.Label", + label: "DND5E.EFFECT.Change.Type.Minimum.Label", defaultPriority: 100, handler: ActiveEffect5e._applyChangeRule, - group: "DND5E.ACTIVEEFFECT.ChangeType.Group.Rules", + group: "DND5E.EFFECT.Change.Group.Rules", skipConditions: true } }); @@ -4723,10 +4723,25 @@ DND5E.calendarDeltasRecoveryMapping = new Map([ * @enum {string} */ DND5E.expiryEvents = Object.seal({ - longRest: "DND5E.EFFECT.Expiry.LongRest", - shortRest: "DND5E.EFFECT.Expiry.ShortRest" + longRest: "DND5E.EFFECT.Expiry.Type.LongRest", + shortRest: "DND5E.EFFECT.Expiry.Type.ShortRest" }); +/* -------------------------------------------- */ + +/** + * System-specific "expiry" choices which do not require registration or custom expiry events, and instead + * are handled dynamically in `ActiveEffect#isExpiryEvent`. + * @type {Record} + */ +DND5E.pseudoExpiryEvents = Object.seal({ + sourceStart: "DND5E.EFFECT.Expiry.Type.SourceStart", + sourceEnd: "DND5E.EFFECT.Expiry.Type.SourceEnd", + targetStart: "DND5E.EFFECT.Expiry.Type.TargetStart", + targetEnd: "DND5E.EFFECT.Expiry.Type.TargetEnd" +}); +preLocalize("pseudoExpiryEvents"); + /* -------------------------------------------- */ /* Requests */ /* -------------------------------------------- */ diff --git a/module/data/item/spell.mjs b/module/data/item/spell.mjs index cc93c3145c..e5d629002f 100644 --- a/module/data/item/spell.mjs +++ b/module/data/item/spell.mjs @@ -550,6 +550,7 @@ export default class SpellData extends ItemDataModel.mixin(ActivitiesTemplate, I return { value, label, group: "DND5E.DurationPermanent" }; }) ]; + context.expiryOptions = ActiveEffect.implementation.expiryOptions; // Targets context.targetTypes = [ diff --git a/module/data/shared/_types.mjs b/module/data/shared/_types.mjs index f8bac3ed12..05d0d2c8b5 100644 --- a/module/data/shared/_types.mjs +++ b/module/data/shared/_types.mjs @@ -49,6 +49,7 @@ * @typedef DurationData * @property {string} value Scalar value for the activity's duration. * @property {string} units Units that are used for the duration. + * @property {string} expiry Active effect expiry event. * @property {string} special Description of any special duration details. */ diff --git a/module/data/shared/duration-field.mjs b/module/data/shared/duration-field.mjs index a249087c8d..eec50f43f9 100644 --- a/module/data/shared/duration-field.mjs +++ b/module/data/shared/duration-field.mjs @@ -15,6 +15,7 @@ export default class DurationField extends SchemaField { fields = { value: new FormulaField({ deterministic: true }), units: new StringField({ required: true, blank: false, initial: "inst" }), + expiry: new StringField(), special: new StringField(), ...fields }; @@ -56,21 +57,23 @@ export default class DurationField extends SchemaField { /** * Create duration data usable for an active effect based on this duration. * @this {DurationData} - * @returns {EffectDurationData} + * @returns {Partial} */ static getEffectDuration() { - if ( !Number.isNumeric(this.value) ) return {}; - const { value, units } = this; + let { expiry, value, units } = this; + if ( !Number.isNumeric(value) ) return expiry ? { expiry, value: null } : {}; + if ( expiry && !ActiveEffect.implementation.expirySupportsDuration(expiry) ) return { expiry, value: null }; switch ( units ) { - case "turn": return { value, units: "turns" }; - case "round": return { value, units: "rounds" }; - case "second": return { value, units: "seconds" }; - case "minute": return { value, units: "minutes" }; - case "hour": return { value, units: "hours" }; - case "day": return { value, units: "days" }; - case "month": return { value, units: "months" }; - case "year": return { value, units: "years" }; - default: return {}; + case "turn": units = "turns"; break; + case "round": units = "rounds"; break; + case "second": units = "seconds"; break; + case "minute": units = "minutes"; break; + case "hour": units = "hours"; break; + case "day": units = "days" ; break; + case "month": units = "months"; break; + case "year": units = "years"; break; + default: return expiry ? { expiry, value: null } : {}; } + return { expiry, value, units }; } } diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 38caa09a46..f6bc14b3f0 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -93,7 +93,7 @@ export default class ActiveEffect5e extends DependentDocumentMixin(ActiveEffect) /* -------------------------------------------- */ /** @inheritdoc */ - static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ACTIVEEFFECT"]; + static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.EFFECT"]; /* -------------------------------------------- */ /* Properties */ @@ -120,6 +120,21 @@ export default class ActiveEffect5e extends DependentDocumentMixin(ActiveEffect) /* -------------------------------------------- */ + /** + * Possible expiry options. + * @type {FormSelectOption[]} + */ + static get expiryOptions() { + const general = _loc("DND5E.EFFECT.Expiry.Group.General"); + const specific = _loc("DND5E.EFFECT.Expiry.Group.Specific"); + return [ + ...Object.entries(ActiveEffect5e.EXPIRY_EVENTS).map(([value, l]) => ({ value, label: _loc(l), group: general })), + ...Object.entries(CONFIG.DND5E.pseudoExpiryEvents).map(([value, label]) => ({ value, label, group: specific })) + ]; + } + + /* -------------------------------------------- */ + /** * Is this effect an enchantment on an item that accepts enchantment? * @type {boolean} @@ -550,22 +565,23 @@ export default class ActiveEffect5e extends DependentDocumentMixin(ActiveEffect) /** @inheritDoc */ _prepareDuration(duration, context) { duration = super._prepareDuration(duration, context); - if ( duration.expired && !Number.isFinite(duration.value) ) { - duration.label = _loc("DND5E.ACTIVEEFFECT.Expired"); - } + + // Expired effects say "Expired" + if ( duration.expired && !Number.isFinite(duration.value) ) duration.label = _loc("DND5E.EFFECT.Expired"); // Pseudo expires adjust label based on relationship to actor else if ( this.constructor.PSEUDO_EXPIRIES.has(duration.expiry) ) { const useYour = (this.modifiesActor || this.isAppliedEnchantment) && (duration.expiry.startsWith("target") || (this.getSourceActor() === this.actor)); - if ( useYour ) duration.label = _loc(`DND5E.ACTIVEEFFECT.Expiry.Your${duration.expiry.slice(6)}`); - else duration.label = _loc(`DND5E.ACTIVEEFFECT.Expiry.${duration.expiry.capitalize()}`); + if ( useYour ) duration.label = _loc(`DND5E.EFFECT.Expiry.Type.Your${duration.expiry.slice(6)}`); + else duration.label = CONFIG.DND5E.pseudoExpiryEvents[duration.expiry]; } - // Durationless expiries just use expiry name + // Duration-less expiries use expiry name else if ( this.constructor.DURATIONLESS_EXPIRIES.has(duration.expiry) ) { duration.label = _loc(CONFIG.ActiveEffect.expiryEvents[duration.expiry]); } + return duration; } @@ -1118,13 +1134,24 @@ export default class ActiveEffect5e extends DependentDocumentMixin(ActiveEffect) /* -------------------------------------------- */ + /** + * Determine whether the provided expiry should be able to display duration fields. + * @param {string} [expiry] Active effect expiry event to check. + * @returns {boolean} + */ + static expirySupportsDuration(expiry) { + return !this.PSEUDO_EXPIRIES.has(expiry) && !this.DURATIONLESS_EXPIRIES.has(expiry); + } + + /* -------------------------------------------- */ + /** * Determine whether the provided expiry should be able to display duration fields. * @param {string} [expiry] Active effect expiry event to check, defaults to this effect's current expiry. * @returns {boolean} */ expirySupportsDuration(expiry=this.duration.expiry) { - return !this.constructor.PSEUDO_EXPIRIES.has(expiry) && !this.constructor.DURATIONLESS_EXPIRIES.has(expiry); + return ActiveEffect5e.expirySupportsDuration(expiry); } /* -------------------------------------------- */ diff --git a/module/documents/activity/enchant.mjs b/module/documents/activity/enchant.mjs index 7154c02682..9ce27f6069 100644 --- a/module/documents/activity/enchant.mjs +++ b/module/documents/activity/enchant.mjs @@ -174,7 +174,9 @@ export default class EnchantActivity extends ActivityMixin(BaseEnchantActivityDa const flags = { enchantmentProfile: profileId }; if ( concentration ) flags.dependentOn = concentration.uuid; - const enchantmentData = effect.clone({ origin: this.uuid, "flags.dnd5e": flags }).toObject(); + const enchantmentData = effect.clone(foundry.utils.mergeObject({ + origin: this.uuid, "flags.dnd5e": flags + }, this.getAppliedEffectChanges(effect, { chatMessage, target: item }))).toObject(); /** * Hook that fires before an enchantment is applied to an item. diff --git a/module/documents/activity/mixin.mjs b/module/documents/activity/mixin.mjs index 5054e62e32..17d0cac257 100644 --- a/module/documents/activity/mixin.mjs +++ b/module/documents/activity/mixin.mjs @@ -1220,6 +1220,25 @@ export default function ActivityMixin(Base) { /* -------------------------------------------- */ + /** + * Retrieve any modifications to be made to an applied effect. + * @param {ActiveEffect5e} effect The base effect that will be applied. + * @param {object} [options={}] + * @param {ChatMessage5e} [options.chatMessage] Message associated with the application. + * @param {Actor5e|Item5e} [options.target] Actor or item to which the effect will be applied. + * @returns {object} + */ + getAppliedEffectChanges(effect, { chatMessage, target }={}) { + const changes = {}; + if ( !Number.isFinite(effect.duration.value) && !effect.duration.expiry ) { + const duration = this.duration?.getEffectData(); + if ( !foundry.utils.isEmpty(duration) ) changes.duration = duration; + } + return changes; + } + + /* -------------------------------------------- */ + /** * Prepare activity favorite data. * @returns {Promise} diff --git a/templates/activity/parts/activity-time.hbs b/templates/activity/parts/activity-time.hbs index 5ed0dc04bf..937ba6ddbd 100644 --- a/templates/activity/parts/activity-time.hbs +++ b/templates/activity/parts/activity-time.hbs @@ -28,7 +28,7 @@ {{> "dnd5e.field-duration" duration=activity.duration fields=fields.duration.fields data=data.duration - durationUnits=durationUnits inputs=inputs }} + durationUnits=durationUnits expiryOptions=expiryOptions inputs=inputs }} {{ formField fields.duration.fields.concentration value=data.duration.concentration input=inputs.createCheckboxInput rootId=partId }} diff --git a/templates/items/details/details-spell.hbs b/templates/items/details/details-spell.hbs index 743f078af2..25489de5f6 100644 --- a/templates/items/details/details-spell.hbs +++ b/templates/items/details/details-spell.hbs @@ -85,7 +85,7 @@ {{!-- Duration --}} {{> "dnd5e.field-duration" duration=system.duration fields=fields.duration.fields data=source.duration - durationUnits=durationUnits inputs=inputs }} + durationUnits=durationUnits expiryOptions=expiryOptions inputs=inputs }} {{> "dnd5e.field-targets" target=system.target fields=fields.target.fields data=source.target inputs=inputs diff --git a/templates/shared/fields/field-duration.hbs b/templates/shared/fields/field-duration.hbs index 9a56e827cb..decd456891 100644 --- a/templates/shared/fields/field-duration.hbs +++ b/templates/shared/fields/field-duration.hbs @@ -20,3 +20,6 @@ placeholder=(localize "DND5E.DURATION.FIELDS.duration.special.label") rootId=partId }} {{/if}} + +{{!-- Expiry Options --}} +{{ formField fields.expiry value=data.expiry options=expiryOptions rootId=partId }}