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