Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
krbz999 committed Mar 5, 2024
1 parent 358ed84 commit 5f9c845
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 16 deletions.
9 changes: 9 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@
"DND5E.ClassName": "Class Name",
"DND5E.ClassOriginal": "Original Class",
"DND5E.ClassSaves": "Saving Throws",
"DND5E.Confirm": "Confirm",
"DND5E.ComponentMaterial": "Material",
"DND5E.ComponentMaterialAbbr": "M",
"DND5E.ComponentSomatic": "Somatic",
Expand Down Expand Up @@ -442,6 +443,14 @@
"DND5E.Concentration": "Concentration",
"DND5E.ConcentrationAbbr": "C",
"DND5E.ConcentrationDuration": "Concentration, up to {duration}",
"DND5E.ConcentratingOn": "You are maintaining concentration on the effects of the '{name}' {type}.",
"DND5E.ConcentratingEndChoice": "You are concentrating on effects from more than one source. Pick which effect to end.",
"DND5E.ConcentratingMissingItem": "The effect that is concentrated on and to be replaced does not exist.",
"DND5E.ConcentratingLimited": "You are not able to begin concentrating on an additional effect.",
"DND5E.ConcentratingEnd": "End Concentration",
"DND5E.ConcentratingItemless": "Concentration With No Item",
"DND5E.ConcentratingWarnLimit": "You cannot maintain concentration on more effects!",
"DND5E.ConcentratingWarnLimitOptional": "You may end concentration on one of your maintained effects to use this item.",
"DND5E.ConsumeTitle": "Resource Consumption",
"DND5E.ConsumeAmount": "Consumption Amount",
"DND5E.ConsumeTarget": "Consumption Target",
Expand Down
32 changes: 32 additions & 0 deletions module/applications/item/ability-use-dialog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,19 @@ export default class AbilityUseDialog extends Dialog {
const slotOptions = config.consumeSpellSlot ? this._createSpellSlotOptions(item.actor, item.system.level) : [];
const resourceOptions = this._createResourceOptions(item);

const limit = item.actor.system.attributes?.concentration?.limit ?? 0;
const concentrationOptions = this._createConcentrationOptions(item);

const data = {
item,
...config,
slotOptions,
resourceOptions,
concentration: {
show: concentrationOptions.length > 0,
options: concentrationOptions,
optional: (concentrationOptions.length < limit) ? "-" : null
},
scaling: item.usageScaling,
note: this._getAbilityUseNote(item, config),
title: game.i18n.format("DND5E.AbilityUseHint", {
Expand Down Expand Up @@ -89,6 +97,24 @@ export default class AbilityUseDialog extends Dialog {
/* Helpers */
/* -------------------------------------------- */

/**
* Create an array of options for which concentration effect to end or replace.
* @param {Item5e} item The item being used.
* @returns {object[]} Array of concentration options.
* @private
*/
static _createConcentrationOptions(item) {
const effects = item.actor.concentration?.effects ?? new Set();
return effects.reduce((acc, effect) => {
const data = effect.flags.dnd5e?.itemData;
acc.push({
name: effect.id,
label: data?.name ?? item.actor.items.get(data)?.name ?? game.i18n.localize("DND5E.ConcentratingItemless")
});
return acc;
}, []);
}

/**
* Create an array of spell slot options for a select.
* @param {Actor5e} actor The actor with spell slots.
Expand Down Expand Up @@ -310,6 +336,12 @@ export default class AbilityUseDialog extends Dialog {
}
}

// Display warnings that the actor cannot concentrate on this item, or if it must replace one of the effects.
if ( data.concentration.show ) {
const locale = `DND5E.ConcentratingWarnLimit${data.concentration.optional ? "Optional" : ""}`;
warnings.push(game.i18n.localize(locale));
}

data.warnings = warnings;
}

Expand Down
3 changes: 2 additions & 1 deletion module/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2598,7 +2598,8 @@ DND5E.statusEffects = {
},
concentrating: {
name: "EFFECT.DND5E.StatusConcentrating",
icon: "systems/dnd5e/icons/svg/statuses/concentrating.svg"
icon: "systems/dnd5e/icons/svg/statuses/concentrating.svg",
special: "CONCENTRATING"
},
cursed: {
name: "EFFECT.DND5E.StatusCursed",
Expand Down
113 changes: 99 additions & 14 deletions module/documents/active-effect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ export default class ActiveEffect5e extends ActiveEffect {
if ( typeof effectData === "string" ) effectData = CONFIG.statusEffects.find(e => e.id === effectData);
if ( foundry.utils.getType(effectData) !== "Object" ) return;
const createData = {
...foundry.utils.deepClone(effectData),
_id: staticID(`dnd5e${effectData.id}`),
name: game.i18n.localize(effectData.name),
_id: staticID(`dnd5e${effectData.id}`),
...foundry.utils.deepClone(effectData),
statuses: [effectData.id, ...effectData.statuses ?? []]
};
if ( !("description" in createData) && effectData.reference ) {
Expand Down Expand Up @@ -354,7 +354,53 @@ export default class ActiveEffect5e extends ActiveEffect {
}

/* -------------------------------------------- */
/* Exhaustion Handling */
/* Concentration Handling */
/* -------------------------------------------- */

/**
* Create an effect for concentration on an actor, optionally replacing an existing effect.
* @param {Item5e} item The item on which to begin concentrating.
* @param {ActiveEffect5e} [existing] An existing effect to replace.
* @returns {Promise<ActiveEffect5e} A promise that resolves to the created effect.
*/
static async createConcentrationEffect(item, existing=null) {
if ( !item.isEmbedded || !item.requiresConcentration ) {
throw new Error("You may not begin concentration on this item!");
}

const actor = item.actor;

const baseData = {
name: `${game.i18n.localize("DND5E.Concentration")}: ${item.name}`,
description: game.i18n.format("DND5E.ConcentratingOn", {
name: item.name,
type: game.i18n.localize(`TYPES.Item.${item.type}`)
}),
duration: {seconds: 60}, // TODO: get duration in seconds from item.
"flags.dnd5e.itemData": actor.items.has(item.id) ? item.id : item.toObject()
};

const effects = actor.concentration?.effects ?? new Set();

existing ??= effects.find(e => {
const d = e.flags.dnd5e?.itemData ?? {};
return (d === item.id) || (d._id === item.id);
});

// Replace an existing effect if already concentrating on this item or if another effect has been chosen.
if (existing) {
const effectData = foundry.utils.mergeObject(existing.toObject(), baseData);
await existing.delete();
return ActiveEffect5e.create(effectData, { parent: item.actor });
}

const effectData = (await ActiveEffect5e.fromStatusEffect(CONFIG.specialStatusEffects.CONCENTRATING)).toObject();
foundry.utils.mergeObject(effectData, baseData);
return ActiveEffect5e.create(effectData, { parent: item.actor });
}

/* -------------------------------------------- */
/* Exhaustion and Concentration Handling */
/* -------------------------------------------- */

/**
Expand Down Expand Up @@ -402,21 +448,60 @@ export default class ActiveEffect5e extends ActiveEffect {
/* -------------------------------------------- */

/**
* Implement custom exhaustion cycling when interacting with the Token HUD.
* Implement custom exhaustion cycling when interacting with the Token HUD,
* and management of which items the actor is concentrating on.
* @param {PointerEvent} event The triggering event.
*/
static onClickTokenHUD(event) {
static async onClickTokenHUD(event) {
const { target } = event;
if ( !target.classList?.contains("effect-control") || (target.dataset?.statusId !== "exhaustion") ) return;
if ( !target.classList?.contains("effect-control") ) return;

const actor = canvas.hud.token.object?.actor;
let level = foundry.utils.getProperty(actor ?? {}, "system.attributes.exhaustion");
if ( !Number.isFinite(level) ) return;
event.preventDefault();
event.stopPropagation();
if ( event.button === 0 ) level++;
else level--;
const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
actor.update({ "system.attributes.exhaustion": Math.clamped(level, 0, max) });
if ( !actor ) return;
if ( target.dataset?.statusId === "exhaustion" ) {
let level = foundry.utils.getProperty(actor, "system.attributes.exhaustion");
if ( !Number.isFinite(level) ) return;
event.preventDefault();
event.stopPropagation();
if ( event.button === 0 ) level++;
else level--;
const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
actor.update({ "system.attributes.exhaustion": Math.clamped(level, 0, max) });
} else if ( target.dataset?.statusId === "concentrating" ) {
const effects = actor.concentration?.effects ?? new Set();
if ( effects.size < 1 ) return;
event.preventDefault();
event.stopPropagation();
if ( effects.size === 1 ) {
effects.first().delete();
return;
}
const choices = effects.reduce((acc, effect) => {
const data = effect.flags.dnd5e?.itemData;
acc[effect.id] = data?.name ?? actor.items.get(data)?.name ?? game.i18n.localize("DND5E.ConcentratingItemless");
return acc;
}, {});
const options = HandlebarsHelpers.selectOptions(choices, { hash: { sort: true } });
const content = `
<form class="dnd5e">
<p>${game.i18n.localize("DND5E.ConcentratingEndChoice")}</p>
<div class="form-group">
<label>${game.i18n.localize("DND5E.Source")}</label>
<div class="form-fields">
<select name="source">${options}</select>
</div>
</div>
</form>`;
const source = await Dialog.prompt({
content: content,
callback: ([html]) => new FormDataExtended(html.querySelector("FORM")).object.source,
rejectClose: false,
title: game.i18n.localize("DND5E.Concentration"),
label: game.i18n.localize("DND5E.Confirm")
});
if ( !source ) return;
actor.effects.get(source).delete();
}
}

/* -------------------------------------------- */
Expand Down
27 changes: 27 additions & 0 deletions module/documents/actor/actor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ export default class Actor5e extends SystemDocumentMixin(Actor) {
return this.system.attributes.ac.equippedShield ?? null;
}

/* -------------------------------------------- */

/**
* The items this actor is concentrating on, and the relevant effects.
* @type {object}
*/
get concentration() {
const concentration = {
items: new Set(),
effects: new Set()
};

const limit = this.system.attributes?.concentration?.limit ?? 0;
if ( !limit ) return concentration;

for (const effect of this.effects) {
if ( !effect.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING) ) continue;
const data = effect.flags.dnd5e?.itemData;
concentration.effects.add(effect);
if ( data ) {
const item = this.items.get(data) ?? new Item.implementation(data, { keepId: true, parent: this });
if ( item ) concentration.items.add(item);
}
}
return concentration;
}

/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
Expand Down
41 changes: 40 additions & 1 deletion module/documents/item.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Advancement from "./advancement/advancement.mjs";
import AbilityUseDialog from "../applications/item/ability-use-dialog.mjs";
import Proficiency from "./actor/proficiency.mjs";
import SystemDocumentMixin from "./mixins/document.mjs";
import ActiveEffect5e from "./active-effect.mjs";

/**
* Override and extend the basic Item implementation.
Expand Down Expand Up @@ -888,6 +889,8 @@ export default class Item5e extends SystemDocumentMixin(Item) {
* @property {boolean} consumeUsage Should this item consume its limited uses or recharge?
* @property {string|number|null} slotLevel The spell slot type or level to consume by default.
* @property {number|null} resourceAmount The amount to consume by default when scaling with consumption.
* @property {boolean} beginConcentration Should this item initiate concentration?
* @property {string|null} endConcentration The id of the item to end concentration on, if any.
*/

/**
Expand Down Expand Up @@ -984,7 +987,7 @@ export default class Item5e extends SystemDocumentMixin(Item) {
*/
if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return;

// Determine whether the item can be used by testing for resource consumption
// Determine whether the item can be used by testing the chosen values of the config.
const usage = item._getUsageUpdates(config);
if ( !usage ) return;

Expand Down Expand Up @@ -1015,6 +1018,14 @@ export default class Item5e extends SystemDocumentMixin(Item) {
// Prepare card data & display it if options.createMessage is true
const cardData = await item.displayCard(options);

// Initiate concentration.
if ( config.beginConcentration ) {
const effect = config.endConcentration ? item.actor.concentration.effects.find(e => {
return e.id === config.endConcentration;
}) : null;
await ActiveEffect5e.createConcentrationEffect(item, effect);
}

// Initiate measured template creation
let templates;
if ( config.createMeasuredTemplate ) {
Expand Down Expand Up @@ -1053,6 +1064,8 @@ export default class Item5e extends SystemDocumentMixin(Item) {
const config = {
consumeSpellSlot: null,
slotLevel: null,
beginConcentration: null,
endConcentration: null,
consumeUsage: null,
consumeResource: null,
resourceAmount: null,
Expand All @@ -1073,6 +1086,12 @@ export default class Item5e extends SystemDocumentMixin(Item) {
if ( consume.target === this.id ) config.consumeUsage = null;
}
if ( game.user.can("TEMPLATE_CREATE") && this.hasAreaTarget ) config.createMeasuredTemplate = target.prompt;
if ( this.requiresConcentration ) {
config.beginConcentration = true;
const items = this.actor.concentration.items;
const limit = this.actor.system.attributes?.concentration.limit ?? 0;
if ( limit <= items.size ) config.endConcentration = items.has(this.id) ? this.id : items.first().id;
}

return config;
}
Expand Down Expand Up @@ -1117,6 +1136,26 @@ export default class Item5e extends SystemDocumentMixin(Item) {
actorUpdates[`system.spells.${config.slotLevel}.value`] = Math.max(spells - 1, 0);
}

// Determine whether the item can be used by testing for available concentration.
if ( config.beginConcentration ) {
const effects = this.actor.concentration.effects;

// Case 1: Replacing.
if ( config.endConcentration ) {
const replacedEffect = effects.find(i => i.id === config.endConcentration);
if ( !replacedEffect ) {
ui.notifications.warn("DND5E.ConcentratingMissingItem", {localize: true});
return false;
}
}

// Case 2: Starting concentration, but at limit.
else if ( effects.size >= this.actor.system.attributes.concentration.limit ) {
ui.notifications.warn("DND5E.ConcentratingLimited", {localize: true});
return false;
}
}

// Return the configured usage
return {itemUpdates, actorUpdates, resourceUpdates, deleteIds};
}
Expand Down
19 changes: 19 additions & 0 deletions templates/apps/ability-use.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@
</div>
{{/if}}

{{#if (ne beginConcentration null)}}
{{#if concentration.show}}
<div class="form-group">
<label>{{ localize "DND5E.ConcentratingEnd" }}</label>
<div class="form-fields">
<select name="endConcentration">
{{selectOptions concentration.options selected=endConcentration sort=true blank=concentration.optional nameAttr="name" labelAttr="label"}}
</select>
</div>
</div>
{{/if}}

<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="beginConcentration" {{checked beginConcentration}}>{{localize "DND5E.Concentration"}}
</label>
</div>
{{/if}}

{{#if (ne consumeUsage null)}}
<div class="form-group">
<label class="checkbox">
Expand Down

0 comments on commit 5f9c845

Please sign in to comment.