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