From d3a08c563b6579b6a618d4b3522a3148429d10f4 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 21 Mar 2024 16:35:26 -0700 Subject: [PATCH 001/199] [#2780] Allow default concept compendiums to be defined --- module/applications/actor/character-sheet-2.mjs | 7 +++---- module/config.mjs | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 666f0787ca..99f24243fa 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1,6 +1,5 @@ import CharacterData from "../../data/actor/character.mjs"; import * as Trait from "../../documents/actor/trait.mjs"; -import { setTheme } from "../../settings.mjs"; import { formatNumber, simplifyBonus, staticID } from "../../utils.mjs"; import ContextMenu5e from "../context-menu.mjs"; import SheetConfig5e from "../sheet-config.mjs"; @@ -967,9 +966,9 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { */ _onFindItem(type) { switch ( type ) { - case "class": game.packs.get("dnd5e.classes").render(true); break; - case "race": game.packs.get("dnd5e.races").render(true); break; - case "background": game.packs.get("dnd5e.backgrounds").render(true); break; + case "class": game.packs.get(CONFIG.DND5E.sourcePacks.CLASSES)?.render(true); break; + case "race": game.packs.get(CONFIG.DND5E.sourcePacks.RACES)?.render(true); break; + case "background": game.packs.get(CONFIG.DND5E.sourcePacks.BACKGROUNDS)?.render(true); break; } } diff --git a/module/config.mjs b/module/config.mjs index 978802f926..78ace90d03 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -2344,7 +2344,10 @@ preLocalize("weaponProperties", { sort: true }); * @enum {string} */ DND5E.sourcePacks = { - ITEMS: "dnd5e.items" + BACKGROUNDS: "dnd5e.backgrounds", + CLASSES: "dnd5e.classes", + ITEMS: "dnd5e.items", + RACES: "dnd5e.races" }; /* -------------------------------------------- */ From a92fa8d286c764f9137f38d7395835540f343c04 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 21 Mar 2024 17:45:04 -0700 Subject: [PATCH 002/199] [#3295] Fix summoned active effect labels --- module/data/item/fields/summons-field.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 5708ce27fe..ba858ecde9 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -277,7 +277,7 @@ export class SummonsData extends foundry.abstract.DataModel { }], disabled: false, icon: "icons/magic/defensive/shield-barrier-blue.webp", - name: game.i18n.localize("DND5E.Summoning.ArmorClass.Label") + name: game.i18n.localize("DND5E.Summoning.Bonuses.ArmorClass.Label") })).toObject()); } } @@ -297,7 +297,7 @@ export class SummonsData extends foundry.abstract.DataModel { }], disabled: false, icon: "icons/magic/life/heart-glowing-red.webp", - name: game.i18n.localize("DND5E.Summoning.HitPoints.Label") + name: game.i18n.localize("DND5E.Summoning.Bonuses.HitPoints.Label") })).toObject()); } else { updates["system.attributes.hp.max"] = actor.system.attributes.hp.max + hpBonus.total; From cc86e8c11c2f86cb936719c6fe0c3aa1ea95909a Mon Sep 17 00:00:00 2001 From: Zhell Date: Fri, 22 Mar 2024 14:50:32 +0100 Subject: [PATCH 003/199] [#3301] --- module/applications/item/ability-use-dialog.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/applications/item/ability-use-dialog.mjs b/module/applications/item/ability-use-dialog.mjs index 5fbd5c1c28..000e6daf53 100644 --- a/module/applications/item/ability-use-dialog.mjs +++ b/module/applications/item/ability-use-dialog.mjs @@ -61,7 +61,7 @@ export default class AbilityUseDialog extends Dialog { summoningOptions: this._createSummoningOptions(item), resourceOptions: this._createResourceOptions(item), concentration: { - show: concentrationOptions.length > 0, + show: (config.beginConcentrating !== null) && !!concentrationOptions.length, options: concentrationOptions, optional: (concentrationOptions.length < limit) ? "—" : null }, From 11e741f0e8a5cd6de67a0760ee29e6ce949980a6 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 09:47:51 -0700 Subject: [PATCH 004/199] [#3306] Fix issue with rolling versatile damage --- module/documents/item.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 213ffcc831..c104ba6498 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1643,7 +1643,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { // Adjust damage from versatile usage if ( versatile && dmg.versatile ) { rollConfigs[0].parts[0] = dmg.versatile; - rollConfig.messageData["flags.dnd5e.roll"].versatile = true; + rollConfig.messageData["flags.dnd5e"].roll.versatile = true; } // Add magical damage if available From 614753f56e55bc0e7877b88aa19d95839e526c20 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 10:30:51 -0700 Subject: [PATCH 005/199] [#3311] Fix bug with rolling upcast damage, fix Magic Missile scaling --- module/documents/item.mjs | 10 +++++----- packs/_source/spells/1st-level/magic-missile.json | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/module/documents/item.mjs b/module/documents/item.mjs index c104ba6498..e5d49b8fe6 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1661,11 +1661,11 @@ export default class Item5e extends SystemDocumentMixin(Item) { else level = this.actor.system.details.spellLevel; rollConfigs.forEach(c => this._scaleCantripDamage(c.parts, scaling.formula, level, rollData)); } - else if ( spellLevel && (scaling.mode === "level") && (scaling.formula || c.parts.length) ) { - rollConfigs.forEach(c => - this._scaleSpellDamage(c.parts, this.system.level, spellLevel, scaling.formula || c.parts[0], rollData) - ); - } + else if ( spellLevel && (scaling.mode === "level") ) rollConfigs.forEach(c => { + if ( scaling.formula || c.parts.length ) { + this._scaleSpellDamage(c.parts, this.system.level, spellLevel, scaling.formula || c.parts[0], rollData); + } + }); } // Add damage bonus formula diff --git a/packs/_source/spells/1st-level/magic-missile.json b/packs/_source/spells/1st-level/magic-missile.json index a479edc61b..f436eeea5a 100644 --- a/packs/_source/spells/1st-level/magic-missile.json +++ b/packs/_source/spells/1st-level/magic-missile.json @@ -87,12 +87,13 @@ "prepared": false }, "scaling": { - "mode": "level", + "mode": "none", "formula": "" }, "properties": [ "vocal", - "somatic" + "somatic", + "mgc" ], "crewed": false }, @@ -103,10 +104,10 @@ "folder": "ERuXllkhTQF51rDJ", "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.1.1", "coreVersion": "11.315", "createdTime": 1661787234065, - "modifiedTime": 1704823521820, + "modifiedTime": 1711128581683, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!41JIhpDyM9Anm7cs" From 23b93955ba1415464d4ac0cced60687b8fdad290 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 10:37:41 -0700 Subject: [PATCH 006/199] [#3310] Prevent empty damage lines from causing roll errors --- module/dice/dice.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/dice/dice.mjs b/module/dice/dice.mjs index 46091ae24d..e0cabd72fb 100644 --- a/module/dice/dice.mjs +++ b/module/dice/dice.mjs @@ -190,7 +190,7 @@ export async function damageRoll({ rollOptions.criticalBonusDice = criticalBonusDice; rollOptions.criticalBonusDamage = criticalBonusDamage; } - rolls.push(new CONFIG.Dice.DamageRoll(formula, data, rollOptions)); + if ( formula ) rolls.push(new CONFIG.Dice.DamageRoll(formula, data, rollOptions)); } // Prompt a Dialog to further configure the DamageRoll From 1fad08ab8feb6cf90ce629acb3d0312d217f749c Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 10:47:14 -0700 Subject: [PATCH 007/199] [#3313] Fix bug with displaying class journal pages --- module/applications/journal/class-page-sheet.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/applications/journal/class-page-sheet.mjs b/module/applications/journal/class-page-sheet.mjs index d100285c38..720484f69e 100644 --- a/module/applications/journal/class-page-sheet.mjs +++ b/module/applications/journal/class-page-sheet.mjs @@ -170,7 +170,7 @@ export default class JournalClassPageSheet extends JournalPageSheet { continue; case "ItemGrant": if ( advancement.configuration.optional ) continue; - features.push(...await Promise.all(advancement.configuration.items.map(makeLink))); + features.push(...await Promise.all(advancement.configuration.items.map(i => makeLink(i.uuid)))); break; } } @@ -305,7 +305,7 @@ export default class JournalClassPageSheet extends JournalPageSheet { switch ( advancement.constructor.typeName ) { case "ItemGrant": if ( !advancement.configuration.optional ) continue; - features.push(...await Promise.all(advancement.configuration.items.map(makeLink))); + features.push(...await Promise.all(advancement.configuration.items.map(i => makeLink(i.uuid)))); break; } } @@ -332,8 +332,8 @@ export default class JournalClassPageSheet extends JournalPageSheet { * @returns {object[]} Prepared features. */ async _getFeatures(item, optional=false) { - const prepareFeature = async uuid => { - const document = await fromUuid(uuid); + const prepareFeature = async f => { + const document = await fromUuid(f.uuid); return { document, name: document.name, From 62c47124a584bf0715e3cb93200b01afd83217de Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 10:52:55 -0700 Subject: [PATCH 008/199] [#3299] Fix compendium app breaking when loading pre-V10 compendium --- module/applications/item/item-compendium.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/applications/item/item-compendium.mjs b/module/applications/item/item-compendium.mjs index 8e364222e1..f584a99858 100644 --- a/module/applications/item/item-compendium.mjs +++ b/module/applications/item/item-compendium.mjs @@ -15,7 +15,7 @@ export default class ItemCompendium5e extends Compendium { items = this.collection.index; } for ( const item of items ) { - if ( items.has(item.system.container) ) { + if ( items.has(item.system?.container) ) { this._element?.[0].querySelector(`[data-entry-id="${item._id}"]`)?.remove(); } } From 9034874517ca5cc7bcd548556986b7984b43d67b Mon Sep 17 00:00:00 2001 From: Zhell Date: Fri, 22 Mar 2024 19:07:17 +0100 Subject: [PATCH 009/199] [#3297] --- module/data/item/templates/equippable-item.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/data/item/templates/equippable-item.mjs b/module/data/item/templates/equippable-item.mjs index 5762b7b99e..0760d18367 100644 --- a/module/data/item/templates/equippable-item.mjs +++ b/module/data/item/templates/equippable-item.mjs @@ -75,7 +75,8 @@ export default class EquippableItemTemplate extends SystemDataModel { * @type {boolean} */ get magicAvailable() { - return this.attunement !== CONFIG.DND5E.attunementTypes.REQUIRED; + const attunement = this.attunement !== CONFIG.DND5E.attunementTypes.REQUIRED; + return attunement && this.properties.has("mgc") && this.validProperties.has("mgc"); } /* -------------------------------------------- */ From 05702c8910cf2b72e63ccef4bcda65b4d810980e Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 22 Mar 2024 10:11:32 -0700 Subject: [PATCH 010/199] [#3308] Fix item changes not being retained on summons --- module/data/item/fields/summons-field.mjs | 6 ++---- module/data/token/token-system-flags.mjs | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index ba858ecde9..8eee7c94e8 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -242,7 +242,7 @@ export class SummonsData extends foundry.abstract.DataModel { * @returns {object} Changes that will be applied to the actor & its items. */ async getChanges(actor, profile) { - const updates = { actor: {}, effects: [], items: [] }; + const updates = { effects: [], items: [] }; const rollData = this.item.getRollData(); const prof = rollData.attributes?.prof ?? 0; @@ -390,11 +390,9 @@ export class SummonsData extends foundry.abstract.DataModel { ui.notifications.warn("DND5E.Summoning.Warning.Wildcard", { localize: true }); } + actorUpdates["flags.dnd5e.summon.origin"] = this.item.uuid; const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates)); tokenDocument.delta.updateSource(actorUpdates); - tokenDocument.updateSource({ - "flags.dnd5e.summon": { origin: this.item.uuid } - }); return tokenDocument.toObject(); } diff --git a/module/data/token/token-system-flags.mjs b/module/data/token/token-system-flags.mjs index fac4578088..e85daf140d 100644 --- a/module/data/token/token-system-flags.mjs +++ b/module/data/token/token-system-flags.mjs @@ -22,7 +22,6 @@ const { * @property {boolean} isPolymorphed Is the actor represented by this token transformed? * @property {string} originalActor Original actor before transformation. * @property {object} previousActorData Actor data from before transformation for unlinked tokens. - * @property {object} summon Data for summoned tokens. * @property {TokenRingFlagData} tokenRing */ export default class TokenSystemFlags extends foundry.abstract.DataModel { @@ -34,7 +33,6 @@ export default class TokenSystemFlags extends foundry.abstract.DataModel { required: false, initial: undefined, idOnly: true }), previousActorData: new ObjectField({required: false, initial: undefined}), - summon: new ObjectField({required: false, initial: undefined}), tokenRing: new SchemaField({ enabled: new BooleanField({label: "DND5E.TokenRings.Enabled"}), colors: new SchemaField({ From 5e844b542ecbcb37440d8584a0d540fd16727067 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sat, 23 Mar 2024 10:48:06 -0700 Subject: [PATCH 011/199] [#3323, #3324] Fix advancement issues with missing linked items --- module/applications/advancement/item-choice-flow.mjs | 8 +++++--- module/documents/advancement/item-grant.mjs | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 1c31533d68..69fcd20e23 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -66,9 +66,11 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } const items = [...this.pool, ...this.dropped].reduce((items, i) => { - i.checked = this.selected.has(i.uuid); - i.disabled = !i.checked && choices.full; - if ( !previouslySelected.has(i.uuid) ) items.push(i); + if ( i ) { + i.checked = this.selected.has(i.uuid); + i.disabled = !i.checked && choices.full; + if ( !previouslySelected.has(i.uuid) ) items.push(i); + } return items; }, []); diff --git a/module/documents/advancement/item-grant.mjs b/module/documents/advancement/item-grant.mjs index b60a8ba2e8..b43bf8768b 100644 --- a/module/documents/advancement/item-grant.mjs +++ b/module/documents/advancement/item-grant.mjs @@ -48,9 +48,8 @@ export default class ItemGrantAdvancement extends Advancement { /** @inheritdoc */ summaryForLevel(level, { configMode=false }={}) { // Link to compendium items - if ( !this.value.added || configMode ) { - return this.configuration.items.reduce((html, i) => html + dnd5e.utils.linkForUuid(i.uuid), ""); - } + if ( !this.value.added || configMode ) return this.configuration.items.filter(i => fromUuidSync(i.uuid)) + .reduce((html, i) => html + dnd5e.utils.linkForUuid(i.uuid), ""); // Link to items on the actor else { From 2f639d0d1bbdb0e85f2f7aad776dfde6612e2942 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sat, 23 Mar 2024 11:32:31 -0700 Subject: [PATCH 012/199] [#3326] Add option to create spell scrolls with reduced description Adds a configuration object that is passed to `createScrollFromSpell` to allow for more customization of the creation process. Right now it has a single option `explanation` which controls how much of the spell scroll rules are included in the created scroll. - `full`: Existing functionality including entire spell scroll rules - `reference`: Adds scroll level, reference link to rules, and potentially a concentration note to beginning of description - `none`: Just includes the original spell description In order to support `reference` mod a non-level-specific version of the spell scroll rules have been added to the magic items section of the SRD rules and a reference of `&Reference[Spell Scroll]`. --- module/config.mjs | 3 +- module/documents/item.mjs | 92 +++++++++++++------- packs/_source/rules/appendix-e-rules.json | 42 ++++++++- packs/_source/rules/chapter-11-dm-tools.json | 10 +-- 4 files changed, 109 insertions(+), 38 deletions(-) diff --git a/module/config.mjs b/module/config.mjs index 978802f926..104187e623 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -2261,7 +2261,7 @@ patchConfig("spellSchools", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }) /* -------------------------------------------- */ /** - * Spell scroll item ID within the `DND5E.sourcePacks` compendium for each level. + * Spell scroll item ID within the `DND5E.sourcePacks` compendium or a full UUID for each spell level. * @enum {string} */ DND5E.spellScrollIds = { @@ -3355,6 +3355,7 @@ DND5E.rules = { consumables: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UEPAcZFzQ5x196zE", itemspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DABoaeeF6w31UCsj", charges: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.NLRXcgrpRCfsA5mO", + spellscroll: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gi8IKhtOlBVhMJrN", creaturetags: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9jV1fFF163dr68vd", telepathy: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.geTidcFIYWuUvD2L", legendaryactions: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.C1awOyZh78pq1xmY", diff --git a/module/documents/item.mjs b/module/documents/item.mjs index e5d49b8fe6..5d3baba01c 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2616,13 +2616,25 @@ export default class Item5e extends SystemDocumentMixin(Item) { /* -------------------------------------------- */ + /** + * Configuration options for spell scroll creation. + * + * @typedef {object} SpellScrollConfiguration + * @property {"full"|"reference"|"none"} [explanation="full"] Length of spell scroll rules text to include. + */ + /** * Create a consumable spell scroll Item from a spell Item. - * @param {Item5e|object} spell The spell or item data to be made into a scroll - * @param {object} [options] Additional options that modify the created scroll - * @returns {Promise} The created scroll consumable item + * @param {Item5e|object} spell The spell or item data to be made into a scroll. + * @param {object} [options] Additional options that modify the created scroll. + * @param {SpellScrollConfiguration} [config={}] Configuration options for scroll creation. + * @returns {Promise} The created scroll consumable item. */ - static async createScrollFromSpell(spell, options={}) { + static async createScrollFromSpell(spell, options={}, config={}) { + + config = foundry.utils.mergeObject({ + explanation: "full" + }, config); // Get spell data const itemData = (spell instanceof Item5e) ? spell.toObject() : spell; @@ -2631,11 +2643,12 @@ export default class Item5e extends SystemDocumentMixin(Item) { * A hook event that fires before the item data for a scroll is created. * @function dnd5e.preCreateScrollFromSpell * @memberof hookEvents - * @param {object} itemData The initial item data of the spell to convert to a scroll - * @param {object} [options] Additional options that modify the created scroll - * @returns {boolean} Explicitly return false to prevent the scroll to be created. + * @param {object} itemData The initial item data of the spell to convert to a scroll. + * @param {object} options Additional options that modify the created scroll. + * @param {SpellScrollConfiguration} config Configuration options for scroll creation. + * @returns {boolean} Explicitly return false to prevent the scroll to be created. */ - if ( Hooks.call("dnd5e.preCreateScrollFromSpell", itemData, options) === false ) return; + if ( Hooks.call("dnd5e.preCreateScrollFromSpell", itemData, options, config) === false ) return; let { actionType, description, source, activation, duration, target, @@ -2653,28 +2666,45 @@ export default class Item5e extends SystemDocumentMixin(Item) { const scrollItem = await fromUuid(scrollUuid); const scrollData = scrollItem.toObject(); delete scrollData._id; - - // Split the scroll description into an intro paragraph and the remaining details - const scrollDescription = scrollData.system.description.value; - const pdel = "

"; - const scrollIntroEnd = scrollDescription.indexOf(pdel); - const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length); - const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length); const isConc = properties.includes("concentration"); // Create a composite description from the scroll description and the spell details - const desc = [ - scrollIntro, - "
", - `

${itemData.name} (${game.i18n.format("DND5E.LevelNumber", {level})})

`, - isConc ? `

${game.i18n.localize("DND5E.ScrollRequiresConcentration")}

` : null, - "
", - description.value, - "
", - `

${game.i18n.localize("DND5E.ScrollDetails")}

`, - "
", - scrollDetails - ].filterJoin(""); + let desc; + switch ( config.explanation ) { + case "full": + // Split the scroll description into an intro paragraph and the remaining details + const scrollDescription = scrollData.system.description.value; + const pdel = "

"; + const scrollIntroEnd = scrollDescription.indexOf(pdel); + const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length); + const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length); + desc = [ + scrollIntro, + "
", + `

${itemData.name} (${game.i18n.format("DND5E.LevelNumber", {level})})

`, + isConc ? `

${game.i18n.localize("DND5E.ScrollRequiresConcentration")}

` : null, + "
", + description.value, + "
", + `

${game.i18n.localize("DND5E.ScrollDetails")}

`, + "
", + scrollDetails + ].filterJoin(""); + break; + case "reference": + desc = [ + "

", + CONFIG.DND5E.spellLevels[level] ?? level, + "&Reference[Spell Scroll]", + isConc ? `, ${game.i18n.localize("DND5E.ScrollRequiresConcentration")}` : null, + "

", + description.value + ].filterJoin(""); + break; + default: + desc = description.value; + break; + } // Used a fixed attack modifier and saving throw according to the level of spell scroll. if ( ["mwak", "rwak", "msak", "rsak"].includes(actionType) ) { @@ -2707,10 +2737,12 @@ export default class Item5e extends SystemDocumentMixin(Item) { * A hook event that fires after the item data for a scroll is created but before the item is returned. * @function dnd5e.createScrollFromSpell * @memberof hookEvents - * @param {Item5e|object} spell The spell or item data to be made into a scroll. - * @param {object} spellScrollData The final item data used to make the scroll. + * @param {Item5e|object} spell The spell or item data to be made into a scroll. + * @param {object} spellScrollData The final item data used to make the scroll. + * @param {SpellScrollConfiguration} config Configuration options for scroll creation. */ - Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData); + Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData, config); + return new this(spellScrollData); } diff --git a/packs/_source/rules/appendix-e-rules.json b/packs/_source/rules/appendix-e-rules.json index a450b2975e..af970d2655 100644 --- a/packs/_source/rules/appendix-e-rules.json +++ b/packs/_source/rules/appendix-e-rules.json @@ -10830,6 +10830,44 @@ "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!journal.pages!NizgRXLNUqtdlC1s.qKmqbRFE9n2Up5bF" + }, + { + "sort": 20675000, + "name": "Spell Scroll", + "type": "rule", + "_id": "gi8IKhtOlBVhMJrN", + "title": { + "show": true, + "level": 2 + }, + "image": {}, + "text": { + "format": 1, + "content": "

A spell scroll bears the words of a single spell, written in a mystical cipher. If the spell is on your class’s spell list, you can read the scroll and cast its spell without providing any material components. Otherwise, the scroll is unintelligible. Casting the spell by reading the scroll requires the spell’s normal casting time. Once the spell is cast, the words on the scroll fade, and it crumbles to dust. If the casting is interrupted, the scroll is not lost.

If the spell is on your class’s spell list but of a higher level than you can normally cast, you must make an ability check using your spellcasting ability to determine whether you cast it successfully. The DC equals 10 + the spell’s level. On a failed check, the spell disappears from the scroll with no other effect.

The level of the spell on the scroll determines the spell’s saving throw DC and attack bonus, as well as the scroll’s rarity, as shown in the Spell Scroll table.

Spell Scroll
Spell LevelRaritySave DCAttack Bonus
CantripCommon13+5
1stCommon13+5
2ndUncommon13+5
3rdUncommon15+7
4thRare15+7
5thRare17+9
6thVery rare17+9
7thVery rare18+10
8thVery rare18+10
9thLegendary19+11

A wizard spell on a spell scroll can be copied just as spells in spellbooks can be copied. When a spell is copied from a spell scroll, the copier must succeed on an Intelligence (Arcana) check with a DC equal to 10 + the spell’s level. If the check succeeds, the spell is successfully copied. Whether the check succeeds or fails, the spell scroll is destroyed.

", + "markdown": "" + }, + "video": { + "controls": true, + "volume": 0.5 + }, + "src": null, + "system": { + "tooltip": "", + "type": "rule" + }, + "ownership": { + "default": -1 + }, + "flags": {}, + "_stats": { + "systemId": "dnd5e", + "systemVersion": "3.2.0", + "coreVersion": "11.315", + "createdTime": 1711217829430, + "modifiedTime": 1711218278065, + "lastModifiedBy": "dnd5ebuilder0000" + }, + "_key": "!journal.pages!NizgRXLNUqtdlC1s.gi8IKhtOlBVhMJrN" } ], "folder": null, @@ -10839,10 +10877,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1704059808409, - "modifiedTime": 1706217746263, + "modifiedTime": 1711218278065, "lastModifiedBy": "dnd5ebuilder0000" }, "_id": "NizgRXLNUqtdlC1s", diff --git a/packs/_source/rules/chapter-11-dm-tools.json b/packs/_source/rules/chapter-11-dm-tools.json index c70ca02732..d5b854e250 100644 --- a/packs/_source/rules/chapter-11-dm-tools.json +++ b/packs/_source/rules/chapter-11-dm-tools.json @@ -181,7 +181,7 @@ }, "text": { "format": 1, - "content": "

Magic items are gleaned from the hoards of conquered monsters or discovered in long-lost vaults. Such items grant capabilities a character could rarely have otherwise, or they complement their owner's capabilities in wondrous ways.

Attunement

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UQ65OwIyGK65eiOK inline]

Wearing and Wielding Items

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J inline]

Activating an Item

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.PqHSxFUwaXHL7kSz inline]

Magic Items A-Z

Magic items are presented in alphabetical order. A magic item's description gives the item's name, its category, its rarity, and its magical properties.

@UUID[Compendium.dnd5e.rules.JournalEntry.sfJtvPjEs50Ruzi4]{Magic Items A-Z}

Sentient Magic Items

Some magic items possess sentience and personality. Such an item might be possessed, haunted by the spirit of a previous owner, or self-aware thanks to the magic used to create it. In any case, the item behaves like a character, complete with personality quirks, ideals, bonds, and sometimes flaws. A sentient item might be a cherished ally to its wielder or a continual thorn in the side.

Most sentient items are weapons. Other kinds of items can manifest sentience, but consumable items such as potions and scrolls are never sentient.

Sentient magic items function as NPCs under the GM's control. Any activated property of the item is under the item's control, not its wielder's. As long as the wielder maintains a good relationship with the item, the wielder can access those properties normally. If the relationship is strained, the item can suppress its activated properties or even turn them against the wielder.

Creating Sentient Magic Items

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Kh16IbKZRyEer1ju inline]

Conflict

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.S48JxKMOF2ZmAfav inline]

Artifacts

@UUID[Compendium.dnd5e.items.Item.1RwJWOAeyoideLKe]{Orb of Dragonkind}

", + "content": "

Magic items are gleaned from the hoards of conquered monsters or discovered in long-lost vaults. Such items grant capabilities a character could rarely have otherwise, or they complement their owner's capabilities in wondrous ways.

Attunement

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UQ65OwIyGK65eiOK inline]

Wearing and Wielding Items

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J inline]

Activating an Item

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.PqHSxFUwaXHL7kSz inline]

Magic Items A-Z

Magic items are presented in alphabetical order. A magic item's description gives the item's name, its category, its rarity, and its magical properties.

@UUID[Compendium.dnd5e.rules.JournalEntry.sfJtvPjEs50Ruzi4]{Magic Items A-Z}

Spell Scrolls

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gi8IKhtOlBVhMJrN inline]

Sentient Magic Items

Some magic items possess sentience and personality. Such an item might be possessed, haunted by the spirit of a previous owner, or self-aware thanks to the magic used to create it. In any case, the item behaves like a character, complete with personality quirks, ideals, bonds, and sometimes flaws. A sentient item might be a cherished ally to its wielder or a continual thorn in the side.

Most sentient items are weapons. Other kinds of items can manifest sentience, but consumable items such as potions and scrolls are never sentient.

Sentient magic items function as NPCs under the GM's control. Any activated property of the item is under the item's control, not its wielder's. As long as the wielder maintains a good relationship with the item, the wielder can access those properties normally. If the relationship is strained, the item can suppress its activated properties or even turn them against the wielder.

Creating Sentient Magic Items

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Kh16IbKZRyEer1ju inline]

Conflict

@Embed[Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.S48JxKMOF2ZmAfav inline]

Artifacts

@UUID[Compendium.dnd5e.items.Item.1RwJWOAeyoideLKe]{Orb of Dragonkind}

", "markdown": "" }, "_id": "YHsuCV4IZZZoyDap", @@ -199,10 +199,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": null, - "modifiedTime": 1705191662357, + "modifiedTime": 1711218115742, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!journal.pages!t6scHTmpmHBTZGTX.YHsuCV4IZZZoyDap" @@ -257,10 +257,10 @@ }, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1660925148884, - "modifiedTime": 1705193612784, + "modifiedTime": 1711218115742, "lastModifiedBy": "dnd5ebuilder0000" }, "folder": null, From 9c2a3b39f9eead7dabeb62d954760fe661b2107a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 09:55:45 -0700 Subject: [PATCH 013/199] [#3175] Prevent resource max targeted by AE from incrementing --- module/applications/actor/character-sheet-2.mjs | 3 ++- templates/actors/character-sheet-2.hbs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 666f0787ca..5c91aefcd5 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1212,11 +1212,12 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { // Legacy resources const resources = Object.entries(this.actor.system.resources).reduce((arr, [k, r]) => { const { value, max, sr, lr, label } = r; + const source = this.actor._source.system.resources[k]; if ( label && max ) arr.push({ id: `resources.${k}`, type: "resource", img: "icons/svg/upgrade.svg", - resource: { value, max }, + resource: { value, max, source }, css: "uses", title: label, subtitle: [ diff --git a/templates/actors/character-sheet-2.hbs b/templates/actors/character-sheet-2.hbs index a23a67119c..4da1f1b1bb 100644 --- a/templates/actors/character-sheet-2.hbs +++ b/templates/actors/character-sheet-2.hbs @@ -423,8 +423,8 @@ {{ resource.value }} {{/if}} / - {{#if @root.actor.isOwner}} - {{else}} From 0c2ab42d5e63bf4fc7dbc44e12e0f060ad9aa8c9 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 10:36:32 -0700 Subject: [PATCH 014/199] [#3298] Fix issue preventing races from being deleted --- module/documents/advancement/size.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/documents/advancement/size.mjs b/module/documents/advancement/size.mjs index 6ee1af5a71..5be8f731f1 100644 --- a/module/documents/advancement/size.mjs +++ b/module/documents/advancement/size.mjs @@ -94,6 +94,6 @@ export default class SizeAdvancement extends Advancement { /** @inheritdoc */ async reverse(level) { this.actor.updateSource({"system.traits.size": "med"}); - this.updateSource({ "value.-=size": null }); + this.updateSource({ "value.size": null }); } } From 010813c72b2de695017062855d4cc0b31ee868ca Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 10:52:07 -0700 Subject: [PATCH 015/199] Update system.json for 3.1.1 release --- system.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system.json b/system.json index c67e56baa1..b148408443 100644 --- a/system.json +++ b/system.json @@ -2,14 +2,14 @@ "id": "dnd5e", "title": "Dungeons & Dragons Fifth Edition", "description": "A system for playing the fifth edition of the world's most popular role-playing game in the Foundry Virtual Tabletop environment.", - "version": "3.1.0", + "version": "3.1.1", "compatibility": { "minimum": "11.315", "verified": "12" }, "url": "https://github.com/foundryvtt/dnd5e/", "manifest": "https://raw.githubusercontent.com/foundryvtt/dnd5e/master/system.json", - "download": "https://github.com/foundryvtt/dnd5e/releases/download/release-3.1.0/dnd5e-release-3.1.0.zip", + "download": "https://github.com/foundryvtt/dnd5e/releases/download/release-3.1.1/dnd5e-release-3.1.1.zip", "authors": [ { "name": "Atropos", From ca6d120553281add2ecbad952264858aaec44663 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 11:28:31 -0700 Subject: [PATCH 016/199] [#3269] Add spell components back to chat cards --- module/data/item/spell.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/module/data/item/spell.mjs b/module/data/item/spell.mjs index fc6e53c9c5..ef7f4b4e02 100644 --- a/module/data/item/spell.mjs +++ b/module/data/item/spell.mjs @@ -113,6 +113,7 @@ export default class SpellData extends ItemDataModel.mixin( const context = await super.getCardData(enrichmentOptions); context.isSpell = true; context.subtitle = [this.parent.labels.level, CONFIG.DND5E.spellSchools[this.school]?.label].filterJoin(" • "); + if ( this.parent.labels.components.vsm ) context.tags = [this.parent.labels.components.vsm, ...context.tags]; return context; } From d0f9e98c9128791c556e17475d01bc82c09c3531 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 12:52:39 -0700 Subject: [PATCH 017/199] [#1856] Allows HD & attribute consumption to be set on world items Displays the list of hit dice consumption options for items not in an actor so they can be selected. For attributes, changes the select dropdown to a text input so the attribute can be entered or modified manually for non-embedded items. --- less/v1/items.less | 2 +- module/applications/item/item-sheet.mjs | 3 ++- templates/items/parts/item-activation.hbs | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/less/v1/items.less b/less/v1/items.less index 277a6477a1..d148fb2d74 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -254,7 +254,7 @@ .form-fields { flex-wrap: nowrap; } - &.consumption input { + &.consumption [name="system.consume.amount"] { flex: 0 0 32px; } span { diff --git a/module/applications/item/item-sheet.mjs b/module/applications/item/item-sheet.mjs index a0ca719ff0..e6667abfeb 100644 --- a/module/applications/item/item-sheet.mjs +++ b/module/applications/item/item-sheet.mjs @@ -137,6 +137,7 @@ export default class ItemSheet5e extends ItemSheet { concealDetails: !game.user.isGM && (this.document.system.identified === false) }); context.abilityConsumptionTargets = this._getItemConsumptionTargets(); + context.abilityConsumptionManual = !item.isEmbedded && (this.item.system.consume?.type === "attribute"); if ( ("properties" in item.system) && (item.type in CONFIG.DND5E.validProperties) ) { context.properties = item.system.validProperties.reduce((obj, k) => { @@ -261,7 +262,7 @@ export default class ItemSheet5e extends ItemSheet { const consume = this.item.system.consume || {}; if ( !consume.type ) return []; const actor = this.item.actor; - if ( !actor ) return {}; + if ( !actor && (consume.type !== "hitDice") ) return {}; // Ammunition if ( consume.type === "ammo" ) { diff --git a/templates/items/parts/item-activation.hbs b/templates/items/parts/item-activation.hbs index 0150dce730..b65862bf15 100644 --- a/templates/items/parts/item-activation.hbs +++ b/templates/items/parts/item-activation.hbs @@ -150,9 +150,14 @@ {{#if system.consume.type}} + {{#if abilityConsumptionManual}} + + {{else}} + {{/if}} {{/if}} - {{#if abilityConsumptionManual}} + {{#if abilityConsumptionHint}} + data-tooltip="{{ abilityConsumptionHint }}"> {{else}}
- +
-

{{localize "JOURNALENTRYPAGE.DND5E.Class.SubclassDescriptionHint"}}

+

{{ localize "JOURNALENTRYPAGE.DND5E.Class.SubclassDescriptionHint" }}

- +
    {{#each subclasses}}
  1. -
    {{{dnd5e-linkForUuid this.document.uuid}}}
    +
    {{{ dnd5e-linkForUuid this.document.uuid }}}
  2. {{else}} -
  3. {{localize "JOURNALENTRYPAGE.DND5E.Class.SubclassHint"}}
  4. +
  5. {{ localize "JOURNALENTRYPAGE.DND5E.Class.SubclassHint" }}
  6. {{/each}}
diff --git a/templates/journal/page-class-view.hbs b/templates/journal/page-class-view.hbs index f60afefbaf..083e0e016e 100644 --- a/templates/journal/page-class-view.hbs +++ b/templates/journal/page-class-view.hbs @@ -1,89 +1,91 @@ {{#if data.title.show}}
- {{data.name}} + {{ data.name }}
{{/if}} {{#if linked.document}}
- {{{enriched.value}}} + {{{ enriched.value }}} - {{localize "JOURNALENTRYPAGE.DND5E.Class.FeaturesHeader"}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.FeaturesHeader" }}

- {{localize "JOURNALENTRYPAGE.DND5E.Class.FeaturesDescription" name=linked.name lowercaseName=linked.lowercaseName}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.FeaturesDescription" name=linked.name + lowercaseName=linked.lowercaseName }}

{{#if (or advancement.hp enriched.additionalHitPoints)}} - {{localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsHeader"}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsHeader" }}

- {{{localize "JOURNALENTRYPAGE.DND5E.Class.HitDice" dice=advancement.hp.hitDice class=linked.lowercaseName}}}
- {{{localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsLevel1" max=advancement.hp.max}}}
- {{{localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsLevelX" dice=advancement.hp.hitDice - average=advancement.hp.average class=linked.lowercaseName}}} + {{{ localize "JOURNALENTRYPAGE.DND5E.Class.HitDice" dice=advancement.hp.hitDice + class=linked.lowercaseName }}}
+ {{{ localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsLevel1" max=advancement.hp.max }}}
+ {{{ localize "JOURNALENTRYPAGE.DND5E.Class.HitPointsLevelX" dice=advancement.hp.hitDice + average=advancement.hp.average class=linked.lowercaseName}}}

- {{{enriched.additionalHitPoints}}} + {{{ enriched.additionalHitPoints }}} {{/if}} {{#if (or advancement.traits enriched.additionalTraits)}} - {{localize "JOURNALENTRYPAGE.DND5E.Class.TraitsHeader"}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.TraitsHeader" }} {{#if advancement.traits}}

- {{localize "DND5E.Armor"}}: {{advancement.traits.armor}}
- {{localize "TYPES.Item.weaponPl"}}: {{advancement.traits.weapons}}
- {{localize "TYPES.Item.toolPl"}}: {{advancement.traits.tools}}
- {{localize "DND5E.ClassSaves"}}: {{advancement.traits.saves}}
- {{localize "DND5E.Skills"}}: {{advancement.traits.skills}} + {{ localize "DND5E.Armor" }}: {{ advancement.traits.armor }}
+ {{ localize "TYPES.Item.weaponPl" }}: {{ advancement.traits.weapons }}
+ {{ localize "TYPES.Item.toolPl" }}: {{ advancement.traits.tools }}
+ {{ localize "DND5E.ClassSaves" }}: {{ advancement.traits.saves }}
+ {{ localize "DND5E.Skills" }}: {{ advancement.traits.skills }}

{{/if}} - {{{enriched.additionalTraits}}} + {{{ enriched.additionalTraits }}} {{/if}} {{#if (or advancement.equipment enriched.additionalEquipment)}} - {{localize "JOURNALENTRYPAGE.DND5E.Class.EquipmentHeader"}} - {{{enriched.additionalEquipment}}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.EquipmentHeader" }} + {{{ enriched.additionalEquipment }}} {{/if}} {{> "dnd5e.journal-table" table=table level=title.level3 - caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableCaption" class=linked.name)}} + caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableCaption" class=linked.name) }} {{#each features}} - {{this.name}} - {{{this.description}}} + {{ this.name }} + {{{ this.description }}} {{/each}} {{#if optionalTable}} - {{localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesCaption"}} -

{{localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesDescription" class=linked.lowercaseName}}

+ {{ localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesCaption" }} +

{{ localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesDescription" class=linked.lowercaseName }}

{{> "dnd5e.journal-table" table=optionalTable level=title.level3 - caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableOptionalCaption" class=linked.name)}} + caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableOptionalCaption" class=linked.name) }} {{#each optionalFeatures}} - {{this.name}} - {{{this.description}}} + {{ this.name }} + {{{ this.description }}} {{/each}} {{/if}} {{#if (or enriched.subclass subclasses)}} - {{#if system.subclassHeader}}{{system.subclassHeader}} - {{else}}{{localize "JOURNALENTRYPAGE.DND5E.Class.SubclassItems"}}{{/if}} + {{#if system.subclassHeader}}{{ system.subclassHeader }} + {{else}}{{ localize "JOURNALENTRYPAGE.DND5E.Class.SubclassItems" }}{{/if}} - {{{enriched.subclass}}} + {{{ enriched.subclass }}} {{#each subclasses}} - {{this.name}} + {{ this.name }} {{{this.description}}} {{#if this.table}} {{> "dnd5e.journal-table" table=this.table level=title.level4 - caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableCaption" class=this.name)}} + caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableCaption" class=this.name) }} {{/if}} {{#each this.features}} - {{this.name}} - {{{this.description}}} + {{ this.name }} + {{{ this.description }}} {{/each}} {{/each}} {{/if}} @@ -91,6 +93,6 @@ {{else}}
- {{localize "JOURNALENTRYPAGE.DND5E.Class.NoValidClass"}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.NoValidClass" }}
{{/if}} diff --git a/templates/journal/page-subclass-edit.hbs b/templates/journal/page-subclass-edit.hbs new file mode 100644 index 0000000000..ddd0d1e50c --- /dev/null +++ b/templates/journal/page-subclass-edit.hbs @@ -0,0 +1,34 @@ +
+ {{> journalEntryPageHeader }} + +
+ +
+
    + {{#if document.system.item}} +
  1. +
    {{{ dnd5e-linkForUuid document.system.item }}}
    +
    + + + +
    +
  2. + {{else}} +
  3. {{ localize "JOURNALENTRYPAGE.DND5E.Subclass.ItemHint" }}
  4. + {{/if}} +
+
+
+ +
+ +
+ +
+

{{localize "JOURNALENTRYPAGE.DND5E.Subclass.DescriptionHint"}}

+
+
diff --git a/templates/journal/page-subclass-view.hbs b/templates/journal/page-subclass-view.hbs new file mode 100644 index 0000000000..09807e3e79 --- /dev/null +++ b/templates/journal/page-subclass-view.hbs @@ -0,0 +1,38 @@ +{{#if data.title.show}} +
+ {{data.name}} +
+{{/if}} + +{{#if linked.document}} + +
+ {{{ enriched.value }}} + + {{> "dnd5e.journal-table" table=table level=title.level3 + caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableCaption" class=linked.name) }} + + {{#each features}} + {{ this.name }} + {{{ this.description }}} + {{/each}} + + {{#if optionalTable}} + {{ localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesCaption" }} +

{{ localize "JOURNALENTRYPAGE.DND5E.Class.OptionalFeaturesDescription" class=linked.lowercaseName }}

+ + {{> "dnd5e.journal-table" table=optionalTable level=title.level3 + caption=(localize "JOURNALENTRYPAGE.DND5E.Class.TableOptionalCaption" class=linked.name) }} + + {{#each optionalFeatures}} + {{ this.name }} + {{{ this.description }}} + {{/each}} + {{/if}} +
+ +{{else}} +
+ {{ localize "JOURNALENTRYPAGE.DND5E.Subclass.NoValidSubclass" }} +
+{{/if}} From 04962df892ea0ecb979abb16b7ebd15158a9bfd5 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 27 Mar 2024 14:09:24 -0700 Subject: [PATCH 028/199] [#3242] Add ability to change creature type when summoning Introduces a new `creatureTypes` set in summoning configuration that defines creature types that the summoned creature will be turned into upon summoning. If more than one type is listed, then the player will have the chance to choose that in the usage dialog. Makes use of Foundry's new `` element for input. --- lang/en.json | 4 + less/v1/items.less | 6 ++ .../applications/item/ability-use-dialog.mjs | 19 +++-- module/applications/item/summoning-config.mjs | 4 + module/data/item/fields/summons-field.mjs | 77 +++++++++++++------ module/documents/item.mjs | 22 ++++-- templates/apps/ability-use.hbs | 17 +++- templates/apps/summoning-config.hbs | 9 +++ 8 files changed, 115 insertions(+), 43 deletions(-) diff --git a/lang/en.json b/lang/en.json index 86002522d6..e3e7d080f2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1480,6 +1480,10 @@ "Label": "Creature Changes", "Hint": "Changes that will be made to the creature being summoned. Any @ references used in the following formulas will be based on the summoner's stats." }, + "CreatureTypes": { + "Label": "Creature Types", + "Hint": "Summoned creature will be changed to this type. If more than one type is selected, then player will be able to choose from these types when summoning." + }, "DisplayName": "Display Name", "DropHint": "Drop creature here", "ItemChanges": { diff --git a/less/v1/items.less b/less/v1/items.less index 277a6477a1..b0ea1850d1 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -545,6 +545,8 @@ /* ----------------------------------------- */ .dnd5e.summoning-config { + max-block-size: 90vh; + .unbutton { width: unset; border: none; @@ -596,4 +598,8 @@ padding-inline: 4px; } } + + multi-select .tags .tag { + cursor: pointer; + } } diff --git a/module/applications/item/ability-use-dialog.mjs b/module/applications/item/ability-use-dialog.mjs index 000e6daf53..d6627c5844 100644 --- a/module/applications/item/ability-use-dialog.mjs +++ b/module/applications/item/ability-use-dialog.mjs @@ -177,17 +177,22 @@ export default class AbilityUseDialog extends Dialog { /** * Create an array of summoning profiles. * @param {Item5e} item The item. - * @returns {object|null} Array of select options. + * @returns {{ profiles: object, creatureTypes: object }|null} Array of select options. */ static _createSummoningOptions(item) { - const profiles = item.system.summons?.profiles ?? []; - if ( profiles.length <= 1 ) return null; + const summons = item.system.summons; + if ( !summons?.profiles.length ) return null; const options = {}; - for ( const profile of profiles ) { + if ( summons.profiles.length > 1 ) options.profiles = summons.profiles.reduce((obj, profile) => { const doc = profile.uuid ? fromUuidSync(profile.uuid) : null; - if ( profile.uuid && !doc ) continue; - options[profile._id] = profile.name ? profile.name : (doc?.name ?? "—"); - } + if ( !profile.uuid || doc ) obj[profile._id] = profile.name ? profile.name : (doc?.name ?? "—"); + return obj; + }, {}); + else options.profile = summons.profiles[0]._id; + if ( summons.creatureTypes.size > 1 ) options.creatureTypes = summons.creatureTypes.reduce((obj, k) => { + obj[k] = CONFIG.DND5E.creatureTypes[k].label; + return obj; + }, {}); return options; } diff --git a/module/applications/item/summoning-config.mjs b/module/applications/item/summoning-config.mjs index 8c554c30e5..33ee2d686a 100644 --- a/module/applications/item/summoning-config.mjs +++ b/module/applications/item/summoning-config.mjs @@ -52,6 +52,10 @@ export default class SummoningConfig extends DocumentSheet { (lhs.name || lhs.document?.name || "").localeCompare(rhs.name || rhs.document?.name || "", game.i18n.lang) ); context.summons = this.document.system.summons; + context.creatureTypes = Object.entries(CONFIG.DND5E.creatureTypes).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: context.summons?.creatureTypes.has(k) ? "selected" : "" }; + return obj; + }, {}); return context; } diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 8eee7c94e8..a1d0ad2fe0 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -2,7 +2,7 @@ import TokenPlacement from "../../../canvas/token-placement.mjs"; import { FormulaField } from "../../fields.mjs"; const { - ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, StringField + ArrayField, BooleanField, DocumentIdField, NumberField, SchemaField, SetField, StringField } = foundry.data.fields; /** @@ -35,6 +35,7 @@ export default class SummonsField extends foundry.data.fields.EmbeddedDataField * @property {string} bonuses.attackDamage Formula for bonus added to damage for attacks. * @property {string} bonuses.saveDamage Formula for bonus added to damage for saving throws. * @property {string} bonuses.healing Formula for bonus added to healing. + * @property {Set} creatureTypes Set of creature types that will be set on summoned creature. * @property {object} match * @property {boolean} match.attacks Match the to hit values on summoned actor's attack to the summoner. * @property {boolean} match.proficiency Match proficiency on summoned actor to the summoner. @@ -63,6 +64,9 @@ export class SummonsData extends foundry.abstract.DataModel { label: "DND5E.Summoning.Bonuses.Healing.Label", hint: "DND5E.Summoning.Bonuses.Healing.Hint" }) }), + creatureTypes: new SetField(new StringField(), { + label: "DND5E.Summoning.CreatureTypes.Label", hint: "DND5E.Summoning.CreatureTypes.Hint" + }), match: new SchemaField({ attacks: new BooleanField({ label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint" @@ -116,11 +120,19 @@ export class SummonsData extends foundry.abstract.DataModel { /* Summoning */ /* -------------------------------------------- */ + /** + * Additional options that might modify summoning behavior. + * + * @typedef {object} SummoningOptions + * @property {string} creatureType Selected creature type if multiple are available. + */ + /** * Process for summoning actor to the scene. - * @param {string} profileId ID of the summoning profile to use. + * @param {string} profileId ID of the summoning profile to use. + * @param {object} [options={}] Additional summoning options. */ - async summon(profileId) { + async summon(profileId, options={}) { if ( !this.canSummon || !canvas.scene ) return; const profile = this.profiles.find(p => p._id === profileId); @@ -132,11 +144,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires before summoning is performed. * @function dnd5e.preSummon * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @returns {boolean} Explicitly return `false` to prevent summoning. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {SummoningOptions} options Additional summoning options. + * @returns {boolean} Explicitly return `false` to prevent summoning. */ - if ( Hooks.call("dnd5e.preSummon", this.item, profile) === false ) return; + if ( Hooks.call("dnd5e.preSummon", this.item, profile, options) === false ) return; // Fetch the actor that will be summoned const actor = await this.fetchActor(profile.uuid); @@ -159,7 +172,7 @@ export class SummonsData extends foundry.abstract.DataModel { actor, placement, tokenUpdates: {}, - actorUpdates: await this.getChanges(actor, profile) + actorUpdates: await this.getChanges(actor, profile, options) }; /** @@ -167,12 +180,13 @@ export class SummonsData extends foundry.abstract.DataModel { * the final token data is constructed. * @function dnd5e.preSummonToken * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {TokenUpdateData} config Configuration for creating a modified token. - * @returns {boolean} Explicitly return `false` to prevent this token from being summoned. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {TokenUpdateData} config Configuration for creating a modified token. + * @param {SummoningOptions} options Additional summoning options. + * @returns {boolean} Explicitly return `false` to prevent this token from being summoned. */ - if ( Hooks.call("dnd5e.preSummonToken", this.item, profile, tokenUpdateData) === false ) continue; + if ( Hooks.call("dnd5e.preSummonToken", this.item, profile, tokenUpdateData, options) === false ) continue; // Create a token document and apply updates const tokenData = await this.getTokenData(tokenUpdateData); @@ -181,11 +195,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires after token creation data is prepared, but before summoning occurs. * @function dnd5e.summonToken * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {object} tokenData Data for creating a token. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {object} tokenData Data for creating a token. + * @param {SummoningOptions} options Additional summoning options. */ - Hooks.callAll("dnd5e.summonToken", this.item, profile, tokenData); + Hooks.callAll("dnd5e.summonToken", this.item, profile, tokenData, options); tokensData.push(tokenData); } @@ -199,11 +214,12 @@ export class SummonsData extends foundry.abstract.DataModel { * A hook event that fires when summoning is complete. * @function dnd5e.postSummon * @memberof hookEvents - * @param {Item5e} item The item that is performing the summoning. - * @param {SummonsProfile} profile Profile used for summoning. - * @param {Token5e[]} tokens Tokens that have been created. + * @param {Item5e} item The item that is performing the summoning. + * @param {SummonsProfile} profile Profile used for summoning. + * @param {Token5e[]} tokens Tokens that have been created. + * @param {SummoningOptions} options Additional summoning options. */ - Hooks.callAll("dnd5e.postSummon", this.item, profile, createdTokens); + Hooks.callAll("dnd5e.postSummon", this.item, profile, createdTokens, options); } /* -------------------------------------------- */ @@ -237,11 +253,12 @@ export class SummonsData extends foundry.abstract.DataModel { /** * Prepare the updates to apply to the summoned actor. - * @param {Actor5e} actor Actor that will be modified. - * @param {SummonsProfile} profile Summoning profile used to summon the actor. - * @returns {object} Changes that will be applied to the actor & its items. + * @param {Actor5e} actor Actor that will be modified. + * @param {SummonsProfile} profile Summoning profile used to summon the actor. + * @param {SummoningOptions} options Additional summoning options. + * @returns {object} Changes that will be applied to the actor & its items. */ - async getChanges(actor, profile) { + async getChanges(actor, profile, options) { const updates = { effects: [], items: [] }; const rollData = this.item.getRollData(); const prof = rollData.attributes?.prof ?? 0; @@ -306,6 +323,16 @@ export class SummonsData extends foundry.abstract.DataModel { } } + // Change creature type + if ( this.creatureTypes.size ) { + const type = this.creatureTypes.has(options.creatureType) ? options.creatureType : this.creatureTypes.first(); + if ( actor.system.details?.race instanceof Item ) { + updates.items.push({ _id: actor.system.details.race.id, "system.type.value": type }); + } else { + updates["system.details.type.value"] = type; + } + } + const attackDamageBonus = Roll.replaceFormulaData(this.bonuses.attackDamage, rollData); const saveDamageBonus = Roll.replaceFormulaData(this.bonuses.saveDamage, rollData); const healingBonus = Roll.replaceFormulaData(this.bonuses.healing, rollData); diff --git a/module/documents/item.mjs b/module/documents/item.mjs index e5d49b8fe6..7817e9b34f 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1057,7 +1057,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { let summoned; if ( config.createSummons ) { try { - summoned = await item.system.summons.summon(config.summonsProfile); + summoned = await item.system.summons.summon(config.summonsProfile, config.summonsOptions); } catch(err) { Hooks.onError("Item5e#use", err, { log: "error", notify: "error" }); } @@ -2137,15 +2137,19 @@ export default class Item5e extends SystemDocumentMixin(Item) { */ static async _onChatCardSummon(message, item) { let summonsProfile; + let summonsOptions = {}; + let needsConfiguration = false; // No profile specified and only one profile on item, use that one - if ( item.system.summons.profiles.length === 1 ) { - summonsProfile = item.system.summons.profiles[0]._id; - } + if ( item.system.summons.profiles.length === 1 ) summonsProfile = item.system.summons.profiles[0]._id; + else needsConfiguration = true; - // Otherwise show the item use dialog to get the profile - else { - const config = await AbilityUseDialog.create(item, { + // More than one creature type requires configuration + if ( item.system.summons.creatureTypes.size > 1 ) needsConfiguration = true; + + // Show the item use dialog to get the profile and other options + if ( needsConfiguration ) { + let config = await AbilityUseDialog.create(item, { beginConcentrating: null, consumeResource: null, consumeSpellSlot: null, @@ -2160,11 +2164,13 @@ export default class Item5e extends SystemDocumentMixin(Item) { disableScaling: true }); if ( !config?.summonsProfile ) return; + config = foundry.utils.expandObject(config); summonsProfile = config.summonsProfile; + summonsOptions = config.summonsOptions; } try { - await item.system.summons.summon(summonsProfile); + await item.system.summons.summon(summonsProfile, summonsOptions); } catch(err) { Hooks.onError("Item5e#_onChatCardSummon", err, { log: "error", notify: "error" }); } diff --git a/templates/apps/ability-use.hbs b/templates/apps/ability-use.hbs index 05fa63ef8c..b98398a34c 100644 --- a/templates/apps/ability-use.hbs +++ b/templates/apps/ability-use.hbs @@ -91,15 +91,26 @@ {{ localize "DND5E.Summoning.Action.Place" }} - {{#if summoningOptions}} + {{#if summoningOptions.profiles}}
{{else}} - + {{/if}}
+ + {{#if summoningOptions.creatureTypes}} +
+ +
+ +
+
+ {{/if}} {{/if}} diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs index fc7037d353..a29e48695d 100644 --- a/templates/apps/summoning-config.hbs +++ b/templates/apps/summoning-config.hbs @@ -51,6 +51,15 @@

{{ localize "DND5E.Summoning.Bonuses.HitPoints.Hint" }}

+
+ + + {{#each creatureTypes}} + + {{/each}} + +

{{ localize "DND5E.Summoning.CreatureTypes.Hint" }}

+

{{ localize "DND5E.Summoning.ItemChanges.Label" }}

{{ localize "DND5E.Summoning.ItemChanges.Hint" }}

From 0d035385a62772f5c9f3e4e6e2fc899337702f97 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 27 Mar 2024 15:31:18 -0700 Subject: [PATCH 029/199] [#1549] Allow NPCs to get spellcasting details from classes NPCs will now use classes to calculated spellcasting levels allowing for them to use any spellcasting type. If the existing `spellLevel` is set on the class then that number is added to the spellcaster level calculated from their classes. --- module/documents/actor/actor.mjs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index 3609cdd31f..ad7eebaf53 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -581,27 +581,21 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { const progression = { slot: 0, pact: 0 }; const types = {}; - // NPCs don't get spell levels from classes - if ( this.type === "npc" ) { - progression.slot = this.system.details.spellLevel ?? 0; - types.leveled = 1; - } + // Grab all classes with spellcasting + const classes = this.items.filter(cls => { + if ( cls.type !== "class" ) return false; + const type = cls.spellcasting.type; + if ( !type ) return false; + types[type] ??= 0; + types[type] += 1; + return true; + }); - else { - // Grab all classes with spellcasting - const classes = this.items.filter(cls => { - if ( cls.type !== "class" ) return false; - const type = cls.spellcasting.type; - if ( !type ) return false; - types[type] ??= 0; - types[type] += 1; - return true; - }); + for ( const cls of classes ) this.constructor.computeClassProgression( + progression, cls, { actor: this, count: types[cls.spellcasting.type] } + ); - for ( const cls of classes ) this.constructor.computeClassProgression( - progression, cls, { actor: this, count: types[cls.spellcasting.type] } - ); - } + if ( this.type === "npc" ) progression.slot += this.system.details.spellLevel ?? 0; for ( const type of Object.keys(CONFIG.DND5E.spellcastingTypes) ) { this.constructor.prepareSpellcastingSlots(this.system.spells, type, progression, { actor: this }); From 00539303ae6c5112a49701cb108fe9850e79fb15 Mon Sep 17 00:00:00 2001 From: Kim Mantas Date: Wed, 27 Mar 2024 13:08:24 +0000 Subject: [PATCH 030/199] Miscellaneous CSS tweaks: - Moved object fit styles to common gold-icon class. - Fixed necrotic SVG path. - Bumped up damage icons in application tray from 11px to 13px. --- less/v2/apps.less | 2 ++ less/v2/chat.less | 13 +++++++++---- module/config.mjs | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/less/v2/apps.less b/less/v2/apps.less index 1d1d3e5c65..7c3b5c63ef 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -274,6 +274,8 @@ box-shadow: 0 0 4px var(--dnd5e-shadow-45); border-radius: 0; background-color: var(--dnd5e-color-light-gray); + object-fit: cover; + object-position: top; } .name-stacked { diff --git a/less/v2/chat.less b/less/v2/chat.less index 025fa319ac..fb2de2ca25 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -340,8 +340,6 @@ > img { width: 32px; height: 32px; - object-fit: cover; - background-color: var(--dnd5e-color-light-gray); } .name-stacked { @@ -527,9 +525,16 @@ .targets .target { flex-wrap: wrap; + .subtitle { + display: flex; + gap: 4px; + } + .change-source { - width: 16px; - padding: 2px; + width: 13px; + height: 13px; + --icon-size: 13px; + padding: 0; border-radius: 4px; display: grid; grid-template-areas: "overlay"; diff --git a/module/config.mjs b/module/config.mjs index 6578e936dd..4c60b42254 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -1567,7 +1567,7 @@ DND5E.damageTypes = { }, necrotic: { label: "DND5E.DamageNecrotic", - icon: "systems/dnd5e/icons/svg/damage/acid.svg", + icon: "systems/dnd5e/icons/svg/damage/necrotic.svg", reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.klOVUV5G1U7iaKoG" }, piercing: { From c90e7cfa9fc8ecd1d52eebadcf00bf9acded901f Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Thu, 28 Mar 2024 16:55:41 +0100 Subject: [PATCH 031/199] [#3375] Use the CONFIG to determine (or override) whether a spell preparation mode prepares or simply knows its spells (#3188) Co-authored-by: Zhell --- module/applications/actor/base-sheet.mjs | 13 +++++++------ module/applications/actor/character-sheet-2.mjs | 3 ++- module/config.mjs | 7 +++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index a324e522a6..1cb3daf481 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -393,14 +393,14 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { const useLabels = {"-20": "-", "-10": "-", 0: "∞"}; // Format a spellbook entry for a certain indexed level - const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => { + const registerSection = (sl, i, label, {prepMode="prepared", value, max, override, config}={}) => { const aeOverride = foundry.utils.hasProperty(this.actor.overrides, `system.spells.spell${i}.override`); spellbook[i] = { order: i, label: label, usesSlots: i > 0, canCreate: owner, - canPrepare: (context.actor.type === "character") && (i >= 1), + canPrepare: ((context.actor.type === "character") && (i >= 1)) || config?.prepares, spells: [], uses: useLabels[i] || value || 0, slots: useLabels[i] || max || 0, @@ -433,14 +433,14 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { if ( !spellbook["0"] && v.cantrips ) registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]); const l = levels[k]; - const config = CONFIG.DND5E.spellPreparationModes[k]; const level = game.i18n.localize(`DND5E.SpellLevel${l.level}`); - const label = `${config.label} — ${level}`; + const label = `${v.label} — ${level}`; registerSection(k, sections[k], label, { prepMode: k, value: l.value, max: l.max, - override: l.override + override: l.override, + config: v }); } @@ -460,7 +460,8 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { prepMode: mode, value: l.value, max: l.max, - override: l.override + override: l.override, + config: config }); } } diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 5c91aefcd5..dfdb8b883f 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -599,7 +599,8 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { // Prepared const mode = system.preparation?.mode; - if ( (mode === "always") || (mode === "prepared") ) { + const config = CONFIG.DND5E.spellPreparationModes[mode] ?? {}; + if ( config.prepares ) { const isAlways = mode === "always"; const prepared = isAlways || system.preparation.prepared; ctx.preparation = { diff --git a/module/config.mjs b/module/config.mjs index 4c60b42254..4a89a77563 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -1978,6 +1978,7 @@ DND5E.pactCastingProgression = { * @property {boolean} [upcast] Whether this preparation mode allows for upcasting. * @property {boolean} [cantrips] Whether this mode allows for cantrips in a spellbook. * @property {number} [order] The sort order of this mode in a spellbook. + * @property {boolean} [prepares] Whether this preparation mode prepares spells. */ /** @@ -1987,7 +1988,8 @@ DND5E.pactCastingProgression = { DND5E.spellPreparationModes = { prepared: { label: "DND5E.SpellPrepPrepared", - upcast: true + upcast: true, + prepares: true }, pact: { label: "DND5E.PactMagic", @@ -1997,7 +1999,8 @@ DND5E.spellPreparationModes = { }, always: { label: "DND5E.SpellPrepAlways", - upcast: true + upcast: true, + prepares: true }, atwill: { label: "DND5E.SpellPrepAtWill", From e6eeeb9b7178e57279a348a84782a8f6c6359158 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sat, 23 Mar 2024 14:57:23 -0700 Subject: [PATCH 032/199] [#3320] Add option to convert spell to scroll from context menu Adds context menu options to the sidebar directory and compendium listing that will create a new scroll in the sidebar, and to the actor's inventory that will create a new scroll on the actor. --- dnd5e.mjs | 3 + lang/en.json | 7 ++- module/applications/components/inventory.mjs | 11 ++++ module/documents/item.mjs | 58 +++++++++++++++++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/dnd5e.mjs b/dnd5e.mjs index 6781e886e7..0375911784 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -494,6 +494,9 @@ Hooks.on("chatMessage", (app, message, data) => dnd5e.applications.Award.chatMes Hooks.on("renderActorDirectory", (app, html, data) => documents.Actor5e.onRenderActorDirectory(html)); Hooks.on("getActorDirectoryEntryContext", documents.Actor5e.addDirectoryContextOptions); +Hooks.on("getCompendiumEntryContext", documents.Item5e.addCompendiumContextOptions); +Hooks.on("getItemDirectoryEntryContext", documents.Item5e.addDirectoryContextOptions); + Hooks.on("applyTokenStatusEffect", canvas.Token5e.onApplyTokenStatusEffect); Hooks.on("targetToken", canvas.Token5e.onTargetToken); diff --git a/lang/en.json b/lang/en.json index 86002522d6..a9a6a15146 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1211,8 +1211,11 @@ "DND5E.NewDayHint": "Recover limited use abilities which recharge \"per day\"?", "DND5E.SaveBonus": "Saving Throw Bonus", "DND5E.SaveGlobalBonusHint": "This bonus applies to all saving throws made by this actor.", -"DND5E.ScrollDetails": "Scroll Details", -"DND5E.ScrollRequiresConcentration": "Requires Concentration", +"DND5E.Scroll": { + "CreateScroll": "Create Scroll", + "Details": "Scroll Details", + "RequiresConcentration": "Requires Concentration" +}, "DND5E.Senses": "Senses", "DND5E.SensesConfig": "Configure Senses", "DND5E.SensesConfigHint": "Configure any special sensory perception abilities that this actor possesses.", diff --git a/module/applications/components/inventory.mjs b/module/applications/components/inventory.mjs index b80ed58da4..1d7e8cdd32 100644 --- a/module/applications/components/inventory.mjs +++ b/module/applications/components/inventory.mjs @@ -1,3 +1,4 @@ +import Item5e from "../../documents/item.mjs"; import {parseInputDelta} from "../../utils.mjs"; import CurrencyManager from "../currency-manager.mjs"; import ContextMenu5e from "../context-menu.mjs"; @@ -202,6 +203,16 @@ export default class InventoryElement extends HTMLElement { condition: () => item.isOwner, callback: li => this._onAction(li[0], "delete") }, + { + name: "DND5E.Scroll.CreateScroll", + icon: '', + callback: async li => { + const data = await Item5e.createScrollFromSpell(item); + Item5e.createDocuments([data.toObject()], { parent: this.actor }); + }, + condition: li => (item.type === "spell") && this.actor?.isOwner, + group: "action" + }, { name: "DND5E.ConcentrationBreak", icon: '', diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 829b22203c..b2d3985d88 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2572,6 +2572,58 @@ export default class Item5e extends SystemDocumentMixin(Item) { /* Factory Methods */ /* -------------------------------------------- */ + /** + * Add additional system-specific compendium context menu options for Item documents. + * @param {jQuery} html The compendium HTML. + * @param {object{}} entryOptions The default array of context menu options. + */ + static addCompendiumContextOptions(html, entryOptions) { + const makeUuid = li => { + const pack = li[0].closest("[data-pack]")?.dataset.pack; + return `Compendium.${pack}.Item.${li.data("documentId")}`; + }; + entryOptions.push({ + name: "DND5E.Scroll.CreateScroll", + icon: '', + callback: async li => { + const spell = await fromUuid(makeUuid(li)); + const data = await Item5e.createScrollFromSpell(spell); + Item5e.createDocuments([data.toObject()]); + }, + condition: li => { + const item = fromUuidSync(makeUuid(li)); + return (item?.type === "spell") && game.user.hasPermission("ITEM_CREATE"); + }, + group: "system" + }); + } + + /* -------------------------------------------- */ + + /** + * Add additional system-specific sidebar directory context menu options for Item documents. + * @param {jQuery} html The sidebar HTML. + * @param {object[]} entryOptions The default array of context menu options. + */ + static addDirectoryContextOptions(html, entryOptions) { + entryOptions.push({ + name: "DND5E.Scroll.CreateScroll", + icon: '', + callback: async li => { + const spell = game.items.get(li.data("documentId")); + const data = await Item5e.createScrollFromSpell(spell); + Item5e.createDocuments([data.toObject()]); + }, + condition: li => { + const item = game.items.get(li.data("documentId")); + return (item.type === "spell") && game.user.hasPermission("ITEM_CREATE"); + }, + group: "system" + }); + } + + /* -------------------------------------------- */ + /** * Prepare creation data for the provided items and any items contained within them. The data created by this method * can be passed to `createDocuments` with `keepId` always set to true to maintain links to container contents. @@ -2682,11 +2734,11 @@ export default class Item5e extends SystemDocumentMixin(Item) { scrollIntro, "
", `

${itemData.name} (${game.i18n.format("DND5E.LevelNumber", {level})})

`, - isConc ? `

${game.i18n.localize("DND5E.ScrollRequiresConcentration")}

` : null, + isConc ? `

${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}

` : null, "
", description.value, "
", - `

${game.i18n.localize("DND5E.ScrollDetails")}

`, + `

${game.i18n.localize("DND5E.Scroll.Details")}

`, "
", scrollDetails ].filterJoin(""); @@ -2696,7 +2748,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { "

", CONFIG.DND5E.spellLevels[level] ?? level, "&Reference[Spell Scroll]", - isConc ? `, ${game.i18n.localize("DND5E.ScrollRequiresConcentration")}` : null, + isConc ? `, ${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}` : null, "

", description.value ].filterJoin(""); From ea32c60cd356943717dba30253fe447496fa7a49 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 28 Mar 2024 10:06:56 -0700 Subject: [PATCH 033/199] [#3320] Simplify scroll creation code --- module/applications/components/inventory.mjs | 5 +---- module/documents/item.mjs | 6 ++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/module/applications/components/inventory.mjs b/module/applications/components/inventory.mjs index 1d7e8cdd32..1ea037abd5 100644 --- a/module/applications/components/inventory.mjs +++ b/module/applications/components/inventory.mjs @@ -206,10 +206,7 @@ export default class InventoryElement extends HTMLElement { { name: "DND5E.Scroll.CreateScroll", icon: '', - callback: async li => { - const data = await Item5e.createScrollFromSpell(item); - Item5e.createDocuments([data.toObject()], { parent: this.actor }); - }, + callback: async li => Item5e.create(await Item5e.createScrollFromSpell(item), { parent: this.actor }), condition: li => (item.type === "spell") && this.actor?.isOwner, group: "action" }, diff --git a/module/documents/item.mjs b/module/documents/item.mjs index b2d3985d88..78ff68c206 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2587,8 +2587,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { icon: '', callback: async li => { const spell = await fromUuid(makeUuid(li)); - const data = await Item5e.createScrollFromSpell(spell); - Item5e.createDocuments([data.toObject()]); + Item5e.create(await Item5e.createScrollFromSpell(spell)); }, condition: li => { const item = fromUuidSync(makeUuid(li)); @@ -2611,8 +2610,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { icon: '', callback: async li => { const spell = game.items.get(li.data("documentId")); - const data = await Item5e.createScrollFromSpell(spell); - Item5e.createDocuments([data.toObject()]); + Item5e.create(await Item5e.createScrollFromSpell(spell)); }, condition: li => { const item = game.items.get(li.data("documentId")); From 1d779a91813ec6a795bf947e63d3d6a88ba93eb8 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sat, 23 Mar 2024 14:23:13 -0700 Subject: [PATCH 034/199] [#3321] Allow spell scrolls to be created at increased level The `SpellScrollConfiguration` object now has a `level` parameter that causes the created spell scroll to include a spell at a higher level than normal. This stores the increase level information in `flags.dnd5e.spellLevel`, which contains the original leve, the casting level, and the scaling information. `Item5e#rollDamage` has been modified to now check for the new flag before scaling damage, and use that information if present. The scaling information is stored in the flag because otherwise it would be lost when converting to a consumable type. --- module/documents/item.mjs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 78ff68c206..9d3ebfa04c 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1610,6 +1610,16 @@ export default class Item5e extends SystemDocumentMixin(Item) { async rollDamage({critical, event=null, spellLevel=null, versatile=false, options={}}={}) { if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item."); + // Fetch level from tags if not specified + let originalLevel = this.system.level; + let scaling = this.system.scaling; + const levelingFlag = this.getFlag("dnd5e", "spellLevel"); + if ( !spellLevel && levelingFlag ) { + spellLevel = parseInt(levelingFlag.value); + originalLevel = parseInt(levelingFlag.base); + scaling = levelingFlag.scaling; + } + // Get roll data const dmg = this.system.damage; const properties = Array.from(this.system.properties).filter(p => CONFIG.DND5E.itemProperties[p]?.isPhysical); @@ -1653,8 +1663,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { } // Scale damage from up-casting spells - const scaling = this.system.scaling; - if ( this.type === "spell" ) { + if ( (this.type === "spell") || scaling ) { if ( scaling.mode === "cantrip" ) { let level; if ( this.actor.type === "character" ) level = this.actor.system.details.level; @@ -1664,7 +1673,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { } else if ( spellLevel && (scaling.mode === "level") ) rollConfigs.forEach(c => { if ( scaling.formula || c.parts.length ) { - this._scaleSpellDamage(c.parts, this.system.level, spellLevel, scaling.formula || c.parts[0], rollData); + this._scaleSpellDamage(c.parts, originalLevel, spellLevel, scaling.formula || c.parts[0], rollData); } }); } @@ -2671,6 +2680,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { * * @typedef {object} SpellScrollConfiguration * @property {"full"|"reference"|"none"} [explanation="full"] Length of spell scroll rules text to include. + * @property {number} [level] Level at which the spell should be cast. */ /** @@ -2687,7 +2697,16 @@ export default class Item5e extends SystemDocumentMixin(Item) { }, config); // Get spell data + const flags = {}; const itemData = (spell instanceof Item5e) ? spell.toObject() : spell; + if ( Number.isNumeric(config.level) ) { + flags.dnd5e = { spellLevel: { + value: parseInt(config.level), + base: spell.system.level, + scaling: spell.system.scaling + } }; + itemData.system.level = parseInt(config.level); + } /** * A hook event that fires before the item data for a scroll is created. @@ -2770,6 +2789,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { name: `${game.i18n.localize("DND5E.SpellScroll")}: ${itemData.name}`, img: itemData.img, effects: itemData.effects ?? [], + flags, system: { description: {value: desc.trim()}, source, actionType, activation, duration, target, range, damage, formula, save, level, ability, properties, attack: {bonus: attack.bonus, flat: true} From 3eac3c99b2a4a412c69868c8d86f4d1686d9bdc2 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 28 Mar 2024 10:01:19 -0700 Subject: [PATCH 035/199] [#3320, #3321] Add configuration dialog for spell scroll creation --- lang/en.json | 7 ++++++ module/applications/actor/base-sheet.mjs | 2 +- module/applications/actor/group-sheet.mjs | 2 +- module/data/user/user-system-flags.mjs | 5 ++++ module/documents/item.mjs | 25 +++++++++++++++++-- templates/apps/spell-scroll-dialog.hbs | 29 +++++++++++++++++++++++ 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 templates/apps/spell-scroll-dialog.hbs diff --git a/lang/en.json b/lang/en.json index a9a6a15146..7901da5560 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1212,8 +1212,15 @@ "DND5E.SaveBonus": "Saving Throw Bonus", "DND5E.SaveGlobalBonusHint": "This bonus applies to all saving throws made by this actor.", "DND5E.Scroll": { + "CreateFrom": "Create Scroll from {spell}", "CreateScroll": "Create Scroll", "Details": "Scroll Details", + "Explanation": { + "Label": "Explanation", + "Hint": "Amount of the rules on using spell scrolls to include in the created scroll.", + "Complete": "Complete", + "Reference": "Reference" + }, "RequiresConcentration": "Requires Concentration" }, "DND5E.Senses": "Senses", diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index 1cb3daf481..12e2357fc9 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -985,7 +985,7 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { if ( (itemData.type === "spell") && (this._tabs[0].active === "inventory" || this.actor.type === "vehicle") ) { const scroll = await Item5e.createScrollFromSpell(itemData); - return scroll.toObject(); + return scroll?.toObject?.(); } // Clean up data diff --git a/module/applications/actor/group-sheet.mjs b/module/applications/actor/group-sheet.mjs index 5d91fa94d7..72ae55817d 100644 --- a/module/applications/actor/group-sheet.mjs +++ b/module/applications/actor/group-sheet.mjs @@ -410,7 +410,7 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { // Create a Consumable spell scroll on the Inventory tab if ( itemData.type === "spell" ) { const scroll = await Item5e.createScrollFromSpell(itemData); - return scroll.toObject(); + return scroll?.toObject?.(); } // Stack identical consumables diff --git a/module/data/user/user-system-flags.mjs b/module/data/user/user-system-flags.mjs index 5479e80c58..aa3db0f92c 100644 --- a/module/data/user/user-system-flags.mjs +++ b/module/data/user/user-system-flags.mjs @@ -20,6 +20,8 @@ const { BooleanField, ForeignDocumentField, NumberField, SchemaField, SetField, * A custom model to validate system flags on User Documents. * * @property {Set} awardDestinations Saved targets from previous use of /award command. + * @property {object} creation + * @property {string} creation.scrollExplanation Default explanation mode for spell scrolls. * @property {Record} sheetPrefs The User's sheet preferences. */ export default class UserSystemFlags extends foundry.abstract.DataModel { @@ -29,6 +31,9 @@ export default class UserSystemFlags extends foundry.abstract.DataModel { awardDestinations: new SetField( new ForeignDocumentField(foundry.documents.BaseActor, { idOnly: true }), { required: false } ), + creation: new SchemaField({ + scrollExplanation: new StringField({initial: "reference"}) + }), sheetPrefs: new MappingField(new SchemaField({ width: new NumberField({ integer: true, positive: true }), height: new NumberField({ integer: true, positive: true }), diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 9d3ebfa04c..eb0dcfa13a 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2679,6 +2679,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { * Configuration options for spell scroll creation. * * @typedef {object} SpellScrollConfiguration + * @property {boolean} [dialog=true] Present scroll creation dialog? * @property {"full"|"reference"|"none"} [explanation="full"] Length of spell scroll rules text to include. * @property {number} [level] Level at which the spell should be cast. */ @@ -2691,11 +2692,31 @@ export default class Item5e extends SystemDocumentMixin(Item) { * @returns {Promise} The created scroll consumable item. */ static async createScrollFromSpell(spell, options={}, config={}) { - config = foundry.utils.mergeObject({ - explanation: "full" + explanation: game.user.getFlag("dnd5e", "creation.scrollExplanation") ?? "reference", + level: spell.system.level }, config); + if ( config.dialog !== false ) { + const anchor = spell instanceof Item5e ? spell.toAnchor().outerHTML : `${spell.name}`; + const result = await Dialog.prompt({ + title: game.i18n.format("DND5E.Scroll.CreateFrom", { spell: spell.name }), + label: game.i18n.localize("DND5E.Scroll.CreateScroll"), + content: await renderTemplate("systems/dnd5e/templates/apps/spell-scroll-dialog.hbs", { + ...config, anchor, spellLevels: Object.entries(CONFIG.DND5E.spellLevels).reduce((obj, [k, v]) => { + if ( Number(k) >= spell.system.level ) obj[k] = v; + return obj; + }, {}) + }), + callback: dialog => (new FormDataExtended(dialog.querySelector("form"))).object, + rejectClose: false, + options: { jQuery: false } + }); + if ( result === null ) return; + foundry.utils.mergeObject(config, result); + await game.user.setFlag("dnd5e", "creation.scrollExplanation", config.explanation); + } + // Get spell data const flags = {}; const itemData = (spell instanceof Item5e) ? spell.toObject() : spell; diff --git a/templates/apps/spell-scroll-dialog.hbs b/templates/apps/spell-scroll-dialog.hbs new file mode 100644 index 0000000000..3fa9deac0d --- /dev/null +++ b/templates/apps/spell-scroll-dialog.hbs @@ -0,0 +1,29 @@ +
+
+ +
+ {{{ anchor }}} +
+
+
+ +
+ +
+

{{ localize "DND5E.Scroll.Explanation.Hint" }}

+
+
+ +
+ +
+
+
From 7b605d02709eb7f9b4fcbe6ab14af0773434e1dd Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 28 Mar 2024 10:28:06 -0700 Subject: [PATCH 036/199] [#3321] Remove extra protection against non-integers --- module/documents/item.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 9d3ebfa04c..1cae6cb7a3 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1615,8 +1615,8 @@ export default class Item5e extends SystemDocumentMixin(Item) { let scaling = this.system.scaling; const levelingFlag = this.getFlag("dnd5e", "spellLevel"); if ( !spellLevel && levelingFlag ) { - spellLevel = parseInt(levelingFlag.value); - originalLevel = parseInt(levelingFlag.base); + spellLevel = levelingFlag.value; + originalLevel = levelingFlag.base; scaling = levelingFlag.scaling; } @@ -2701,11 +2701,11 @@ export default class Item5e extends SystemDocumentMixin(Item) { const itemData = (spell instanceof Item5e) ? spell.toObject() : spell; if ( Number.isNumeric(config.level) ) { flags.dnd5e = { spellLevel: { - value: parseInt(config.level), + value: config.level, base: spell.system.level, scaling: spell.system.scaling } }; - itemData.system.level = parseInt(config.level); + itemData.system.level = config.level; } /** From 42fbbb1c87bd72428d92bee88a2d82229bd4fd7c Mon Sep 17 00:00:00 2001 From: etiquettestartshere <148253744+etiquettestartshere@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:03:36 -0400 Subject: [PATCH 037/199] [#1280] Add Ritual Spell Preparation Mode (#3173) --- lang/en.json | 1 + module/applications/actor/base-sheet.mjs | 2 +- module/config.mjs | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lang/en.json b/lang/en.json index a9a6a15146..1cdb001d3b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1378,6 +1378,7 @@ "DND5E.SpellNone": "None", "DND5E.SpellPrepAtWill": "At-Will", "DND5E.SpellPrepInnate": "Innate Spellcasting", +"DND5E.SpellPrepRitual": "Ritual Only", "DND5E.SpellPrepPrepared": "Prepared", "DND5E.SpellPrepAlways": "Always Prepared", "DND5E.SpellPreparation": "Spell Preparation", diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index 1cb3daf481..9e4a3b3f1a 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -390,7 +390,7 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { if ( Number.isNumeric(order) ) acc[k] = Number(order); return acc; }, {}); - const useLabels = {"-20": "-", "-10": "-", 0: "∞"}; + const useLabels = {"-30": "-", "-20": "-", "-10": "-", 0: "∞"}; // Format a spellbook entry for a certain indexed level const registerSection = (sl, i, label, {prepMode="prepared", value, max, override, config}={}) => { diff --git a/module/config.mjs b/module/config.mjs index b95a92de9d..4f4040588d 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -2004,10 +2004,14 @@ DND5E.spellPreparationModes = { }, atwill: { label: "DND5E.SpellPrepAtWill", - order: -20 + order: -30 }, innate: { label: "DND5E.SpellPrepInnate", + order: -20 + }, + ritual: { + label: "DND5E.SpellPrepRitual", order: -10 } }; From e60f25c4ac4beebd47d5f05cd39a80fda207a512 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 26 Mar 2024 11:55:19 -0700 Subject: [PATCH 038/199] [#3358] Properly handle temporary hit points in `applyDamage` Any damage rolls with the `temphp` type are now not included in the total damage and instead split off. Then, after any damage is applied to current & temporary HP, that temp value is used to set the new temporary HP floor. The damage application interface has been adjusted to display temp HP separately from other changes. The display of normal damage has also changed to add subtle colors (red for damage, green for healing, blue for temp HP). The signs have also been adjusted, so healing will always have a "+", damage will always have a "-", and temp HP will be unsigned. These changes should make it more clear what each of the numbers represents, but just in case this also adds tooltips to each number. --- less/v2/chat.less | 6 +++- less/variables.less | 3 ++ .../components/damage-application.mjs | 29 ++++++++++++++----- module/documents/actor/actor.mjs | 8 ++++- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/less/v2/chat.less b/less/v2/chat.less index 025fa319ac..89b5a0dd8c 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -546,10 +546,14 @@ } } - .calculated-damage { + .calculated { padding-inline-end: 4px; font-size: var(--font-size-14); font-weight: bold; + color: var(--dnd5e-color-application-damage); + + &.healing { color: var(--dnd5e-color-application-healing); } + &.temp { color: var(--dnd5e-color-application-temp); } } .damage-multipliers { diff --git a/less/variables.less b/less/variables.less index 127825d062..5a3382fb8d 100644 --- a/less/variables.less +++ b/less/variables.less @@ -58,6 +58,9 @@ --dnd5e-color-failure: #6e0000; --dnd5e-color-failure-background: #ffdddd; --dnd5e-color-failure-critical: red; + --dnd5e-color-application-damage: #9c5b47; + --dnd5e-color-application-healing: #3c7f58; + --dnd5e-color-application-temp: #007F7F; --dnd5e-background-10: rgb(0 0 0 / 10%); --dnd5e-background-5: rgb(0 0 0 / 5%); --dnd5e-background-card: var(--dnd5e-color-card); diff --git a/module/applications/components/damage-application.mjs b/module/applications/components/damage-application.mjs index 6f9ce3fcba..b22302972c 100644 --- a/module/applications/components/damage-application.mjs +++ b/module/applications/components/damage-application.mjs @@ -1,3 +1,5 @@ +import { formatNumber } from "../../utils.mjs"; + /** * List of multiplier options as tuples containing their numeric value and rendered text. * @type {[number, string][]} @@ -198,7 +200,7 @@ export default class DamageApplicationElement extends HTMLElement { // Calculate damage to apply const targetOptions = this.getTargetOptions(uuid); - const { total, active } = this.calculateDamage(token, targetOptions); + const { temp, total, active } = this.calculateDamage(token, targetOptions); const types = []; for ( const [change, values] of Object.entries(active) ) { @@ -231,9 +233,12 @@ export default class DamageApplicationElement extends HTMLElement { ${token.name} ${changeSources ? `${changeSources}` : ""} -
+
${total}
+
+ ${temp} +
`; @@ -260,20 +265,23 @@ export default class DamageApplicationElement extends HTMLElement { * Calculate the total damage that will be applied to an actor. * @param {Actor5e} actor * @param {DamageApplicationOptions} options - * @returns {{total: number, active: Record>}} + * @returns {{temp: number, total: number, active: Record>}} */ calculateDamage(actor, options) { const damages = actor.calculateDamage(this.damages, options); + let temp = 0; let total = 0; let active = { modification: new Set(), resistance: new Set(), vulnerability: new Set(), immunity: new Set() }; for ( const damage of damages ) { - total += damage.value; + if ( damage.type === "temphp" ) temp += damage.value; + else total += damage.value; if ( damage.active.modification ) active.modification.add(damage.type); if ( damage.active.resistance ) active.resistance.add(damage.type); if ( damage.active.vulnerability ) active.vulnerability.add(damage.type); if ( damage.active.immunity ) active.immunity.add(damage.type); } + temp = Math.floor(Math.max(0, temp)); total = total > 0 ? Math.floor(total) : Math.ceil(total); // Add values from options to prevent active changes from being lost when re-rendering target list @@ -288,7 +296,7 @@ export default class DamageApplicationElement extends HTMLElement { active.immunity = active.immunity.union(options.downgrade); } - return { total, active }; + return { temp, total, active }; } /* -------------------------------------------- */ @@ -323,8 +331,15 @@ export default class DamageApplicationElement extends HTMLElement { * @param {DamageApplicationOptions} options */ refreshListEntry(token, entry, options) { - const { total } = this.calculateDamage(token, options); - entry.querySelector(".calculated-damage").innerText = total; + const { temp, total } = this.calculateDamage(token, options); + const calculatedDamage = entry.querySelector(".calculated.damage"); + calculatedDamage.innerText = formatNumber(-total, { signDisplay: "exceptZero" }); + calculatedDamage.classList.toggle("healing", total < 0); + calculatedDamage.dataset.tooltip = `DND5E.${total < 0 ? "Healing" : "Damage"}`; + calculatedDamage.hidden = !total && !!temp; + const calculatedTemp = entry.querySelector(".calculated.temp"); + calculatedTemp.innerText = temp; + calculatedTemp.hidden = !temp; const pressedMultiplier = entry.querySelector('.multiplier-button[aria-pressed="true"]'); if ( Number(pressedMultiplier?.dataset.multiplier) !== options.multiplier ) { diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index 3609cdd31f..63f75fd8fb 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -921,7 +921,11 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { if ( !damages ) return this; // Round damage towards zero - let amount = damages.reduce((acc, d) => acc + d.value, 0); + let { amount, temp } = damages.reduce((acc, d) => { + if ( d.type === "temphp" ) acc.temp += d.value; + else acc.amount += d.value; + return acc; + }, { amount: 0, temp: 0 }); amount = amount > 0 ? Math.floor(amount) : Math.ceil(amount); const deltaTemp = amount > 0 ? Math.min(hp.temp, amount) : 0; @@ -931,6 +935,8 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { "system.attributes.hp.value": hp.value - deltaHP }; + if ( temp > updates["system.attributes.hp.temp"] ) updates["system.attributes.hp.temp"] = temp; + /** * A hook event that fires before damage is applied to an actor. * @param {Actor5e} actor Actor the damage will be applied to. From 5e8d563c72faf964da6dcfe229df1833c712e0a7 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 28 Mar 2024 14:06:30 -0700 Subject: [PATCH 039/199] [#1549] Have class spellcasting levels override spellLevel --- module/applications/actor/npc-sheet.mjs | 3 +++ module/documents/actor/actor.mjs | 5 ++++- templates/actors/parts/actor-spellbook.hbs | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/module/applications/actor/npc-sheet.mjs b/module/applications/actor/npc-sheet.mjs index 177ca0993a..1b91249d82 100644 --- a/module/applications/actor/npc-sheet.mjs +++ b/module/applications/actor/npc-sheet.mjs @@ -26,6 +26,9 @@ export default class ActorSheet5eNPC extends ActorSheet5e { const cr = parseFloat(context.system.details.cr ?? 0); const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + // Class Spellcasting + context.classSpellcasting = Object.values(this.actor.classes).some(c => c.spellcasting?.levels); + return foundry.utils.mergeObject(context, { labels: { cr: cr >= 1 ? String(cr) : crLabels[cr] ?? 1, diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index ad7eebaf53..cb56b47785 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -595,7 +595,10 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { progression, cls, { actor: this, count: types[cls.spellcasting.type] } ); - if ( this.type === "npc" ) progression.slot += this.system.details.spellLevel ?? 0; + if ( this.type === "npc" ) { + if ( progression.slot || progression.pact ) this.system.details.spellLevel = progression.slot || progression.pact; + else progression.slot = this.system.details.spellLevel ?? 0; + } for ( const type of Object.keys(CONFIG.DND5E.spellcastingTypes) ) { this.constructor.prepareSpellcastingSlots(this.system.spells, type, progression, { actor: this }); diff --git a/templates/actors/parts/actor-spellbook.hbs b/templates/actors/parts/actor-spellbook.hbs index 943b117da5..a7db5e7e53 100644 --- a/templates/actors/parts/actor-spellbook.hbs +++ b/templates/actors/parts/actor-spellbook.hbs @@ -6,7 +6,7 @@ {{else}} {{numberInput system.details.spellLevel name="system.details.spellLevel" class="spellcasting-level" - placeholder="0" min=0 step=1}} + placeholder="0" min=0 step=1 disabled=classSpellcasting}} {{/unless}} + data-tooltip="DND5E.ConsumeAmount" placeholder="{{ localize 'DND5E.QuantityAbbr' }}"> {{#if abilityConsumptionHint}} + placeholder="{{ localize abilityConsumptionHint }}"> {{else}} - {{selectOptions config.featureTypes selected=system.type.value blank="" labelAttr="label"}} + {{ selectOptions config.featureTypes selected=system.type.value blank="" labelAttr="label" }}
{{#if itemSubtypes}}
{{/if}} @@ -70,11 +70,19 @@ {{#each properties}} {{/each}} +

{{ localize "DND5E.Prerequisites.Header" }}

+ +
+ + {{ numberInput system.prerequisites.level name="system.prerequisites.level" step=1 }} +

{{ localize "DND5E.Prerequisites.FIELDS.prerequisites.level.hint" }}

+
+

{{ localize "DND5E.FeatureUsage" }}

{{!-- Item Activation Template --}} From 4423238420dfe154ff8d20676531137ff685f636 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 1 Apr 2024 19:06:12 -0700 Subject: [PATCH 043/199] [#3393] Add profile ID & spell level to summoning flags --- module/data/item/fields/summons-field.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index a1d0ad2fe0..5259705070 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -263,6 +263,13 @@ export class SummonsData extends foundry.abstract.DataModel { const rollData = this.item.getRollData(); const prof = rollData.attributes?.prof ?? 0; + // Add flags + updates["flags.dnd5e.summon"] = { + origin: this.item.uuid, + profile: profile._id + }; + if ( this.item.type === "spell" ) updates["flags.dnd5e.summon"].level = this.item.system.level; + // Match proficiency if ( this.match.proficiency ) { const proficiencyEffect = new ActiveEffect({ @@ -417,7 +424,6 @@ export class SummonsData extends foundry.abstract.DataModel { ui.notifications.warn("DND5E.Summoning.Warning.Wildcard", { localize: true }); } - actorUpdates["flags.dnd5e.summon.origin"] = this.item.uuid; const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates)); tokenDocument.delta.updateSource(actorUpdates); From baa9d218c4dfda2077bb387e396886cb0d47d964 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 2 Apr 2024 10:56:26 -0500 Subject: [PATCH 044/199] [#1833] Adjust wording on prerequisite hint Co-authored-by: Kim Mantas --- lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en.json b/lang/en.json index 8bc85f0ed5..d4446a12cd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1144,7 +1144,7 @@ "prerequisites": { "level": { "label": "Required Level", - "hint": "Character or class level required to select this feature as part of a choice advancement." + "hint": "Character or class level required to select this feature when levelling up." } } } From 04428345a9871185e813bf1be270eb2da0080bcd Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 2 Apr 2024 11:01:49 -0500 Subject: [PATCH 045/199] [#1833] Add level prerequisites to eldritch invocations --- .../warlock/eldritch-invocations/ascendant-step.json | 9 ++++++--- .../eldritch-invocations/bewitching-whispers.json | 9 ++++++--- .../warlock/eldritch-invocations/chains-of-carceri.json | 9 ++++++--- .../warlock/eldritch-invocations/dreadful-word.json | 9 ++++++--- .../warlock/eldritch-invocations/lifedrinker.json | 9 ++++++--- .../eldritch-invocations/master-of-myriad-forms.json | 9 ++++++--- .../warlock/eldritch-invocations/minions-of-chaos.json | 9 ++++++--- .../warlock/eldritch-invocations/mire-the-mind.json | 9 ++++++--- .../warlock/eldritch-invocations/one-with-shadows.json | 9 ++++++--- .../warlock/eldritch-invocations/otherworldly-leap.json | 9 ++++++--- .../warlock/eldritch-invocations/sculptor-of-flesh.json | 9 ++++++--- .../warlock/eldritch-invocations/sign-of-ill-omen.json | 9 ++++++--- .../warlock/eldritch-invocations/thirsting-blade.json | 9 ++++++--- .../eldritch-invocations/visions-of-distant-realms.json | 9 ++++++--- .../eldritch-invocations/whispers-of-the-grave.json | 9 ++++++--- .../warlock/eldritch-invocations/witch-sight.json | 9 ++++++--- 16 files changed, 96 insertions(+), 48 deletions(-) diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/ascendant-step.json b/packs/_source/classfeatures/warlock/eldritch-invocations/ascendant-step.json index 20284822b2..b99542ed1b 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/ascendant-step.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/ascendant-step.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 9 + } }, "flags": {}, "img": "icons/magic/light/explosion-beam-impact-silhouette.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234375, - "modifiedTime": 1704824716663, + "modifiedTime": 1712073563047, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!QEuH5TeBN4PPYT2g" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/bewitching-whispers.json b/packs/_source/classfeatures/warlock/eldritch-invocations/bewitching-whispers.json index 6adb1675e1..a64b048e12 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/bewitching-whispers.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/bewitching-whispers.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 7 + } }, "flags": {}, "img": "icons/magic/air/wind-vortex-swirl-purple.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234362, - "modifiedTime": 1704824716140, + "modifiedTime": 1712073571495, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!KygHql3cTj4IRrvZ" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/chains-of-carceri.json b/packs/_source/classfeatures/warlock/eldritch-invocations/chains-of-carceri.json index 9b221380b6..a29556bd31 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/chains-of-carceri.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/chains-of-carceri.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 15 + } }, "flags": {}, "img": "icons/skills/melee/strike-flail-spiked-pink.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234371, - "modifiedTime": 1704824716555, + "modifiedTime": 1712073576288, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!Phy02H5x0TKHd7T3" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/dreadful-word.json b/packs/_source/classfeatures/warlock/eldritch-invocations/dreadful-word.json index 190cc875fd..164457896d 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/dreadful-word.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/dreadful-word.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 7 + } }, "flags": {}, "img": "icons/magic/air/wind-vortex-swirl-blue.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234374, - "modifiedTime": 1704824716643, + "modifiedTime": 1712073581480, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!QBk1RsuTIEF4GEBC" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/lifedrinker.json b/packs/_source/classfeatures/warlock/eldritch-invocations/lifedrinker.json index 823c88b895..980f88bd41 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/lifedrinker.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/lifedrinker.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 12 + } }, "flags": {}, "img": "icons/skills/melee/strike-sword-blood-red.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234446, - "modifiedTime": 1704824720039, + "modifiedTime": 1712073589086, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!zUIAzBnyt0NDvVXb" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/master-of-myriad-forms.json b/packs/_source/classfeatures/warlock/eldritch-invocations/master-of-myriad-forms.json index 32211cae4a..edf6a8ff83 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/master-of-myriad-forms.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/master-of-myriad-forms.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 15 + } }, "flags": {}, "img": "icons/creatures/abilities/cougar-pounce-stalk-black.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234419, - "modifiedTime": 1704824718627, + "modifiedTime": 1712073592737, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!lxfdjLer3uKjyZqU" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/minions-of-chaos.json b/packs/_source/classfeatures/warlock/eldritch-invocations/minions-of-chaos.json index bc5708076d..0673abdfe7 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/minions-of-chaos.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/minions-of-chaos.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 9 + } }, "flags": {}, "img": "icons/creatures/magical/humanoid-giant-forest-blue.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234376, - "modifiedTime": 1704824716713, + "modifiedTime": 1712073596508, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!QnRKYXb2bXpnNd2k" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/mire-the-mind.json b/packs/_source/classfeatures/warlock/eldritch-invocations/mire-the-mind.json index 99643a9683..9582640a8a 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/mire-the-mind.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/mire-the-mind.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 5 + } }, "flags": {}, "img": "icons/magic/time/clock-stopwatch-white-blue.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234350, - "modifiedTime": 1704824715582, + "modifiedTime": 1712073600359, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!DjXi0IkCTbJx1gsp" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/one-with-shadows.json b/packs/_source/classfeatures/warlock/eldritch-invocations/one-with-shadows.json index 3f294ca9e6..99dc6abedc 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/one-with-shadows.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/one-with-shadows.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 5 + } }, "flags": {}, "img": "icons/magic/unholy/silhouette-robe-evil-power.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234361, - "modifiedTime": 1704824716087, + "modifiedTime": 1712073604595, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!KfnUyjUWAk0bAAus" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/otherworldly-leap.json b/packs/_source/classfeatures/warlock/eldritch-invocations/otherworldly-leap.json index e23871a8e7..0271844347 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/otherworldly-leap.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/otherworldly-leap.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 9 + } }, "flags": {}, "img": "icons/magic/light/beam-strike-orange-gold.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234342, - "modifiedTime": 1704824715249, + "modifiedTime": 1712073609547, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!8zciiglzEOZo7DDN" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/sculptor-of-flesh.json b/packs/_source/classfeatures/warlock/eldritch-invocations/sculptor-of-flesh.json index d35d5eaaaa..9f41f55b44 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/sculptor-of-flesh.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/sculptor-of-flesh.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 7 + } }, "flags": {}, "img": "icons/creatures/mammals/wolf-howl-moon-forest-blue.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234385, - "modifiedTime": 1704824717121, + "modifiedTime": 1712073615140, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!Xa2MLUJGCReJ28B7" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/sign-of-ill-omen.json b/packs/_source/classfeatures/warlock/eldritch-invocations/sign-of-ill-omen.json index 93d9ebb167..954195ac00 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/sign-of-ill-omen.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/sign-of-ill-omen.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 5 + } }, "flags": {}, "img": "icons/magic/symbols/rune-sigil-black-pink.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234398, - "modifiedTime": 1704824717753, + "modifiedTime": 1712073617921, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!dTSV0xZMpY5CxkRk" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/thirsting-blade.json b/packs/_source/classfeatures/warlock/eldritch-invocations/thirsting-blade.json index fc5ea94a8e..7560ab077b 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/thirsting-blade.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/thirsting-blade.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 5 + } }, "flags": {}, "img": "icons/skills/melee/blade-tip-chipped-blood-red.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234426, - "modifiedTime": 1704824718932, + "modifiedTime": 1712073622874, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!pJADgAxxefgcATWr" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/visions-of-distant-realms.json b/packs/_source/classfeatures/warlock/eldritch-invocations/visions-of-distant-realms.json index 2c1ce8a6bc..c4bee934c9 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/visions-of-distant-realms.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/visions-of-distant-realms.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 15 + } }, "flags": {}, "img": "icons/magic/control/hypnosis-mesmerism-eye.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234446, - "modifiedTime": 1704824720069, + "modifiedTime": 1712073626426, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!zYIdNAjqRyhS6qWs" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/whispers-of-the-grave.json b/packs/_source/classfeatures/warlock/eldritch-invocations/whispers-of-the-grave.json index fa7bb41042..16a6a8e2b5 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/whispers-of-the-grave.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/whispers-of-the-grave.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 9 + } }, "flags": {}, "img": "icons/magic/death/undead-ghost-scream-teal.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234405, - "modifiedTime": 1704824718001, + "modifiedTime": 1712073631502, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!gFjxo01hAN4c9hDG" diff --git a/packs/_source/classfeatures/warlock/eldritch-invocations/witch-sight.json b/packs/_source/classfeatures/warlock/eldritch-invocations/witch-sight.json index 235bf2334b..575a2740ba 100644 --- a/packs/_source/classfeatures/warlock/eldritch-invocations/witch-sight.json +++ b/packs/_source/classfeatures/warlock/eldritch-invocations/witch-sight.json @@ -79,7 +79,10 @@ "charged": false }, "crewed": false, - "properties": [] + "properties": [], + "prerequisites": { + "level": 15 + } }, "flags": {}, "img": "icons/magic/perception/eye-tendrils-web-purple.webp", @@ -88,10 +91,10 @@ "sort": 0, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234432, - "modifiedTime": 1704824719294, + "modifiedTime": 1712073635047, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!snsjxGfmzWfZR5Nh" From 561214ee62fd12cbbe18abe8528aaa6efb95cf9d Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 2 Apr 2024 11:18:50 -0500 Subject: [PATCH 046/199] [#3242] Fix issue with creature types on summoning not saving Added an event listener so that the change event for the creature types `multi-select` triggers form submission like the other fields. Also had to manually set it to an empty array if the value isn't in the submit data to work around an issue with the custom element. --- module/applications/item/summoning-config.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/module/applications/item/summoning-config.mjs b/module/applications/item/summoning-config.mjs index 33ee2d686a..26459b7088 100644 --- a/module/applications/item/summoning-config.mjs +++ b/module/applications/item/summoning-config.mjs @@ -74,6 +74,10 @@ export default class SummoningConfig extends DocumentSheet { profileId: event.target.closest("[data-profile-id]")?.dataset.profileId } })); } + + for ( const element of html.querySelectorAll("multi-select") ) { + element.addEventListener("change", this._onChangeInput.bind(this)); + } } /* -------------------------------------------- */ @@ -81,6 +85,7 @@ export default class SummoningConfig extends DocumentSheet { /** @inheritDoc */ _getSubmitData(...args) { const data = foundry.utils.expandObject(super._getSubmitData(...args)); + data.creatureTypes ??= []; data.profiles = Object.values(data.profiles ?? {}); switch ( data.action ) { From 96d1909e5e754c9dd0bcb6fac651c75eb64e15c3 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 25 Mar 2024 15:55:52 -0700 Subject: [PATCH 047/199] [#3345] Add Place Members buttons to group to place into scene Includes modifications to the `TokenPlacement` class to fully support multiple token placements. The functionality of that system has been changed slightly so right clicking will now skip the current placement, but not cancel the whole process. This should allow skipping certain group members when placing the group. There is a current limitation that prevents the already placed tokens from displaying in the scene. --- lang/en.json | 1 + module/applications/actor/group-sheet.mjs | 17 +++--- module/canvas/token-placement.mjs | 63 ++++++++++++++--------- module/data/actor/group.mjs | 29 +++++++++++ module/data/item/fields/summons-field.mjs | 1 + templates/actors/tabs/group-members.hbs | 5 ++ 6 files changed, 86 insertions(+), 30 deletions(-) diff --git a/lang/en.json b/lang/en.json index 886ce11d2d..4cbebb2f32 100644 --- a/lang/en.json +++ b/lang/en.json @@ -845,6 +845,7 @@ "one": "Member", "other": "Members" }, + "PlaceMembers": "Place Members", "Primary": { "Remove": "Remove as Primary Party", "Set": "Set as Primary Party" diff --git a/module/applications/actor/group-sheet.mjs b/module/applications/actor/group-sheet.mjs index 72ae55817d..8fe484cc2d 100644 --- a/module/applications/actor/group-sheet.mjs +++ b/module/applications/actor/group-sheet.mjs @@ -295,22 +295,25 @@ export default class GroupActorSheet extends ActorSheetMixin(ActorSheet) { const award = new Award(this.object, { savedDestinations: this.actor.getFlag("dnd5e", "awardDestinations") }); award.render(true); break; - case "removeMember": - const removeMemberId = button.closest("li.group-member").dataset.actorId; - this.object.system.removeMember(removeMemberId); - break; case "longRest": - this.object.longRest({ advanceTime: true }); + this.actor.longRest({ advanceTime: true }); break; case "movementConfig": const movementConfig = new ActorMovementConfig(this.object); movementConfig.render(true); break; + case "placeMembers": + this.actor.system.placeMembers(); + break; + case "removeMember": + const removeMemberId = button.closest("li.group-member").dataset.actorId; + this.actor.system.removeMember(removeMemberId); + break; case "rollQuantities": - this.object.system.rollQuantities(); + this.actor.system.rollQuantities(); break; case "shortRest": - this.object.shortRest({ advanceTime: true }); + this.actor.shortRest({ advanceTime: true }); break; } } diff --git a/module/canvas/token-placement.mjs b/module/canvas/token-placement.mjs index 72fdc93f46..d766d4ffac 100644 --- a/module/canvas/token-placement.mjs +++ b/module/canvas/token-placement.mjs @@ -9,6 +9,7 @@ * Data for token placement on the scene. * * @typedef {object} PlacementData + * @property {PrototypeToken} prototypeToken * @property {number} x * @property {number} y * @property {number} rotation @@ -35,6 +36,14 @@ export default class TokenPlacement { /* -------------------------------------------- */ + /** + * Index of the token configuration currently being placed in the scene. + * @param {number} + */ + #currentPlacement = -1; + + /* -------------------------------------------- */ + /** * Track the bound event handlers so they can be properly canceled later. * @type {object} @@ -94,7 +103,15 @@ export default class TokenPlacement { async place() { this.#createPreviews(); try { - return await this.#activatePreviewListeners(); + const placements = []; + while ( this.#currentPlacement < this.config.tokens.length - 1 ) { + this.#currentPlacement += 1; + const obj = canvas.tokens.preview.addChild(this.#previews[this.#currentPlacement].object); + obj.draw(); + const placement = await this.#requestPlacement(); + if ( placement ) placements.push(placement); + } + return placements; } finally { this.#destroyPreviews(); } @@ -113,9 +130,8 @@ export default class TokenPlacement { if ( tokenData.randomImg ) tokenData.texture.src = prototypeToken.actor.img; const cls = getDocumentClass("Token"); const doc = new cls(tokenData, { parent: canvas.scene }); - this.#placements.push({ x: 0, y: 0, rotation: tokenData.rotation ?? 0 }); + this.#placements.push({ prototypeToken, x: 0, y: 0, rotation: tokenData.rotation ?? 0 }); this.#previews.push(doc); - doc.object.draw(); } } @@ -154,23 +170,24 @@ export default class TokenPlacement { /** * Activate listeners for the placement preview. - * @returns {Promise} A promise that resolves with the final placement if created. + * @returns {Promise} A promise that resolves with the final placement if created. */ - #activatePreviewListeners() { + #requestPlacement() { return new Promise((resolve, reject) => { this.#events = { - cancel: this.#onCancelPlacement.bind(this), confirm: this.#onConfirmPlacement.bind(this), move: this.#onMovePlacement.bind(this), resolve, reject, - rotate: this.#onRotatePlacement.bind(this) + rotate: this.#onRotatePlacement.bind(this), + skip: this.#onSkipPlacement.bind(this) + }; // Activate listeners canvas.stage.on("mousemove", this.#events.move); canvas.stage.on("mousedown", this.#events.confirm); - canvas.app.view.oncontextmenu = this.#events.cancel; + canvas.app.view.oncontextmenu = this.#events.skip; canvas.app.view.onwheel = this.#events.rotate; }); } @@ -182,7 +199,6 @@ export default class TokenPlacement { * @param {Event} event Triggering event that ended the placement. */ async #finishPlacement(event) { - canvas.tokens._onDragLeftCancel(event); canvas.stage.off("mousemove", this.#events.move); canvas.stage.off("mousedown", this.#events.confirm); canvas.app.view.oncontextmenu = null; @@ -199,17 +215,18 @@ export default class TokenPlacement { event.stopPropagation(); if ( this.#throttle ) return; this.#throttle = true; - const preview = this.#previews[0]; + const idx = this.#currentPlacement; + const preview = this.#previews[idx]; const adjustment = this.#getSnapAdjustment(preview); const point = event.data.getLocalPosition(canvas.tokens); const center = canvas.grid.getCenter(point.x - adjustment.x, point.y - adjustment.y); preview.updateSource({ - x: center[0] + adjustment.x - Math.round((this.config.tokens[0].width * canvas.dimensions.size) / 2), - y: center[1] + adjustment.y - Math.round((this.config.tokens[0].height * canvas.dimensions.size) / 2) + x: center[0] + adjustment.x - Math.round((this.config.tokens[idx].width * canvas.dimensions.size) / 2), + y: center[1] + adjustment.y - Math.round((this.config.tokens[idx].height * canvas.dimensions.size) / 2) }); - this.#placements[0].x = preview.x; - this.#placements[0].y = preview.y; - preview.object.refresh(); + this.#placements[idx].x = preview.x; + this.#placements[idx].y = preview.y; + canvas.tokens.preview.children[this.#currentPlacement]?.refresh(); requestAnimationFrame(() => this.#throttle = false); } @@ -224,10 +241,10 @@ export default class TokenPlacement { event.stopPropagation(); const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; const snap = event.shiftKey ? delta : 5; - const preview = this.#previews[0]; - this.#placements[0].rotation += snap * Math.sign(event.deltaY); - preview.updateSource({ rotation: this.#placements[0].rotation }); - preview.object.refresh(); + const preview = this.#previews[this.#currentPlacement]; + this.#placements[this.#currentPlacement].rotation += snap * Math.sign(event.deltaY); + preview.updateSource({ rotation: this.#placements[this.#currentPlacement].rotation }); + canvas.tokens.preview.children[this.#currentPlacement]?.refresh(); } /* -------------------------------------------- */ @@ -238,17 +255,17 @@ export default class TokenPlacement { */ async #onConfirmPlacement(event) { await this.#finishPlacement(event); - this.#events.resolve(this.#placements); + this.#events.resolve(this.#placements[this.#currentPlacement]); } /* -------------------------------------------- */ /** - * Cancel placement when the right mouse button is clicked. + * Skip placement when the right mouse button is clicked. * @param {Event} event Triggering mouse event. */ - async #onCancelPlacement(event) { + async #onSkipPlacement(event) { await this.#finishPlacement(event); - this.#events.reject(); + this.#events.resolve(false); } } diff --git a/module/data/actor/group.mjs b/module/data/actor/group.mjs index 73af5790cd..da59522203 100644 --- a/module/data/actor/group.mjs +++ b/module/data/actor/group.mjs @@ -1,3 +1,4 @@ +import TokenPlacement from "../../canvas/token-placement.mjs"; import { ActorDataModel } from "../abstract.mjs"; import { FormulaField } from "../fields.mjs"; import CurrencyTemplate from "../shared/currency.mjs"; @@ -191,6 +192,34 @@ export default class GroupActor extends ActorDataModel.mixin(CurrencyTemplate) { /* -------------------------------------------- */ + /** + * Place all members in the group on the current scene. + */ + async placeMembers() { + if ( !game.user.isGM || !canvas.scene ) return; + const minimized = !this.parent.sheet._minimized; + await this.parent.sheet.minimize(); + const tokensData = []; + + try { + const placements = await TokenPlacement.place({ + tokens: Object.values(this.members).map(m => m.actor.prototypeToken) + }); + for ( const placement of placements ) { + const actor = placement.prototypeToken.actor; + delete placement.prototypeToken; + const tokenDocument = await actor.getTokenDocument(placement); + tokensData.push(tokenDocument.toObject()); + } + } finally { + if ( minimized ) this.parent.sheet.maximize(); + } + + await canvas.scene.createEmbeddedDocuments("Token", tokensData); + } + + /* -------------------------------------------- */ + /** * Remove a member from the group. * @param {Actor5e|string} actor An Actor or ID to remove from this group diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 5259705070..68857b78fd 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -424,6 +424,7 @@ export class SummonsData extends foundry.abstract.DataModel { ui.notifications.warn("DND5E.Summoning.Warning.Wildcard", { localize: true }); } + delete placement.prototypeToken; const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates)); tokenDocument.delta.updateSource(actorUpdates); diff --git a/templates/actors/tabs/group-members.hbs b/templates/actors/tabs/group-members.hbs index ec075f1e18..bc46c79959 100644 --- a/templates/actors/tabs/group-members.hbs +++ b/templates/actors/tabs/group-members.hbs @@ -1,5 +1,10 @@ {{#if isGM}} +
  • + +
  • + +
      + {{#each enchantments}} +
    • +
      + {{{ dnd5e-linkForUuid uuid }}} +
      + +
      +
      +
    • + {{else}} +
    • {{ localize "DND5E.Enchantment.Category.Empty" }}
    • + {{/each}} +
    + +

    {{ localize "DND5E.Enchantment.FIELDS.enchantment.restrictions.label" }}

    +
    + + +

    {{ localize "DND5E.Enchantment.FIELDS.enchantment.restrictions.allowMagical.hint" }}

    +
    +
    + + +

    {{ localize "DND5E.Enchantment.FIELDS.enchantment.restrictions.type.hint" }}

    +
    + diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs index a29f8e3114..c9d13f9181 100644 --- a/templates/apps/summoning-config.hbs +++ b/templates/apps/summoning-config.hbs @@ -4,9 +4,9 @@ -
      +
        {{#each profiles}}
      • @@ -20,11 +20,13 @@ - +
        + +
        diff --git a/templates/items/feat.hbs b/templates/items/feat.hbs index 3f7048cab4..7a811a717c 100644 --- a/templates/items/feat.hbs +++ b/templates/items/feat.hbs @@ -83,6 +83,26 @@

        {{ localize "DND5E.Prerequisites.FIELDS.prerequisites.level.hint" }}

        + {{#if system.isEnchantmentSource}} +

        {{ localize "DND5E.Enchantment.Label" }}

        + +
        + + +

        {{ localize "DND5E.Enchantment.FIELDS.enchantment.items.max.hintSource" }}

        +
        + +
        + + +

        {{ localize "DND5E.Enchantment.FIELDS.enchantment.items.period.hint" }}

        +
        + + {{/if}} +

        {{ localize "DND5E.FeatureUsage" }}

        {{!-- Item Activation Template --}} diff --git a/templates/items/parts/item-action.hbs b/templates/items/parts/item-action.hbs index 7c03ca2f0e..0dca9317de 100644 --- a/templates/items/parts/item-action.hbs +++ b/templates/items/parts/item-action.hbs @@ -120,6 +120,19 @@ +{{!-- Enchantment --}} +{{#if system.isEnchantment}} +
        + + +
        +{{/if}} + {{!-- Summoning --}} {{#if (eq system.actionType "summ")}}
        From 99f5e27216b647080e4753449e837cb8189499c4 Mon Sep 17 00:00:00 2001 From: Neil White Date: Mon, 22 Apr 2024 22:32:03 -0700 Subject: [PATCH 083/199] Allow for null/undefined prerequisites value in items when performing advancement --- module/applications/advancement/item-choice-flow.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 47b3118b82..f7c2f010d1 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -69,7 +69,7 @@ export default class ItemChoiceFlow extends ItemGrantFlow { if ( i ) { i.checked = this.selected.has(i.uuid); i.disabled = !i.checked && choices.full; - const validLevel = (i.system.prerequisites.level ?? -Infinity) <= this.level; + const validLevel = (i.system.prerequisites?.level ?? -Infinity) <= this.level; if ( !previouslySelected.has(i.uuid) && validLevel ) items.push(i); } return items; From 4d7b3ec648dfc77032b791869be170312b6cac49 Mon Sep 17 00:00:00 2001 From: Kim Mantas Date: Tue, 23 Apr 2024 13:16:21 +0100 Subject: [PATCH 084/199] Fix typo --- lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en.json b/lang/en.json index 70b4368b8a..08af66a766 100644 --- a/lang/en.json +++ b/lang/en.json @@ -205,7 +205,7 @@ "DND5E.AdvancementItemChoiceSpellLevelSpecificWarning": "Only {level} spells can be chosen for this advancement.", "DND5E.AdvancementItemChoiceSpellLevelHint": "Only allow choices from spells of this level.", "DND5E.AdvancementItemChoiceType": "Item Type", -"DND5E.AdvancementItemChoiceTypeHint": "Restrict what Item types can be choosen.", +"DND5E.AdvancementItemChoiceTypeHint": "Restrict what Item types can be chosen.", "DND5E.AdvancementItemChoiceTypeAny": "Anything", "DND5E.AdvancementItemChoiceTypeWarning": "Only {type} items can be selected for this choice.", "DND5E.AdvancementItemGrantTitle": "Grant Items", From eca969d1574ac8a8fa2eeced811cffb124b699d8 Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:26:11 +0200 Subject: [PATCH 085/199] [#3463] Default to config if skill's ability doesn't exist in src data (#3464) Co-authored-by: Zhell --- module/applications/actor/base-sheet.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index df9866860f..9df2f063bd 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -168,14 +168,20 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { } // Skills & tools. + const baseAbility = (prop, key) => { + let src = source.system[prop]?.[key]?.ability; + if ( src ) return src; + if ( prop === "skills" ) src = CONFIG.DND5E.skills[key]?.ability; + return src ?? "int"; + }; ["skills", "tools"].forEach(prop => { for ( const [key, entry] of Object.entries(context[prop]) ) { entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation; entry.icon = this._getProficiencyIcon(entry.value); entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value]; - entry.label = prop === "skills" ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel(key, {trait: "tool"}); + entry.label = (prop === "skills") ? CONFIG.DND5E.skills[key]?.label : Trait.keyLabel(key, {trait: "tool"}); entry.baseValue = source.system[prop]?.[key]?.value ?? 0; - entry.baseAbility = source.system[prop]?.[key]?.ability ?? "int"; + entry.baseAbility = baseAbility(prop, key); } }); From cdf03df58eb7dfaef0aa0a113450403bbae88d96 Mon Sep 17 00:00:00 2001 From: Zhell Date: Tue, 23 Apr 2024 17:52:43 +0200 Subject: [PATCH 086/199] [#3453] --- module/data/item/weapon.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 5783c256a5..21bb368414 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -134,14 +134,9 @@ export default class WeaponData extends ItemDataModel.mixin( /** @inheritdoc */ get _typeAbilityMod() { - const abi = this.parent?.actor?.system.abilities ?? {}; - const t0 = {simpleM: "str", martialM: "str", simpleR: "dex", martialR: "dex"}[this.type.value]; - if ( t0 && this.properties.has("fin") && abi.dex && abi.str ) { - const t1 = (t0 === "str") ? "dex" : "str"; - return (abi[t1].mod > abi[t0].mod) ? t1 : t0; - } - - return null; + const { str, dex } = this.parent?.actor?.system.abilities ?? {}; + if ( this.properties.has("fin") && str && dex ) return (dex.mod > str.mod) ? "dex" : "str"; + return { simpleM: "str", martialM: "str", simpleR: "dex", martialR: "dex" }[this.type.value] ?? null; } /* -------------------------------------------- */ From 60b525f7657dd4dd2c6835f4fd8eb35c9dce6b48 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 09:28:24 -0700 Subject: [PATCH 087/199] [#3418] Add missing period field, minor code cleanup --- lang/en.json | 3 +-- module/applications/item/enchantment-config.mjs | 12 ++++-------- module/data/item/fields/enchantment-field.mjs | 5 +++-- templates/items/feat.hbs | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/lang/en.json b/lang/en.json index 357c9b50ab..5af86b6db4 100644 --- a/lang/en.json +++ b/lang/en.json @@ -702,8 +702,7 @@ "items": { "max": { "label": "Item Limit", - "hint": "Formula for the maximum number of items that can have this enchantment.", - "hintSource": "Formula for the maximum number of enchantments of this type that can be active at a time." + "hint": "Formula for the maximum number of enchantments of this type that can be active at a time." }, "period": { "label": "Replacement Period", diff --git a/module/applications/item/enchantment-config.mjs b/module/applications/item/enchantment-config.mjs index 6e745c317b..6f4a342ae2 100644 --- a/module/applications/item/enchantment-config.mjs +++ b/module/applications/item/enchantment-config.mjs @@ -66,22 +66,18 @@ export default class EnchantmentConfig extends DocumentSheet { /* -------------------------------------------- */ /** @inheritDoc */ - async _updateObject(event, formData) { - const { action, enchantmentId } = formData; - delete formData.action; - delete formData.enchantmentId; - + async _updateObject(event, { action, enchantmentId, ...formData }) { await this.document.update({"system.enchantment": formData}); switch ( action ) { case "add-enchantment": - const effect = await this.document.createEmbeddedDocuments("ActiveEffect", [{ + const effect = await ActiveEffect.implementation.create({ name: this.document.name, icon: this.document.img, origin: this.document.uuid, "flags.dnd5e.type": "enchantment" - }]); - effect[0].sheet.render(true); + }, { parent: this.document }); + effect.sheet.render(true); break; case "delete-enchantment": const enchantment = this.document.effects.get(enchantmentId); diff --git a/module/data/item/fields/enchantment-field.mjs b/module/data/item/fields/enchantment-field.mjs index 7d05822cbe..888c975b44 100644 --- a/module/data/item/fields/enchantment-field.mjs +++ b/module/data/item/fields/enchantment-field.mjs @@ -20,7 +20,7 @@ export default class EnchantmentField extends EmbeddedDataField { * * @property {object} items * @property {string} items.max Maximum number of items that can have this enchantment. - * @property {string} items.period Frequently at which the enchantment be swapped. + * @property {string} items.period Frequency at which the enchantment be swapped. * @property {object} restrictions * @property {boolean} restrictions.allowMagical Allow enchantments to be applied to items that are already magical. * @property {string} restrictions.type Item type to which this enchantment can be applied. @@ -31,7 +31,8 @@ export class EnchantmentData extends foundry.abstract.DataModel { static defineSchema() { return { items: new SchemaField({ - max: new FormulaField({deterministic: true}) + max: new FormulaField({deterministic: true}), + period: new StringField() }), restrictions: new SchemaField({ allowMagical: new BooleanField(), diff --git a/templates/items/feat.hbs b/templates/items/feat.hbs index 7a811a717c..3aaa4627ee 100644 --- a/templates/items/feat.hbs +++ b/templates/items/feat.hbs @@ -89,7 +89,7 @@
        -

        {{ localize "DND5E.Enchantment.FIELDS.enchantment.items.max.hintSource" }}

        +

        {{ localize "DND5E.Enchantment.FIELDS.enchantment.items.max.hint" }}

        From 3a5b71db666d5706c80f788baff9397c40bd2102 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 09:35:06 -0700 Subject: [PATCH 088/199] [#3400] Fix typo in challenge visibility setting hint --- lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en.json b/lang/en.json index 6d642cd624..9355d78e33 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1949,7 +1949,7 @@ "SETTINGS.5eAutoSpellTemplateN": "Always place Spell Template", "SETTINGS.5eChallengeVisibility": { "Name": "Challenge Visibility", - "Hint": "Control what roll DCs are visible to the players and whether successess/failures are highlighted.", + "Hint": "Control what roll DCs are visible to the players and whether successes/failures are highlighted.", "All": "Show all", "None": "Hide all", "Player": "Show only from other players" From 12569fc69f59801027318490731d451658b77a8b Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 09:46:37 -0700 Subject: [PATCH 089/199] [#3132] Prevent rest from saying you recover negative hit points --- module/documents/actor/actor.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index 6acccb40e6..dfebd390ed 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -2480,7 +2480,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { else max = Math.max(0, hp.effectiveMax); updates["system.attributes.hp.value"] = max; if ( recoverTemp ) updates["system.attributes.hp.temp"] = 0; - return { updates, hitPointsRecovered: max - hp.value }; + return { updates, hitPointsRecovered: Math.max(0, max - hp.value) }; } /* -------------------------------------------- */ From a759decf27d91dafa4df5c9847ea908527eed89a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 09:51:29 -0700 Subject: [PATCH 090/199] [#2991] Indicate clearly when attunement max is editable --- less/v2/inventory.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/less/v2/inventory.less b/less/v2/inventory.less index b0e24a838b..58a44f6d0a 100644 --- a/less/v2/inventory.less +++ b/less/v2/inventory.less @@ -155,7 +155,7 @@ width: 22px; text-align: left; font-weight: bold; - background: none; + background: color-mix(in oklab, var(--dnd5e-color-card), black 7%); border: none; padding: 0; transition: box-shadow 250ms ease; From b17ed7bd7e432855f9b70ffbb5d7daaf09266d8a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 10:01:45 -0700 Subject: [PATCH 091/199] [#2773] Display limited spell uses on sheet Adds the uses as the first column on the player's spellbook tab. These uses only display when the spellbook is wide enough and are editable like in the features tab. If the sheet it too narrow, then the uses are instead displayed in the spell's subtitle alongside and required components. In the narrow mode they are not editable. --- less/v2/inventory.less | 4 +++- templates/actors/tabs/character-spells.hbs | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/less/v2/inventory.less b/less/v2/inventory.less index 58a44f6d0a..4919883252 100644 --- a/less/v2/inventory.less +++ b/less/v2/inventory.less @@ -342,6 +342,7 @@ } input { text-align: end; } } + .spell-uses { display: none; } /* Item Recovery */ .item-recovery { width: 60px; } @@ -473,7 +474,8 @@ } @container (min-width: 600px) { - .item-price, .item-formula { display: flex; } + .item-price, .item-formula, .spell-uses { display: flex; } + .subtitle-uses { display: none; } } @container (min-width: 650px) { diff --git a/templates/actors/tabs/character-spells.hbs b/templates/actors/tabs/character-spells.hbs index 56b1896107..a911413074 100644 --- a/templates/actors/tabs/character-spells.hbs +++ b/templates/actors/tabs/character-spells.hbs @@ -61,6 +61,7 @@ {{!-- Section Header --}}

        {{ localize label }}

        +
        {{ localize "DND5E.Uses" }}
        {{ localize "DND5E.SpellHeader.School" }}
        {{ localize "DND5E.SpellHeader.Time" }}
        {{ localize "DND5E.SpellHeader.Range" }}
        @@ -107,7 +108,14 @@ {{ item.name }}
        {{ item.name }} - {{ item.labels.components.vsm }} + + {{ item.labels.components.vsm }} + {{#if ctx.hasUses}} + + — {{ item.system.uses.value }} / {{ item.system.uses.max }} + + {{/if}} +
        {{#each item.labels.components.all}} @@ -120,6 +128,16 @@
        + {{!-- Spell Uses --}} +
        + {{#if ctx.hasUses}} + + / + {{ item.system.uses.max }} + {{/if}} +
        + {{!-- Spell School --}}
        From 6ac942008fac64c16a34ee668c8df9e543a8731b Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 10:40:56 -0700 Subject: [PATCH 092/199] [#3374] Disable damage fields when targeted by enchantment --- module/applications/item/item-sheet.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/module/applications/item/item-sheet.mjs b/module/applications/item/item-sheet.mjs index 907e7e63a4..faa3b71f89 100644 --- a/module/applications/item/item-sheet.mjs +++ b/module/applications/item/item-sheet.mjs @@ -149,13 +149,11 @@ export default class ItemSheet5e extends ItemSheet { } if ( ("properties" in item.system) && (item.type in CONFIG.DND5E.validProperties) ) { - const overrides = this._getItemOverrides(); context.properties = item.system.validProperties.reduce((obj, k) => { const v = CONFIG.DND5E.itemProperties[k]; obj[k] = { label: v.label, - selected: item.system.properties.has(k), - disabled: overrides?.includes(`properties.${k}`) + selected: item.system.properties.has(k) }; return obj; }, {}); @@ -374,6 +372,12 @@ export default class ItemSheet5e extends ItemSheet { if ( "properties" in this.item.system ) { ActiveEffect5e.addOverriddenChoices(this.item, "system.properties", "system.properties", overrides); } + if ( ("damage" in this.item.system) && foundry.utils.getProperty(this.item.overrides, "system.damage.parts") ) { + overrides.push("damage-control"); + Array.fromRange(2).forEach(index => overrides.push( + `system.damage.parts.${index}.0`, `system.damage.parts.${index}.1` + )); + } return overrides; } @@ -532,6 +536,7 @@ export default class ItemSheet5e extends ItemSheet { element.ariaDisabled = true; element.dataset.tooltip = "DND5E.Enchantment.Warning.Override"; } + if ( override === "damage-control" ) html[0].querySelectorAll(".damage-control").forEach(e => e.remove()); } } From 2f4190000a73877ece38e53f0d0d864ad0485900 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 10:56:54 -0700 Subject: [PATCH 093/199] [#3071] Fix rendering issues with UP & DOWN item tooltips --- module/tooltips.mjs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/module/tooltips.mjs b/module/tooltips.mjs index 35987c72cd..494127313b 100644 --- a/module/tooltips.mjs +++ b/module/tooltips.mjs @@ -176,21 +176,7 @@ export default class Tooltips5e { * @protected */ _positionItemTooltip(direction=TooltipManager.TOOLTIP_DIRECTIONS.LEFT) { - const tooltip = this.tooltip; - const { clientWidth, clientHeight } = document.documentElement; - const tooltipBox = tooltip.getBoundingClientRect(); - const targetBox = game.tooltip.element.getBoundingClientRect(); - const maxTop = clientHeight - tooltipBox.height; - const top = Math.min(maxTop, targetBox.bottom - ((targetBox.height + tooltipBox.height) / 2)); - const left = targetBox.left - tooltipBox.width - game.tooltip.constructor.TOOLTIP_MARGIN_PX; - const right = targetBox.right + game.tooltip.constructor.TOOLTIP_MARGIN_PX; - const { RIGHT, LEFT } = TooltipManager.TOOLTIP_DIRECTIONS; - if ( (direction === LEFT) && (left < 0) ) direction = RIGHT; - else if ( (direction === RIGHT) && (right + targetBox.width > clientWidth) ) direction = LEFT; - tooltip.style.top = `${Math.max(0, top)}px`; - tooltip.style.right = ""; - if ( direction === RIGHT ) tooltip.style.left = `${Math.min(right, clientWidth - tooltipBox.width)}px`; - else tooltip.style.left = `${Math.max(0, left)}px`; + game.tooltip._setAnchor(direction); // Set overflowing styles for item tooltips. if ( tooltip.classList.contains("item-tooltip") ) { From 18982bab89b5a30c345d7a205e35177c1ba52c15 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 13:24:00 -0700 Subject: [PATCH 094/199] [#3474] Fix compendium content that uses floor in dice expressions The new Peggy parser no longer properly handles roll expressions in the format `floor(@something / 2)d8` and now must be formetted with extra parenthesis: `(floor(@something / 2))d8`. This adjusts all of the uses of the old format in SRD content. --- packs/_source/monsters/humanoid/cult-fanatic.json | 12 ++++++------ packs/_source/monsters/humanoid/priest.json | 12 ++++++------ packs/_source/monsters/undead/mummy-lord.json | 14 +++++++------- packs/_source/spells/2nd-level/flame-blade.json | 6 +++--- .../_source/spells/2nd-level/spiritual-weapon.json | 6 +++--- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packs/_source/monsters/humanoid/cult-fanatic.json b/packs/_source/monsters/humanoid/cult-fanatic.json index 462230c42f..de8d7c6f74 100644 --- a/packs/_source/monsters/humanoid/cult-fanatic.json +++ b/packs/_source/monsters/humanoid/cult-fanatic.json @@ -1926,7 +1926,7 @@ "damage": { "parts": [ [ - "floor(@item.level / 2)d8 + @mod", + "(floor(@item.level / 2))d8 + @mod", "force" ] ], @@ -1979,10 +1979,10 @@ }, "_stats": { "systemId": "dnd5e", - "systemVersion": "2.0.0", - "coreVersion": "10.279", + "systemVersion": "3.2.0", + "coreVersion": "11.315", "createdTime": 1661787234115, - "modifiedTime": 1661791116932, + "modifiedTime": 1713903765041, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors.items!tYfQIxCJT0WaaKmc.JbxsYXxSOTZbf9I0" @@ -1997,10 +1997,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787233223, - "modifiedTime": 1704847054445, + "modifiedTime": 1713903765041, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!tYfQIxCJT0WaaKmc" diff --git a/packs/_source/monsters/humanoid/priest.json b/packs/_source/monsters/humanoid/priest.json index 482493a2b2..4ec4584d22 100644 --- a/packs/_source/monsters/humanoid/priest.json +++ b/packs/_source/monsters/humanoid/priest.json @@ -1585,7 +1585,7 @@ "damage": { "parts": [ [ - "floor(@item.level / 2)d8 + @mod", + "(floor(@item.level / 2))d8 + @mod", "force" ] ], @@ -1638,10 +1638,10 @@ }, "_stats": { "systemId": "dnd5e", - "systemVersion": "2.0.0", - "coreVersion": "10.279", + "systemVersion": "3.2.0", + "coreVersion": "11.315", "createdTime": 1661787234115, - "modifiedTime": 1661791116514, + "modifiedTime": 1713903779111, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors.items!PVD5wRdyO7iCJPs1.JbxsYXxSOTZbf9I0" @@ -2142,10 +2142,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787232808, - "modifiedTime": 1704847047405, + "modifiedTime": 1713903779111, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!PVD5wRdyO7iCJPs1" diff --git a/packs/_source/monsters/undead/mummy-lord.json b/packs/_source/monsters/undead/mummy-lord.json index e501a9f7b0..ae542c4e1c 100644 --- a/packs/_source/monsters/undead/mummy-lord.json +++ b/packs/_source/monsters/undead/mummy-lord.json @@ -69,7 +69,7 @@ "hp": { "value": 97, "max": 97, - "temp": null, + "temp": 0, "tempmax": 0, "formula": "13d8 + 39" }, @@ -2831,7 +2831,7 @@ "damage": { "parts": [ [ - "floor(@item.level / 2)d8 + @mod", + "(floor(@item.level / 2))d8 + @mod", "force" ] ], @@ -2884,10 +2884,10 @@ }, "_stats": { "systemId": "dnd5e", - "systemVersion": "2.0.0", - "coreVersion": "10.279", + "systemVersion": "3.2.0", + "coreVersion": "11.315", "createdTime": 1661787234115, - "modifiedTime": 1661791116569, + "modifiedTime": 1713903749927, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors.items!UFW8M3JHzHkxUEGM.JbxsYXxSOTZbf9I0" @@ -3751,10 +3751,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787232849, - "modifiedTime": 1704847048406, + "modifiedTime": 1713903751812, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!UFW8M3JHzHkxUEGM" diff --git a/packs/_source/spells/2nd-level/flame-blade.json b/packs/_source/spells/2nd-level/flame-blade.json index 5554a27902..3a53110eb3 100644 --- a/packs/_source/spells/2nd-level/flame-blade.json +++ b/packs/_source/spells/2nd-level/flame-blade.json @@ -60,7 +60,7 @@ "damage": { "parts": [ [ - "floor(2 + @item.level / 2)d6", + "(floor(2 + @item.level / 2))d6", "fire" ] ], @@ -105,10 +105,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234089, - "modifiedTime": 1704823522543, + "modifiedTime": 1713903698778, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!Advtckpz1B733bu9" diff --git a/packs/_source/spells/2nd-level/spiritual-weapon.json b/packs/_source/spells/2nd-level/spiritual-weapon.json index d297dc939e..fb0aeb4cfe 100644 --- a/packs/_source/spells/2nd-level/spiritual-weapon.json +++ b/packs/_source/spells/2nd-level/spiritual-weapon.json @@ -60,7 +60,7 @@ "damage": { "parts": [ [ - "floor(@item.level / 2)d8 + @mod", + "(floor(@item.level / 2))d8 + @mod", "force" ] ], @@ -103,10 +103,10 @@ "flags": {}, "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234115, - "modifiedTime": 1704823523229, + "modifiedTime": 1713903686177, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!JbxsYXxSOTZbf9I0" From d15121dbe9347fe6fee6bbc83bded2def57c57f2 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 13:36:45 -0700 Subject: [PATCH 095/199] [#2597] Add identifier field to background, add idnetifier input --- module/data/item/background.mjs | 4 +++- templates/items/background.hbs | 10 ++++++++++ templates/items/class.hbs | 7 ++++--- templates/items/race.hbs | 16 ++++++++++++++++ templates/items/subclass.hbs | 1 + 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/module/data/item/background.mjs b/module/data/item/background.mjs index 26ac451e41..d9b8c00a61 100644 --- a/module/data/item/background.mjs +++ b/module/data/item/background.mjs @@ -1,5 +1,5 @@ import { ItemDataModel } from "../abstract.mjs"; -import { AdvancementField } from "../fields.mjs"; +import { AdvancementField, IdentifierField } from "../fields.mjs"; import ItemDescriptionTemplate from "./templates/item-description.mjs"; import StartingEquipmentTemplate from "./templates/starting-equipment.mjs"; @@ -8,12 +8,14 @@ import StartingEquipmentTemplate from "./templates/starting-equipment.mjs"; * @mixes ItemDescriptionTemplate * @mixes StartingEquipmentTemplate * + * @property {string} identifier Identifier slug for this background. * @property {object[]} advancement Advancement objects for this background. */ export default class BackgroundData extends ItemDataModel.mixin(ItemDescriptionTemplate, StartingEquipmentTemplate) { /** @inheritdoc */ static defineSchema() { return this.mergeSchema(super.defineSchema(), { + identifier: new IdentifierField({required: true, label: "DND5E.Identifier"}), advancement: new foundry.data.fields.ArrayField(new AdvancementField(), {label: "DND5E.AdvancementTitle"}) }); } diff --git a/templates/items/background.hbs b/templates/items/background.hbs index efb908d2f9..519b6d26d8 100644 --- a/templates/items/background.hbs +++ b/templates/items/background.hbs @@ -41,6 +41,16 @@ {{!-- Details Tab --}}
        + {{!-- Identifier --}} +
        + +
        + +
        +

        {{ localize "DND5E.IdentifierError" }}

        +
        + {{!-- Starting Equipment --}}

        {{ localize "DND5E.StartingEquipment.Title" }} diff --git a/templates/items/class.hbs b/templates/items/class.hbs index 2d5594fd61..08852518a4 100644 --- a/templates/items/class.hbs +++ b/templates/items/class.hbs @@ -45,11 +45,12 @@
        - +

        - {{{localize "DND5E.ClassIdentifierHint" identifier=item.identifier}}} + {{{ localize "DND5E.ClassIdentifierHint" identifier=item.identifier }}} + {{ localize "DND5E.IdentifierError" }}

        diff --git a/templates/items/race.hbs b/templates/items/race.hbs index 86bf79ff8d..fdb7746bc4 100644 --- a/templates/items/race.hbs +++ b/templates/items/race.hbs @@ -24,6 +24,7 @@ {{!-- Item Sheet Navigation --}} @@ -69,6 +70,21 @@ engine="prosemirror" collaborate=false}}

        + {{!-- Details Tab --}} +
        + + {{!-- Identifier --}} +
        + +
        + +
        +

        {{ localize "DND5E.IdentifierError" }}

        +
        + +
        + {{!-- Advancement Tab --}} {{> "dnd5e.item-advancement"}} diff --git a/templates/items/subclass.hbs b/templates/items/subclass.hbs index df78a34def..3e2e4a2319 100644 --- a/templates/items/subclass.hbs +++ b/templates/items/subclass.hbs @@ -47,6 +47,7 @@
        +

        {{ localize "DND5E.IdentifierError" }}

        From d6ec13bc1d513bc89b8aa7908d0c17bddc1401c5 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 23 Apr 2024 14:46:48 -0700 Subject: [PATCH 096/199] [#3374] Fix base damage being deleted when enchantment targets it --- module/applications/item/item-sheet.mjs | 4 +++- module/data/item/templates/action.mjs | 13 ------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/module/applications/item/item-sheet.mjs b/module/applications/item/item-sheet.mjs index faa3b71f89..df5db89a06 100644 --- a/module/applications/item/item-sheet.mjs +++ b/module/applications/item/item-sheet.mjs @@ -460,7 +460,9 @@ export default class ItemSheet5e extends ItemSheet { // Handle Damage array const damage = formData.system?.damage; - if ( damage ) damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]); + if ( damage && !foundry.utils.getProperty(this.item.overrides, "system.damage.parts") ) { + damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]); + } // Handle properties if ( foundry.utils.hasProperty(formData, "system.properties") ) { diff --git a/module/data/item/templates/action.mjs b/module/data/item/templates/action.mjs index 82a3ca8af2..164e53b43c 100644 --- a/module/data/item/templates/action.mjs +++ b/module/data/item/templates/action.mjs @@ -69,7 +69,6 @@ export default class ActionTemplate extends ItemDataModel { ActionTemplate.#migrateAttack(source); ActionTemplate.#migrateCritical(source); ActionTemplate.#migrateSave(source); - ActionTemplate.#migrateDamage(source); } /* -------------------------------------------- */ @@ -128,18 +127,6 @@ export default class ActionTemplate extends ItemDataModel { } } - /* -------------------------------------------- */ - - /** - * Migrate damage parts. - * @param {object} source The candidate source data from which the model will be constructed. - */ - static #migrateDamage(source) { - if ( !("damage" in source) ) return; - source.damage ??= {}; - source.damage.parts ??= []; - } - /* -------------------------------------------- */ /* Getters */ /* -------------------------------------------- */ From d968668dbecd2d7736a2aab8546cd389c0ce18e0 Mon Sep 17 00:00:00 2001 From: Kim Mantas Date: Wed, 24 Apr 2024 17:37:06 +0100 Subject: [PATCH 097/199] Show race advancements before class and subclass advancements. --- module/applications/advancement/advancement-manager.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/applications/advancement/advancement-manager.mjs b/module/applications/advancement/advancement-manager.mjs index 825a28bcb6..1b3586a94a 100644 --- a/module/applications/advancement/advancement-manager.mjs +++ b/module/applications/advancement/advancement-manager.mjs @@ -330,9 +330,10 @@ export default class AdvancementManager extends Application { * @private */ createLevelChangeSteps(classItem, levelDelta) { + const raceItem = this.clone.system?.details?.race; const pushSteps = (flows, data) => this.steps.push(...flows.map(flow => ({ flow, ...data }))); const getItemFlows = characterLevel => this.clone.items.contents.flatMap(i => { - if ( ["class", "subclass"].includes(i.type) ) return []; + if ( ["class", "subclass", "race"].includes(i.type) ) return []; return this.constructor.flowsForLevel(i, characterLevel); }); @@ -341,6 +342,7 @@ export default class AdvancementManager extends Application { const classLevel = classItem.system.levels + offset; const characterLevel = (this.actor.system.details.level ?? 0) + offset; const stepData = { type: "forward", class: {item: classItem, level: classLevel} }; + pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel), stepData); pushSteps(this.constructor.flowsForLevel(classItem, classLevel), stepData); pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel), stepData); pushSteps(getItemFlows(characterLevel), stepData); @@ -354,6 +356,7 @@ export default class AdvancementManager extends Application { pushSteps(getItemFlows(characterLevel).reverse(), stepData); pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel).reverse(), stepData); pushSteps(this.constructor.flowsForLevel(classItem, classLevel).reverse(), stepData); + pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel).reverse(), stepData); if ( classLevel === 1 ) this.steps.push({ type: "delete", item: classItem, automatic: true }); } From 9f20378ca19e6304120d84eed1d3f6b8ea78f695 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 24 Apr 2024 10:11:30 -0700 Subject: [PATCH 098/199] [#3429] Fix bypasses when calculating damage modification --- module/documents/actor/actor.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index dfebd390ed..eb84787cc1 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -1048,7 +1048,8 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { d.active = {}; // Apply type-specific damage reduction - if ( !ignore("modification", d.type) && traits.dm?.amount[d.type] ) { + if ( !ignore("modification", d.type) && traits.dm?.amount[d.type] + && !traits.dm.bypasses.intersection(d.properties).size ) { const modification = simplifyBonus(traits.dm.amount[d.type], rollData); if ( Math.sign(d.value) !== Math.sign(d.value + modification) ) d.value = 0; else d.value += modification; From 460d33a397be3b749efc81f5d8cfa81724d0a6f4 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 24 Apr 2024 10:17:14 -0700 Subject: [PATCH 099/199] [#3452] Don't replace `damage.active` set in `preCalculateDamage` --- module/documents/actor/actor.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index eb84787cc1..64145435b9 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -1039,13 +1039,15 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { const rollData = this.getRollData({deterministic: true}); damages.forEach(d => { + d.active ??= {}; + // Skip damage types with immunity if ( skipped(d.type) || (!ignore("immunity", d.type) && hasEffect("di", d.type, d.properties)) ) { d.value = 0; - d.active = { multiplier: 0, immunity: true }; + d.active.multiplier = 0; + d.active.immunity = true; return; } - d.active = {}; // Apply type-specific damage reduction if ( !ignore("modification", d.type) && traits.dm?.amount[d.type] @@ -1074,7 +1076,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { if ( (options.invertHealing !== false) && (d.type === "healing") ) damageMultiplier *= -1; d.value = d.value * damageMultiplier; - d.active.multiplier = damageMultiplier; + d.active.multiplier = (d.active.multiplier ?? 1) * damageMultiplier; }); /** From ede8ccd82b3f88c46ebe30dd14407f361b56bc6e Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 24 Apr 2024 11:02:16 -0700 Subject: [PATCH 100/199] Fix issue with rendering sheet with missing damage types --- module/applications/actor/character-sheet-2.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 9e826b0b40..47a12a2ca2 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -484,7 +484,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { const total = simplifyBonus(v, rollData); if ( !total ) return null; const value = { - label: `${CONFIG.DND5E.damageTypes[k]?.label ?? key} ${formatNumber(total, { signDisplay: "always" })}`, + label: `${CONFIG.DND5E.damageTypes[k]?.label ?? k} ${formatNumber(total, { signDisplay: "always" })}`, color: total > 0 ? "maroon" : "green" }; const icons = value.icons = []; From 5df66910548c7432d9b75f68b0d36856998d4aaf Mon Sep 17 00:00:00 2001 From: Kim Mantas Date: Wed, 24 Apr 2024 21:30:10 +0100 Subject: [PATCH 101/199] Fix advancements failing to update in v12 --- module/documents/item.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/module/documents/item.mjs b/module/documents/item.mjs index caff24fbff..58ae0ce450 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2385,16 +2385,18 @@ export default class Item5e extends SystemDocumentMixin(Item) { if ( idx === -1 ) throw new Error(`Advancement of ID ${id} could not be found to update`); const advancement = this.advancement.byId[id]; - advancement.updateSource(updates); if ( source ) { + advancement.updateSource(updates); advancement.render(); return this; } const advancementCollection = this.toObject().system.advancement; - advancementCollection[idx] = advancement.toObject(); + const clone = new advancement.constructor(advancementCollection[idx], { parent: advancement.parent }); + clone.updateSource(updates); + advancementCollection[idx] = clone.toObject(); return this.update({"system.advancement": advancementCollection}).then(r => { - advancement.render(); + advancement.render(false, { height: "auto" }); return r; }); } From 6b72ea39ec705457c98981155ef25ef3a3182f21 Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Thu, 25 Apr 2024 16:04:08 +0200 Subject: [PATCH 102/199] Add colors to damage types config (#2730) Co-authored-by: Zhell --- module/config.mjs | 46 +++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/module/config.mjs b/module/config.mjs index 182cba1745..a785c25122 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -1562,6 +1562,7 @@ preLocalize("physicalDamageTypes", { sort: true }); * @property {string} icon Icon representing this type. * @property {boolean} [isPhysical] Is this a type that can be bypassed by magical or silvered weapons? * @property {string} [reference] Reference to a rule page describing this damage type. + * @property {Color} Color Visual color of the damage type. */ /** @@ -1572,70 +1573,83 @@ DND5E.damageTypes = { acid: { label: "DND5E.DamageAcid", icon: "systems/dnd5e/icons/svg/damage/acid.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IQhbKRPe1vCPdh8v" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IQhbKRPe1vCPdh8v", + color: new Color(0x839D50) }, bludgeoning: { label: "DND5E.DamageBludgeoning", icon: "systems/dnd5e/icons/svg/damage/bludgeoning.svg", isPhysical: true, - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.39LFrlef94JIYO8m" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.39LFrlef94JIYO8m", + color: new Color(0x0000A0) }, cold: { label: "DND5E.DamageCold", icon: "systems/dnd5e/icons/svg/damage/cold.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4xsFUooHDEdfhw6g" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4xsFUooHDEdfhw6g", + color: new Color(0xADD8E6) }, fire: { label: "DND5E.DamageFire", icon: "systems/dnd5e/icons/svg/damage/fire.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.f1S66aQJi4PmOng6" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.f1S66aQJi4PmOng6", + color: new Color(0xFF4500) }, force: { label: "DND5E.DamageForce", icon: "systems/dnd5e/icons/svg/damage/force.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFTWzngD8dKWQuUR" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFTWzngD8dKWQuUR", + color: new Color(0x800080) }, lightning: { label: "DND5E.DamageLightning", icon: "systems/dnd5e/icons/svg/damage/lightning.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9SaxFJ9bM3SutaMC" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9SaxFJ9bM3SutaMC", + color: new Color(0x1E90FF) }, necrotic: { label: "DND5E.DamageNecrotic", icon: "systems/dnd5e/icons/svg/damage/necrotic.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.klOVUV5G1U7iaKoG" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.klOVUV5G1U7iaKoG", + color: new Color(0x006400) }, piercing: { label: "DND5E.DamagePiercing", icon: "systems/dnd5e/icons/svg/damage/piercing.svg", isPhysical: true, - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.95agSnEGTdAmKhyC" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.95agSnEGTdAmKhyC", + color: new Color(0xC0C0C0) }, poison: { label: "DND5E.DamagePoison", icon: "systems/dnd5e/icons/svg/statuses/poisoned.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k5wOYXdWPzcWwds1" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k5wOYXdWPzcWwds1", + color: new Color(0x8A2BE2) }, psychic: { label: "DND5E.DamagePsychic", icon: "systems/dnd5e/icons/svg/damage/psychic.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YIKbDv4zYqbE5teJ" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YIKbDv4zYqbE5teJ", + color: new Color(0xFF1493) }, radiant: { label: "DND5E.DamageRadiant", icon: "systems/dnd5e/icons/svg/damage/radiant.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5tcK9buXWDOw8yHH" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5tcK9buXWDOw8yHH", + color: new Color(0xFFD700) }, slashing: { label: "DND5E.DamageSlashing", icon: "systems/dnd5e/icons/svg/damage/slashing.svg", isPhysical: true, - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sz2XKQ5lgsdPEJOa" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sz2XKQ5lgsdPEJOa", + color: new Color(0x8B0000) }, thunder: { label: "DND5E.DamageThunder", icon: "systems/dnd5e/icons/svg/damage/thunder.svg", - reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iqsmMHk7FSpiNkQy" + reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iqsmMHk7FSpiNkQy", + color: new Color(0x708090) } }; patchConfig("damageTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); @@ -1652,11 +1666,13 @@ preLocalize("damageTypes", { keys: ["label"], sort: true }); DND5E.healingTypes = { healing: { label: "DND5E.Healing", - icon: "systems/dnd5e/icons/svg/damage/healing.svg" + icon: "systems/dnd5e/icons/svg/damage/healing.svg", + color: new Color(0x46C252) }, temphp: { label: "DND5E.HealingTemp", - icon: "systems/dnd5e/icons/svg/damage/temphp.svg" + icon: "systems/dnd5e/icons/svg/damage/temphp.svg", + color: new Color(0x4B66DE) } }; patchConfig("healingTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); From 36be39748038ffc9ec298668afd96bee01b2810a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 5 Apr 2024 13:38:56 -0500 Subject: [PATCH 103/199] [#3417] Allow multiple spell abilities in Grant & Choice advancement Allows more than one ability to be specified within the spell data for `ItemGrant` and `ItemChoice` advancement types, presenting the player with a choice of abilities if more than one are specified. --- less/elements.less | 9 ++++++ less/v1/items.less | 5 --- .../advancement/advancement-config.mjs | 4 +++ .../advancement/item-choice-config.mjs | 13 +++++--- .../advancement/item-choice-flow.mjs | 31 +++++++++++++----- .../advancement/item-grant-config.mjs | 18 +++++++++-- .../advancement/item-grant-flow.mjs | 20 +++++++++++- module/data/advancement/spell-config.mjs | 32 +++++++++++++++---- module/documents/advancement/item-grant.mjs | 16 +++++++--- templates/advancement/item-choice-flow.hbs | 13 ++++++-- templates/advancement/item-grant-flow.hbs | 13 ++++++-- .../parts/advancement-spell-config.hbs | 23 +++++++------ 12 files changed, 152 insertions(+), 45 deletions(-) diff --git a/less/elements.less b/less/elements.less index 75d8a883aa..cc31b08da9 100644 --- a/less/elements.less +++ b/less/elements.less @@ -183,3 +183,12 @@ item-list-controls search { .filter-control[data-action="sort"] { padding-left: .625rem; } } + +/* ---------------------------------- */ +/* Multi Select */ +/* ---------------------------------- */ + +.dnd5e multi-select .tags { + flex-wrap: wrap; + .tag { cursor: pointer; } +} diff --git a/less/v1/items.less b/less/v1/items.less index 1a6a99a068..925c212595 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -607,9 +607,4 @@ margin-block-end: -3px; } } - - multi-select .tags { - flex-wrap: wrap; - .tag { cursor: pointer; } - } } diff --git a/module/applications/advancement/advancement-config.mjs b/module/applications/advancement/advancement-config.mjs index 7a1150044f..3a29962c53 100644 --- a/module/applications/advancement/advancement-config.mjs +++ b/module/applications/advancement/advancement-config.mjs @@ -112,6 +112,10 @@ export default class AdvancementConfig extends FormApplication { // Remove an item from the list if ( this.options.dropKeyPath ) html.on("click", "[data-action='delete']", this._onItemDelete.bind(this)); + + for ( const element of html[0].querySelectorAll("multi-select") ) { + element.addEventListener("change", this._onChangeInput.bind(this)); + } } /* -------------------------------------------- */ diff --git a/module/applications/advancement/item-choice-config.mjs b/module/applications/advancement/item-choice-config.mjs index 26354041b9..85d4d5d25d 100644 --- a/module/applications/advancement/item-choice-config.mjs +++ b/module/applications/advancement/item-choice-config.mjs @@ -5,7 +5,7 @@ import AdvancementConfig from "./advancement-config.mjs"; */ export default class ItemChoiceConfig extends AdvancementConfig { - /** @inheritdoc */ + /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["dnd5e", "advancement", "item-choice", "two-column"], @@ -18,11 +18,15 @@ export default class ItemChoiceConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ getData(options={}) { const indexes = this.advancement.configuration.pool.map(i => fromUuidSync(i.uuid)); const context = { ...super.getData(options), + abilities: Object.entries(CONFIG.DND5E.abilities).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: this.advancement.configuration.spell?.ability.has(k) ? "selected" : "" }; + return obj; + }, {}), showContainerWarning: indexes.some(i => i?.type === "container"), showSpellConfig: this.advancement.configuration.type === "spell", validTypes: this.advancement.constructor.VALID_TYPES.reduce((obj, type) => { @@ -44,9 +48,10 @@ export default class ItemChoiceConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ async prepareConfigurationUpdate(configuration) { if ( configuration.choices ) configuration.choices = this.constructor._cleanedObject(configuration.choices); + if ( configuration.spell ) configuration.spell.ability ??= []; // Ensure items are still valid if type restriction or spell restriction are changed const pool = []; @@ -62,7 +67,7 @@ export default class ItemChoiceConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ _validateDroppedItem(event, item) { this.advancement._validateItemType(item); } diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index f7c2f010d1..92bc686d0e 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -6,6 +6,12 @@ import ItemGrantFlow from "./item-grant-flow.mjs"; */ export default class ItemChoiceFlow extends ItemGrantFlow { + /** + * Currently selected ability. + * @type {string} + */ + ability; + /** * Set of selected UUIDs. * @type {Set} @@ -38,6 +44,8 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /** @inheritdoc */ async getContext() { + const context = {}; + this.selected ??= new Set( this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")) ?? Object.values(this.advancement.value[this.level] ?? {}) @@ -55,27 +63,31 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } const max = this.advancement.configuration.choices[this.level]; - const choices = { max, current: this.selected.size, full: this.selected.size >= max }; + context.choices = { max, current: this.selected.size, full: this.selected.size >= max }; - const previousLevels = {}; + context.previousLevels = {}; const previouslySelected = new Set(); for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) { if ( level > this.level ) continue; - previousLevels[level] = await Promise.all(Object.values(data).map(uuid => fromUuid(uuid))); + context.previousLevels[level] = await Promise.all(Object.values(data).map(uuid => fromUuid(uuid))); Object.values(data).forEach(uuid => previouslySelected.add(uuid)); } - const items = [...this.pool, ...this.dropped].reduce((items, i) => { + context.items = [...this.pool, ...this.dropped].reduce((items, i) => { if ( i ) { i.checked = this.selected.has(i.uuid); - i.disabled = !i.checked && choices.full; + i.disabled = !i.checked && context.choices.full; const validLevel = (i.system.prerequisites?.level ?? -Infinity) <= this.level; if ( !previouslySelected.has(i.uuid) && validLevel ) items.push(i); } return items; }, []); - return { choices, items, previousLevels }; + context.abilities = this.getSelectAbilities(); + context.abilities.disabled = previouslySelected.size; + this.ability ??= context.abilities.selected; + + return context; } /* -------------------------------------------- */ @@ -90,8 +102,11 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /** @inheritdoc */ _onChangeInput(event) { - if ( event.target.checked ) this.selected.add(event.target.name); - else this.selected.delete(event.target.name); + if ( event.target.type === "checkbox" ) { + if ( event.target.checked ) this.selected.add(event.target.name); + else this.selected.delete(event.target.name); + } + else if ( event.target.name === "ability" ) this.ability = event.target.value; this.render(); } diff --git a/module/applications/advancement/item-grant-config.mjs b/module/applications/advancement/item-grant-config.mjs index 018188446c..3b008bba17 100644 --- a/module/applications/advancement/item-grant-config.mjs +++ b/module/applications/advancement/item-grant-config.mjs @@ -5,7 +5,7 @@ import AdvancementConfig from "./advancement-config.mjs"; */ export default class ItemGrantConfig extends AdvancementConfig { - /** @inheritdoc */ + /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["dnd5e", "advancement", "item-grant"], @@ -17,10 +17,14 @@ export default class ItemGrantConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ getData(options={}) { const context = super.getData(options); const indexes = context.configuration.items.map(i => fromUuidSync(i.uuid)); + context.abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [k, c]) => { + obj[k] = { label: c.label, selected: context.configuration.spell?.ability.has(k) ? "selected" : "" }; + return obj; + }, {}); context.showContainerWarning = indexes.some(i => i?.type === "container"); context.showSpellConfig = indexes.some(i => i?.type === "spell"); return context; @@ -28,7 +32,15 @@ export default class ItemGrantConfig extends AdvancementConfig { /* -------------------------------------------- */ - /** @inheritdoc */ + /** @inheritDoc */ + async prepareConfigurationUpdate(configuration) { + if ( configuration.spell ) configuration.spell.ability ??= []; + return configuration; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ _validateDroppedItem(event, item) { this.advancement._validateItemType(item); } diff --git a/module/applications/advancement/item-grant-flow.mjs b/module/applications/advancement/item-grant-flow.mjs index 1c98279625..410758a890 100644 --- a/module/applications/advancement/item-grant-flow.mjs +++ b/module/applications/advancement/item-grant-flow.mjs @@ -31,7 +31,8 @@ export default class ItemGrantFlow extends AdvancementFlow { item.checked = added ? checked.has(item.uuid) : (config.optional && !i.optional); item.optional = config.optional || i.optional; return item; - }, []).filter(i => i) + }, []).filter(i => i), + abilities: this.getSelectAbilities() }; } @@ -44,6 +45,23 @@ export default class ItemGrantFlow extends AdvancementFlow { /* -------------------------------------------- */ + /** + * Get the context information for selected spell abilities. + * @returns {object} + */ + getSelectAbilities() { + const config = this.advancement.configuration; + return { + options: config.spell?.ability.size > 1 ? config.spell.ability.reduce((obj, k) => { + obj[k] = CONFIG.DND5E.abilities[k]?.label; + return obj; + }, {}) : null, + selected: this.ability ?? this.retainedData?.ability ?? config.spell?.ability.first() + }; + } + + /* -------------------------------------------- */ + /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); diff --git a/module/data/advancement/spell-config.mjs b/module/data/advancement/spell-config.mjs index 3a96a8c800..9114c37ecb 100644 --- a/module/data/advancement/spell-config.mjs +++ b/module/data/advancement/spell-config.mjs @@ -1,27 +1,45 @@ import { FormulaField } from "../fields.mjs"; +const { SchemaField, SetField, StringField } = foundry.data.fields; + export default class SpellConfigurationData extends foundry.abstract.DataModel { /** @inheritdoc */ static defineSchema() { return { - ability: new foundry.data.fields.StringField({label: "DND5E.AbilityModifier"}), - preparation: new foundry.data.fields.StringField({label: "DND5E.SpellPreparationMode"}), - uses: new foundry.data.fields.SchemaField({ + ability: new SetField(new StringField()), + preparation: new StringField({label: "DND5E.SpellPreparationMode"}), + uses: new SchemaField({ max: new FormulaField({deterministic: true, label: "DND5E.UsesMax"}), - per: new foundry.data.fields.StringField({label: "DND5E.UsesPeriod"}) + per: new StringField({label: "DND5E.UsesPeriod"}) }, {label: "DND5E.LimitedUses"}) }; } + /* -------------------------------------------- */ + /* Data Migrations */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + static migrateData(source) { + if ( foundry.utils.getType(source.ability) === "string" ) { + source.ability = source.ability ? [source.ability] : []; + } + } + + /* -------------------------------------------- */ + /* Helpers */ /* -------------------------------------------- */ /** * Changes that this spell configuration indicates should be performed on spells. - * @type {object} + * @param {object} data Data for the advancement process. + * @returns {object} */ - get spellChanges() { + getSpellChanges(data={}) { const updates = {}; - if ( this.ability ) updates["system.ability"] = this.ability; + if ( this.ability.size ) { + updates["system.ability"] = this.ability.has(data.ability) ? data.ability : this.ability.first(); + } if ( this.preparation ) updates["system.preparation.mode"] = this.preparation; if ( this.uses.max && this.uses.per ) { updates["system.uses.max"] = this.uses.max; diff --git a/module/documents/advancement/item-grant.mjs b/module/documents/advancement/item-grant.mjs index b43bf8768b..fcc899992c 100644 --- a/module/documents/advancement/item-grant.mjs +++ b/module/documents/advancement/item-grant.mjs @@ -85,7 +85,9 @@ export default class ItemGrantAdvancement extends Advancement { async apply(level, data, retainedData={}) { const items = []; const updates = {}; - const spellChanges = this.configuration.spell?.spellChanges ?? {}; + const spellChanges = this.configuration.spell?.getSpellChanges({ + ability: data.ability ?? this.retainedData?.ability ?? this.value?.ability + }) ?? {}; for ( const [uuid, selected] of Object.entries(data) ) { if ( !selected ) continue; @@ -105,7 +107,10 @@ export default class ItemGrantAdvancement extends Advancement { updates[itemData._id] = uuid; } this.actor.updateSource({items}); - this.updateSource({[this.storagePath(level)]: updates}); + this.updateSource({ + "value.ability": data.ability, + [this.storagePath(level)]: updates + }); } /* -------------------------------------------- */ @@ -117,7 +122,10 @@ export default class ItemGrantAdvancement extends Advancement { this.actor.updateSource({items: [item]}); updates[item._id] = item.flags.dnd5e.sourceId; } - this.updateSource({[this.storagePath(level)]: updates}); + this.updateSource({ + "value.ability": data.ability, + [this.storagePath(level)]: updates + }); } /* -------------------------------------------- */ @@ -132,7 +140,7 @@ export default class ItemGrantAdvancement extends Advancement { this.actor.items.delete(id); } this.updateSource({[keyPath.replace(/\.([\w\d]+)$/, ".-=$1")]: null}); - return { items }; + return { ability: this.value?.ability, items }; } /* -------------------------------------------- */ diff --git a/templates/advancement/item-choice-flow.hbs b/templates/advancement/item-choice-flow.hbs index 34dbee67ce..2e57bcaa59 100644 --- a/templates/advancement/item-choice-flow.hbs +++ b/templates/advancement/item-choice-flow.hbs @@ -1,5 +1,5 @@ -
        +

        {{{title}}}

        @@ -7,6 +7,15 @@

        {{advancement.configuration.hint}}

        {{/if}} + {{#if abilities.options}} +
        + + +
        + {{/if}} + {{#each previousLevels}}

        {{localize "DND5E.AdvancementLevelHeader" level=@key}}

        {{#each this}} diff --git a/templates/advancement/item-grant-flow.hbs b/templates/advancement/item-grant-flow.hbs index 09614b1623..3c8a96564d 100644 --- a/templates/advancement/item-grant-flow.hbs +++ b/templates/advancement/item-grant-flow.hbs @@ -1,5 +1,14 @@ - -

        {{{title}}}

        + +

        {{{ title }}}

        + + {{#if abilities.options}} +
        + + +
        + {{/if}} {{#each items}}
        diff --git a/templates/advancement/parts/advancement-spell-config.hbs b/templates/advancement/parts/advancement-spell-config.hbs index 8238f43254..083f66ad62 100644 --- a/templates/advancement/parts/advancement-spell-config.hbs +++ b/templates/advancement/parts/advancement-spell-config.hbs @@ -1,27 +1,32 @@
        - +
        - + + {{#each abilities}} + + {{/each}} +
        - +
        - +
        - +
        From 502feb4fe7c9d73d456436f539eb1b9e2d3c4819 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 10:17:43 -0700 Subject: [PATCH 104/199] [#1833] Change >= to just > for check --- .../applications/advancement/ability-score-improvement-flow.mjs | 2 +- module/applications/advancement/item-choice-flow.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/applications/advancement/ability-score-improvement-flow.mjs b/module/applications/advancement/ability-score-improvement-flow.mjs index e8e6a29552..cb5e0d2c84 100644 --- a/module/applications/advancement/ability-score-improvement-flow.mjs +++ b/module/applications/advancement/ability-score-improvement-flow.mjs @@ -204,7 +204,7 @@ export default class AbilityScoreImprovementFlow extends AdvancementFlow { } // If a feat has a level pre-requisite, make sure it is less than or equal to current character level - if ( (item.system.prerequisites?.level ?? -Infinity) >= this.advancement.actor.system.details.level ) { + if ( (item.system.prerequisites?.level ?? -Infinity) > this.advancement.actor.system.details.level ) { ui.notifications.error(game.i18n.format("DND5E.AdvancementAbilityScoreImprovementFeatLevelWarning", { level: item.system.prerequisites.level })); diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 47b3118b82..4804450320 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -148,7 +148,7 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } // If a feature has a level pre-requisite, make sure it is less than or equal to current level - if ( (item.system.prerequisites?.level ?? -Infinity) >= this.level ) { + if ( (item.system.prerequisites?.level ?? -Infinity) > this.level ) { ui.notifications.error(game.i18n.format("DND5E.AdvancementItemChoiceFeatureLevelWarning", { level: item.system.prerequisites.level })); From 9a5f15aad1a9508daab726fc84a6476f92f7aa64 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 3 Nov 2022 16:24:58 -0700 Subject: [PATCH 105/199] [#1909] Add units to item weight & convert for calculating encumbrance Item weights now define a specific unit which will make it possible to have a mixture of imperial and metric object weights, as well as represent large items in vehicle inventories. Defines these weights along with a conversion method in `CONFIG.weightUnits`. --- lang/en.json | 19 +++++++ less/v1/items.less | 2 +- module/applications/actor/vehicle-sheet.mjs | 14 ++--- module/config.mjs | 59 ++++++++++++++++++-- module/data/item/container.mjs | 12 ++-- module/data/item/templates/physical-item.mjs | 41 +++++++++++--- module/documents/actor/actor.mjs | 16 +++--- module/utils.mjs | 21 +++++++ templates/items/parts/item-description.hbs | 5 +- templates/shared/inventory.hbs | 3 +- 10 files changed, 158 insertions(+), 34 deletions(-) diff --git a/lang/en.json b/lang/en.json index 9355d78e33..e3ed19843f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1817,6 +1817,25 @@ "DND5E.WeaponSimpleProficiency": "Simple", "DND5E.WeaponSimpleR": "Simple Ranged", "DND5E.Weight": "Weight", +"DND5E.WeightUnit": { + "Label": "Weight Units", + "Kilograms": { + "Label": "Kilograms", + "Abbreviation": "kg" + }, + "Megagrams": { + "Label": "Tonnes", + "Abbreviation": "t" + }, + "Pounds": { + "Label": "Pounds", + "Abbreviation": "lb" + }, + "Tons": { + "Label": "Tons", + "Abbreviation": "tn" + } +}, "DND5E.WhisperedTo": "Whispered to", "DND5E.Wiki": "Wiki", "DND5E.available": "available", diff --git a/less/v1/items.less b/less/v1/items.less index 925c212595..a860a8499a 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -137,7 +137,7 @@ font-size: var(--font-size-13); } - [name="system.price.denomination"] { + :is([name="system.price.denomination"], [name="system.weight.units"]) { border: none; } } diff --git a/module/applications/actor/vehicle-sheet.mjs b/module/applications/actor/vehicle-sheet.mjs index b843b38c4d..445121dcbb 100644 --- a/module/applications/actor/vehicle-sheet.mjs +++ b/module/applications/actor/vehicle-sheet.mjs @@ -48,11 +48,6 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { : CONFIG.DND5E.encumbrance.currencyPerWeight.imperial; totalWeight += totalCoins / currencyPerWeight; - // Vehicle weights are an order of magnitude greater. - totalWeight /= game.settings.get("dnd5e", "metricWeightUnits") - ? CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.metric - : CONFIG.DND5E.encumbrance.vehicleWeightMultiplier.imperial; - // Compute overall encumbrance const max = actorData.system.attributes.capacity.cargo; const pct = Math.clamped((totalWeight * 100) / max, 0, 100); @@ -210,12 +205,15 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { }, { label: game.i18n.localize("DND5E.Weight"), css: "item-weight", - property: "system.weight", + property: "system.weight.value", editable: "Number" }] } }; + const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[this.actor.type] ?? CONFIG.DND5E.encumbrance.baseUnits.default; + const units = game.settings.get("dnd5e", "metricWeightUnits") ? baseUnits.metric : baseUnits.imperial; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for ( const item of context.items ) { @@ -225,7 +223,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { // Handle cargo explicitly const isCargo = item.flags.dnd5e?.vehicleCargo === true; if ( isCargo ) { - totalWeight += item.system.totalWeight ?? 0; + totalWeight += item.system.totalWeightin?.(units) ?? 0; cargo.cargo.items.push(item); continue; } @@ -245,7 +243,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { else features.actions.items.push(item); break; default: - totalWeight += item.system.totalWeight ?? 0; + totalWeight += item.system.totalWeightIn?.(units) ?? 0; cargo.cargo.items.push(item); } } diff --git a/module/config.mjs b/module/config.mjs index a785c25122..4501cba134 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -1740,18 +1740,63 @@ preLocalize("distanceUnits"); /* -------------------------------------------- */ +/** + * Configuration data for a weight unit. + * + * @typedef {object} WeightUnitConfiguration + * @property {string} label Localized label for the unit. + * @property {string} abbreviation Localized abbreviation for the unit. + * @property {number} conversion Number that by which this unit should be multiplied to arrive at a standard value. + * @property {string} type Whether this is an "imperial" or "metric" unit. + */ + +/** + * The valid units for measurement of weight. + * @enum {WeightUnitConfiguration} + */ +DND5E.weightUnits = { + lb: { + label: "DND5E.WeightUnit.Pounds.Label", + abbreviation: "DND5E.WeightUnit.Pounds.Abbreviation", + conversion: 1, + type: "imperial" + }, + tn: { + label: "DND5E.WeightUnit.Tons.Label", + abbreviation: "DND5E.WeightUnit.Tons.Abbreviation", + conversion: 2000, + type: "imperial" + }, + kg: { + label: "DND5E.WeightUnit.Kilograms.Label", + abbreviation: "DND5E.WeightUnit.Kilograms.Abbreviation", + conversion: 2.2046, + type: "metric" + }, + mg: { + label: "DND5E.WeightUnit.Megagrams.Label", + abbreviation: "DND5E.WeightUnit.Megagrams.Abbreviation", + conversion: 2204.6, + type: "metric" + } +}; +preLocalize("weightUnits", { keys: ["label", "abbreviation"] }); + +/* -------------------------------------------- */ + /** * Encumbrance configuration data. * * @typedef {object} EncumbranceConfiguration * @property {Record} currencyPerWeight Pieces of currency that equal a base weight (lbs or kgs). - * @property {Record} effects Data used to create encumbrance-replated Active Effects. + * @property {Record} effects Data used to create encumbrance-related Active Effects. * @property {object} threshold Amount to multiply strength to get given capacity threshold. * @property {Record} threshold.encumbered * @property {Record} threshold.heavilyEncumbered * @property {Record} threshold.maximum * @property {Record} speedReduction Speed reduction caused by encumbered status. * @property {Record} vehicleWeightMultiplier Multiplier used to determine vehicle carrying capacity. + * @property {Record>} baseUnits Base units used to calculate carrying weight. */ /** @@ -1805,9 +1850,15 @@ DND5E.encumbrance = { m: 1.5 } }, - vehicleWeightMultiplier: { - imperial: 2000, // 2000 lbs in an imperial ton - metric: 1000 // 1000 kg in a metric ton + baseUnits: { + default: { + imperial: "lb", + metric: "kg" + }, + vehicle: { + imperial: "tn", + metric: "mg" + } } }; Object.defineProperty(DND5E.encumbrance, "strMultiplier", { diff --git a/module/data/item/container.mjs b/module/data/item/container.mjs index 6c249da62a..1666c67c58 100644 --- a/module/data/item/container.mjs +++ b/module/data/item/container.mjs @@ -199,7 +199,9 @@ export default class ContainerData extends ItemDataModel.mixin( */ get contentsWeight() { if ( this.parent?.pack && !this.parent?.isEmbedded ) return this.#contentsWeight(); - return this.contents.reduce((weight, item) => weight + item.system.totalWeight, this.currencyWeight); + return this.contents.reduce((weight, item) => + weight + item.system.totalWeightIn(this.weight.units), this.currencyWeight + ); } /** @@ -208,7 +210,9 @@ export default class ContainerData extends ItemDataModel.mixin( */ async #contentsWeight() { const contents = await this.contents; - return contents.reduce(async (weight, item) => await weight + await item.system.totalWeight, this.currencyWeight); + return contents.reduce(async (weight, item) => + await weight + await item.system.totalWeightIn(this.weight.units), this.currencyWeight + ); } /* -------------------------------------------- */ @@ -220,8 +224,8 @@ export default class ContainerData extends ItemDataModel.mixin( get totalWeight() { if ( this.properties.has("weightlessContents") ) return this.weight; const containedWeight = this.contentsWeight; - if ( containedWeight instanceof Promise ) return containedWeight.then(c => this.weight + c); - return this.weight + containedWeight; + if ( containedWeight instanceof Promise ) return containedWeight.then(c => this.weight.value + c); + return this.weight.value + containedWeight; } /* -------------------------------------------- */ diff --git a/module/data/item/templates/physical-item.mjs b/module/data/item/templates/physical-item.mjs index f8380b3cc6..96daadd2e3 100644 --- a/module/data/item/templates/physical-item.mjs +++ b/module/data/item/templates/physical-item.mjs @@ -1,3 +1,4 @@ +import { convertWeight } from "../../../utils.mjs"; import SystemDataModel from "../../abstract.mjs"; /** @@ -5,7 +6,9 @@ import SystemDataModel from "../../abstract.mjs"; * * @property {string} container Container within which this item is located. * @property {number} quantity Number of items in a stack. - * @property {number} weight Item's weight in pounds or kilograms (depending on system setting). + * @property {object} weight + * @property {number} weight.value Item's weight. + * @property {string} weight.units Units used to measure the weight. * @property {object} price * @property {number} price.value Item's cost in the specified denomination. * @property {string} price.denomination Currency denomination used to determine price. @@ -22,9 +25,15 @@ export default class PhysicalItemTemplate extends SystemDataModel { quantity: new foundry.data.fields.NumberField({ required: true, nullable: false, integer: true, initial: 1, min: 0, label: "DND5E.Quantity" }), - weight: new foundry.data.fields.NumberField({ - required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight" - }), + weight: new foundry.data.fields.SchemaField({ + value: new foundry.data.fields.NumberField({ + required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight" + }), + units: new foundry.data.fields.StringField({ + required: true, label: "DND5E.WeightUnit.Label", + initial: () => game.settings.get("dnd5e", "metricWeightUnits") ? "kg" : "lb" + }) + }, {label: "DND5E.Weight"}), price: new foundry.data.fields.SchemaField({ value: new foundry.data.fields.NumberField({ required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Price" @@ -66,7 +75,7 @@ export default class PhysicalItemTemplate extends SystemDataModel { * @type {number} */ get totalWeight() { - return this.quantity * this.weight; + return this.quantity * this.weight.value; } /* -------------------------------------------- */ @@ -111,12 +120,15 @@ export default class PhysicalItemTemplate extends SystemDataModel { /* -------------------------------------------- */ /** - * Convert null weights to 0. + * Migrate the item's weight from a single field to an object with units & convert null weights to 0. * @param {object} source The candidate source data from which the model will be constructed. */ static #migrateWeight(source) { - if ( !("weight" in source) ) return; - if ( (source.weight === null) || (source.weight === undefined) ) source.weight = 0; + if ( !("weight" in source) || (foundry.utils.getType(source.weight) === "Object") ) return; + source.weight = { + value: Number.isNumeric(source.weight) ? Number(source.weight) : 0, + units: game.settings.get("dnd5e", "metricWeightUnits") ? "kg" : "lb" + }; } /* -------------------------------------------- */ @@ -189,4 +201,17 @@ export default class PhysicalItemTemplate extends SystemDataModel { } return containers; } + + /* -------------------------------------------- */ + + /** + * Calculate the total weight and return it in specific units. + * @param {string} units Units in which the weight should be returned. + * @returns {number} + */ + totalWeightIn(units) { + const weight = this.totalWeight; + if ( weight instanceof Promise ) return weight.then(w => convertWeight(w, this.weight.units, units)); + return convertWeight(weight, this.weight.units, units); + } } diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index eb84787cc1..ca66dd6087 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -501,18 +501,20 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { _prepareEncumbrance() { const config = CONFIG.DND5E.encumbrance; const encumbrance = this.system.attributes.encumbrance ??= {}; - const units = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial"; + const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[this.type] ?? CONFIG.DND5E.encumbrance.baseUnits.default; + const unitSystem = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial"; + const units = unitSystem === "metric" ? baseUnits.metric : baseUnits.imperial; // Get the total weight from items let weight = this.items .filter(item => !item.container) - .reduce((weight, item) => weight + (item.system.totalWeight ?? 0), 0); + .reduce((weight, item) => weight + (item.system.totalWeightIn?.(units) ?? 0), 0); // [Optional] add Currency Weight (for non-transformed actors) const currency = this.system.currency; if ( game.settings.get("dnd5e", "currencyWeight") && currency ) { const numCoins = Object.values(currency).reduce((val, denom) => val + Math.max(denom, 0), 0); - const currencyPerWeight = config.currencyPerWeight[units]; + const currencyPerWeight = config.currencyPerWeight[unitSystem]; weight += numCoins / currencyPerWeight; } @@ -525,16 +527,16 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { const mod = sizeConfig?.capacityMultiplier ?? sizeConfig?.token ?? 1; const calculateThreshold = multiplier => this.type === "vehicle" - ? this.system.attributes.capacity.cargo * config.vehicleWeightMultiplier[units] + ? this.system.attributes.capacity.cargo : ((this.system.abilities.str?.value ?? 10) * multiplier * mod).toNearest(0.1); // Populate final Encumbrance values encumbrance.mod = mod; encumbrance.value = weight.toNearest(0.1); encumbrance.thresholds = { - encumbered: calculateThreshold(config.threshold.encumbered[units]), - heavilyEncumbered: calculateThreshold(config.threshold.heavilyEncumbered[units]), - maximum: calculateThreshold(config.threshold.maximum[units]) + encumbered: calculateThreshold(config.threshold.encumbered[unitSystem]), + heavilyEncumbered: calculateThreshold(config.threshold.heavilyEncumbered[unitSystem]), + maximum: calculateThreshold(config.threshold.maximum[unitSystem]) }; encumbrance.max = encumbrance.thresholds.maximum; encumbrance.stops = { diff --git a/module/utils.mjs b/module/utils.mjs index 9f5aa3949f..c308bdf04c 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -212,6 +212,27 @@ export function getSceneTargets() { return targets; } +/* -------------------------------------------- */ +/* Conversions */ +/* -------------------------------------------- */ + +/** + * Convert the provided weight to another unit. + * @param {number} value The weight being converted. + * @param {string} from The initial units. + * @param {string} to The final units. + * @returns {number} Weight in the specified units. + */ +export function convertWeight(value, from, to) { + if ( from === to ) return value; + const message = unit => `Weight unit ${unit} not defined in CONFIG.DND5E.weightUnits`; + if ( !CONFIG.DND5E.weightUnits[from] ) throw new Error(message(from)); + if ( !CONFIG.DND5E.weightUnits[to] ) throw new Error(message(to)); + return value + * CONFIG.DND5E.weightUnits[from].conversion + / CONFIG.DND5E.weightUnits[to].conversion; +} + /* -------------------------------------------- */ /* Validators */ /* -------------------------------------------- */ diff --git a/templates/items/parts/item-description.hbs b/templates/items/parts/item-description.hbs index 7e3cd2512f..6289d0f754 100644 --- a/templates/items/parts/item-description.hbs +++ b/templates/items/parts/item-description.hbs @@ -15,7 +15,10 @@
        - {{numberInput system.weight name="system.weight"}} + {{ numberInput system.weight.value name="system.weight.value" }} +
        diff --git a/templates/shared/inventory.hbs b/templates/shared/inventory.hbs index d8bd107bb1..d361b84e39 100644 --- a/templates/shared/inventory.hbs +++ b/templates/shared/inventory.hbs @@ -115,7 +115,8 @@
        {{#if ctx.totalWeight}}
        - {{ ctx.totalWeight }} {{ @root.weightUnit }} + {{ ctx.totalWeight }} + {{ lookup (lookup @root.config.weightUnits item.system.weight.units) "abbreviation" }}
        {{/if}}
        From 807e6383ebc704b6433f6f85e7015b941e74442b Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 12:52:13 -0700 Subject: [PATCH 106/199] [#3476] Switch to using @Embed in condition effect descriptions --- module/documents/active-effect.mjs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index fac2a5a9b7..4f12807d02 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -83,12 +83,8 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ /** @inheritdoc */ - static async _fromStatusEffect(statusId, effectData, options) { - if ( !("description" in effectData) && effectData.reference ) { - const page = await fromUuid(effectData.reference); - effectData.description = page?.text.content ?? ""; - } - delete effectData.reference; + static async _fromStatusEffect(statusId, { reference, ...effectData }, options) { + if ( !("description" in effectData) && reference ) effectData.description = `@Embed[${reference} inline]`; return super._fromStatusEffect?.(statusId, effectData, options) ?? new this(effectData, options); } From dddaf442edae6a14a5e07b79cd4d9b95535d154d Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 13:08:02 -0700 Subject: [PATCH 107/199] [#3071] Adjust tooltip direction to avoid overflow if possible --- module/tooltips.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/module/tooltips.mjs b/module/tooltips.mjs index 494127313b..b5e360e028 100644 --- a/module/tooltips.mjs +++ b/module/tooltips.mjs @@ -176,6 +176,23 @@ export default class Tooltips5e { * @protected */ _positionItemTooltip(direction=TooltipManager.TOOLTIP_DIRECTIONS.LEFT) { + const pos = this.tooltip.getBoundingClientRect(); + const dirs = TooltipManager.TOOLTIP_DIRECTIONS; + switch ( direction ) { + case dirs.UP: + if ( pos.y - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.DOWN; + break; + case dirs.DOWN: + if ( pos.y + this.tooltip.offsetHeight > window.innerHeight ) direction = dirs.UP; + break; + case dirs.LEFT: + if ( pos.x - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.RIGHT; + break; + case dirs.RIGHT: + if ( pos.x + this.tooltip.offsetWidth > window.innerWith ) direction = dirs.LEFT; + break; + } + game.tooltip._setAnchor(direction); // Set overflowing styles for item tooltips. From a7d255f42783101499bd3377e920e2ef782bb046 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Sun, 21 Apr 2024 10:45:30 -0700 Subject: [PATCH 108/199] Remove features whose deprecation period ends with `3.2` This extends the deprecation period for `getBaseItem` indexes another 2 versions because fixing this requires a compendium migration and there are still people who haven't done that. --- .../applications/actor/character-sheet-2.mjs | 1 - .../components/adopted-stylesheet-mixin.mjs | 3 +- module/config.mjs | 102 ------------------ module/data/fields.mjs | 2 +- module/data/item/container.mjs | 17 --- module/data/item/equipment.mjs | 15 --- module/data/item/spell.mjs | 18 ---- .../data/item/templates/activated-effect.mjs | 23 ---- .../data/item/templates/equippable-item.mjs | 17 --- module/data/item/templates/identifiable.mjs | 12 --- module/documents/active-effect.mjs | 57 ---------- module/documents/actor/actor.mjs | 24 ----- module/documents/actor/trait.mjs | 2 +- module/documents/token.mjs | 1 - 14 files changed, 4 insertions(+), 290 deletions(-) diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index 47a12a2ca2..ac8e234dd5 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1,6 +1,5 @@ import CharacterData from "../../data/actor/character.mjs"; import * as Trait from "../../documents/actor/trait.mjs"; -import { setTheme } from "../../settings.mjs"; import { formatNumber, simplifyBonus, staticID } from "../../utils.mjs"; import ContextMenu5e from "../context-menu.mjs"; import SheetConfig5e from "../sheet-config.mjs"; diff --git a/module/applications/components/adopted-stylesheet-mixin.mjs b/module/applications/components/adopted-stylesheet-mixin.mjs index 0cd7ebbc9f..07f67434ca 100644 --- a/module/applications/components/adopted-stylesheet-mixin.mjs +++ b/module/applications/components/adopted-stylesheet-mixin.mjs @@ -30,6 +30,7 @@ export default function AdoptedStyleSheetMixin(Base) { /** * Retrieves the cached stylesheet, or generates a new one. + * @returns {CSSStyleSheet} * @protected */ _getStyleSheet() { @@ -50,5 +51,5 @@ export default function AdoptedStyleSheetMixin(Base) { * @abstract */ _adoptStyleSheet(sheet) {} - } + }; } diff --git a/module/config.mjs b/module/config.mjs index a785c25122..0a1ec280fa 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -642,25 +642,6 @@ DND5E.actorSizes = { } }; preLocalize("actorSizes", { keys: ["label", "abbreviation"] }); -patchConfig("actorSizes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); - -/** - * Default token image size for the values of `DND5E.actorSizes`. - * @enum {number} - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - */ -Object.defineProperty(DND5E, "tokenSizes", { - get() { - foundry.utils.logCompatibilityWarning( - "DND5E.tokenSizes has been deprecated and is now accessible through the .token property on DND5E.actorSizes.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return Object.entries(DND5E.actorSizes).reduce((obj, [k, v]) => { - obj[k] = v.token ?? 1; - return obj; - }, {}); - } -}); /* -------------------------------------------- */ /* Canvas */ @@ -816,7 +797,6 @@ DND5E.creatureTypes = { } }; preLocalize("creatureTypes", { keys: ["label", "plural"], sort: true }); -patchConfig("creatureTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); /* -------------------------------------------- */ @@ -869,19 +849,6 @@ preLocalize("itemRarity"); /* -------------------------------------------- */ -/** - * The limited use periods that support a recovery formula. - * @deprecated since DnD5e 3.1, available until DnD5e 3.3 - * @enum {string} - */ -DND5E.limitedUseFormulaPeriods = { - charges: "DND5E.Charges", - dawn: "DND5E.Dawn", - dusk: "DND5E.Dusk" -}; - -/* -------------------------------------------- */ - /** * Configuration data for limited use periods. * @@ -1147,7 +1114,6 @@ DND5E.consumableTypes = { label: "DND5E.ConsumableTrinket" } }; -patchConfig("consumableTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); preLocalize("consumableTypes", { key: "label", sort: true }); preLocalize("consumableTypes.ammo.subtypes", { sort: true }); preLocalize("consumableTypes.poison.subtypes", { sort: true }); @@ -1540,20 +1506,6 @@ preLocalize("currencies", { keys: ["label", "abbreviation"] }); /* Damage Types */ /* -------------------------------------------- */ -/** - * Types of damage that are considered physical. - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - * @enum {string} - */ -DND5E.physicalDamageTypes = { - bludgeoning: "DND5E.DamageBludgeoning", - piercing: "DND5E.DamagePiercing", - slashing: "DND5E.DamageSlashing" -}; -preLocalize("physicalDamageTypes", { sort: true }); - -/* -------------------------------------------- */ - /** * Configuration data for damage types. * @@ -1652,7 +1604,6 @@ DND5E.damageTypes = { color: new Color(0x708090) } }; -patchConfig("damageTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); preLocalize("damageTypes", { keys: ["label"], sort: true }); /* -------------------------------------------- */ @@ -1675,7 +1626,6 @@ DND5E.healingTypes = { color: new Color(0x4B66DE) } }; -patchConfig("healingTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); preLocalize("healingTypes", { keys: ["label"] }); /* -------------------------------------------- */ @@ -1810,15 +1760,6 @@ DND5E.encumbrance = { metric: 1000 // 1000 kg in a metric ton } }; -Object.defineProperty(DND5E.encumbrance, "strMultiplier", { - get() { - foundry.utils.logCompatibilityWarning( - "`DND5E.encumbrance.strMultiplier` has been moved to `DND5E.encumbrance.threshold.maximum`.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return this.threshold.maximum; - } -}); preLocalize("encumbrance.effects", { key: "name" }); /* -------------------------------------------- */ @@ -2315,7 +2256,6 @@ DND5E.spellSchools = { } }; preLocalize("spellSchools", { key: "label", sort: true }); -patchConfig("spellSchools", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); /* -------------------------------------------- */ @@ -2372,47 +2312,6 @@ preLocalize("weaponTypes"); /* -------------------------------------------- */ -/** - * A subset of weapon properties that determine the physical characteristics of the weapon. - * These properties are used for determining physical resistance bypasses. - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - * @enum {string} - */ -DND5E.physicalWeaponProperties = { - ada: "DND5E.WeaponPropertiesAda", - mgc: "DND5E.WeaponPropertiesMgc", - sil: "DND5E.WeaponPropertiesSil" -}; -preLocalize("physicalWeaponProperties", { sort: true }); - -/* -------------------------------------------- */ - -/** - * The set of weapon property flags which can exist on a weapon. - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - * @enum {string} - */ -DND5E.weaponProperties = { - ...DND5E.physicalWeaponProperties, - amm: "DND5E.WeaponPropertiesAmm", - fin: "DND5E.WeaponPropertiesFin", - fir: "DND5E.WeaponPropertiesFir", - foc: "DND5E.WeaponPropertiesFoc", - hvy: "DND5E.WeaponPropertiesHvy", - lgt: "DND5E.WeaponPropertiesLgt", - lod: "DND5E.WeaponPropertiesLod", - rch: "DND5E.WeaponPropertiesRch", - rel: "DND5E.WeaponPropertiesRel", - ret: "DND5E.WeaponPropertiesRet", - spc: "DND5E.WeaponPropertiesSpc", - thr: "DND5E.WeaponPropertiesThr", - two: "DND5E.WeaponPropertiesTwo", - ver: "DND5E.WeaponPropertiesVer" -}; -preLocalize("weaponProperties", { sort: true }); - -/* -------------------------------------------- */ - /** * Compendium packs used for localized items. * @enum {string} @@ -2708,7 +2607,6 @@ DND5E.conditionTypes = { } }; preLocalize("conditionTypes", { key: "label", sort: true }); -patchConfig("conditionTypes", "label", { since: "DnD5e 3.0", until: "DnD5e 3.2" }); /* -------------------------------------------- */ diff --git a/module/data/fields.mjs b/module/data/fields.mjs index 5a8feb2db3..8bcf3650e7 100644 --- a/module/data/fields.mjs +++ b/module/data/fields.mjs @@ -359,7 +359,7 @@ export class MappingField extends foundry.data.fields.ObjectField { _validateType(value, options={}) { if ( foundry.utils.getType(value) !== "Object" ) throw new Error("must be an Object"); const errors = this._validateValues(value, options); - if ( !foundry.utils.isEmpty(errors) ) throw new foundry.data.fields.ModelValidationError(errors); + if ( !foundry.utils.isEmpty(errors) ) throw new foundry.data.validation.DataModelValidationError(errors); } /* -------------------------------------------- */ diff --git a/module/data/item/container.mjs b/module/data/item/container.mjs index 6c249da62a..de82b9a19e 100644 --- a/module/data/item/container.mjs +++ b/module/data/item/container.mjs @@ -82,23 +82,6 @@ export default class ContainerData extends ItemDataModel.mixin( /* Data Preparation */ /* -------------------------------------------- */ - /** @inheritdoc */ - prepareDerivedData() { - const system = this; - Object.defineProperty(this.capacity, "weightless", { - get() { - foundry.utils.logCompatibilityWarning( - "The `system.capacity.weightless` value on containers has migrated to the 'weightlessContents' property.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return system.properties.has("weightlessContents"); - }, - configurable: true - }); - } - - /* -------------------------------------------- */ - /** @inheritDoc */ async getFavoriteData() { const data = super.getFavoriteData(); diff --git a/module/data/item/equipment.mjs b/module/data/item/equipment.mjs index 3c530557fd..311d155e8d 100644 --- a/module/data/item/equipment.mjs +++ b/module/data/item/equipment.mjs @@ -239,19 +239,4 @@ export default class EquipmentData extends ItemDataModel.mixin( const isProficient = (itemProf === true) || actorProfs.has(itemProf) || actorProfs.has(this.type.baseItem); return Number(isProficient); } - - /* -------------------------------------------- */ - - /** - * Does this armor impose disadvantage on stealth checks? - * @type {boolean} - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - */ - get stealth() { - foundry.utils.logCompatibilityWarning( - "The `system.stealth` value on equipment has migrated to the 'stealthDisadvantage' property.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return this.properties.has("stealthDisadvantage"); - } } diff --git a/module/data/item/spell.mjs b/module/data/item/spell.mjs index 331ca2ec4e..edff05e590 100644 --- a/module/data/item/spell.mjs +++ b/module/data/item/spell.mjs @@ -188,22 +188,4 @@ export default class SpellData extends ItemDataModel.mixin( get proficiencyMultiplier() { return 1; } - - /* -------------------------------------------- */ - - /** - * Provide a backwards compatible getter for accessing `components`. - * @deprecated since v3.0. - * @type {object} - */ - get components() { - foundry.utils.logCompatibilityWarning( - "The `system.components` property has been deprecated in favor of a standardized `system.properties` property.", - { since: "DnD5e 3.0", until: "DnD5e 3.2", once: true } - ); - return this.properties.reduce((acc, p) => { - acc[p] = true; - return acc; - }, {}); - } } diff --git a/module/data/item/templates/activated-effect.mjs b/module/data/item/templates/activated-effect.mjs index 30e4247808..1154c9e736 100644 --- a/module/data/item/templates/activated-effect.mjs +++ b/module/data/item/templates/activated-effect.mjs @@ -338,27 +338,4 @@ export default class ActivatedEffectTemplate extends SystemDataModel { get isActive() { return !!this.activation.type; } - - /* -------------------------------------------- */ - /* Deprecations */ - /* -------------------------------------------- */ - - /** - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - * @ignore - */ - get activatedEffectChatProperties() { - foundry.utils.logCompatibilityWarning( - "ActivatedEffectTemplate#activatedEffectChatProperties is deprecated. " - + "Please use ActivatedEffectTemplate#activatedEffectCardProperties.", - { since: "DnD5e 3.0", until: "DnD5e 3.2", once: true } - ); - return [ - this.parent.labels.activation + (this.activation.condition ? ` (${this.activation.condition})` : ""), - this.parent.labels.target, - this.parent.labels.range, - this.parent.labels.duration - ]; - } - } diff --git a/module/data/item/templates/equippable-item.mjs b/module/data/item/templates/equippable-item.mjs index 0760d18367..8f264b80f5 100644 --- a/module/data/item/templates/equippable-item.mjs +++ b/module/data/item/templates/equippable-item.mjs @@ -78,21 +78,4 @@ export default class EquippableItemTemplate extends SystemDataModel { const attunement = this.attunement !== CONFIG.DND5E.attunementTypes.REQUIRED; return attunement && this.properties.has("mgc") && this.validProperties.has("mgc"); } - - /* -------------------------------------------- */ - /* Deprecations */ - /* -------------------------------------------- */ - - /** - * @deprecated since DnD5e 3.0, available until DnD5e 3.2 - * @ignore - */ - get equippableItemChatProperties() { - foundry.utils.logCompatibilityWarning( - "EquippableItemTemplate#equippableItemChatProperties is deprecated. " - + "Please use EquippableItemTemplate#equippableItemCardProperties.", - { since: "DnD5e 3.0", until: "DnD5e 3.2", once: true } - ); - return this.equippableItemCardProperties; - } } diff --git a/module/data/item/templates/identifiable.mjs b/module/data/item/templates/identifiable.mjs index fc65d455aa..5edb8c3cbd 100644 --- a/module/data/item/templates/identifiable.mjs +++ b/module/data/item/templates/identifiable.mjs @@ -57,18 +57,6 @@ export default class IdentifiableTemplate extends SystemDataModel { if ( !this.identified && this.unidentified.name ) { this.parent.name = this.unidentified.name; } - - const description = this.unidentified.description ?? null; - Object.defineProperty(this.description, "unidentified", { - get() { - foundry.utils.logCompatibilityWarning( - "Item's unidentified description has moved to `system.unidentified.description`.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return description; - }, - configurable: true - }); } /* -------------------------------------------- */ diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 4f12807d02..de73ae0868 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -1,4 +1,3 @@ -import EffectsElement from "../applications/components/effects.mjs"; import { FormulaField } from "../data/fields.mjs"; import { staticID } from "../utils.mjs"; @@ -624,60 +623,4 @@ export default class ActiveEffect5e extends ActiveEffect { const delta = current.symmetricDifference(source); for ( const choice of delta ) overrides.push(`${prefix}.${choice}`); } - - /* -------------------------------------------- */ - /* Deprecations */ - /* -------------------------------------------- */ - - /** - * Manage Active Effect instances through the Actor Sheet via effect control buttons. - * @param {MouseEvent} event The left-click event on the effect control - * @param {Actor5e|Item5e} owner The owning document which manages this effect - * @returns {Promise|null} Promise that resolves when the changes are complete. - * @deprecated since 3.0, targeted for removal in 3.2 - */ - static onManageActiveEffect(event, owner) { - foundry.utils.logCompatibilityWarning( - "ActiveEffects5e#onManageActiveEffect has been deprecated in favor of the new dnd5e-effects element.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - event.preventDefault(); - const a = event.currentTarget; - const li = a.closest("li"); - if ( li.dataset.parentId ) owner = owner.items.get(li.dataset.parentId); - const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null; - switch ( a.dataset.action ) { - case "create": - const isActor = owner instanceof Actor; - return owner.createEmbeddedDocuments("ActiveEffect", [{ - name: isActor ? game.i18n.localize("DND5E.EffectNew") : owner.name, - icon: isActor ? "icons/svg/aura.svg" : owner.img, - origin: owner.uuid, - "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, - disabled: li.dataset.effectType === "inactive" - }]); - case "edit": - return effect.sheet.render(true); - case "delete": - return effect.deleteDialog(); - case "toggle": - return effect.update({disabled: !effect.disabled}); - } - } - - /* --------------------------------------------- */ - - /** - * Prepare the data structure for Active Effects which are currently applied to an Actor or Item. - * @param {ActiveEffect5e[]} effects The array of Active Effect instances to prepare sheet data for - * @returns {object} Data for rendering - * @deprecated since 3.0, targeted for removal in 3.2 - */ - static prepareActiveEffectCategories(effects) { - foundry.utils.logCompatibilityWarning( - "ActiveEffects5e#prepareActiveEffectCategories has been deprecated in favor of EffectsElement#prepareCategories.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return EffectsElement.prepareCategories(effects); - } } diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index 64145435b9..d1ed37a1fc 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -917,14 +917,6 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { const hp = this.system.attributes.hp; if ( !hp ) return this; // Group actors don't have HP at the moment - if ( foundry.utils.getType(options) !== "Object" ) { - foundry.utils.logCompatibilityWarning( - "Actor5e.applyDamage now takes an options object as its second parameter with `multiplier` as an parameter.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - options = { multiplier: options }; - } - if ( Number.isNumeric(damages) ) { damages = [{ value: damages }]; options.ignore ??= true; @@ -2776,22 +2768,6 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /* Conversion & Transformation */ /* -------------------------------------------- */ - /** - * Convert all carried currency to the highest possible denomination using configured conversion rates. - * See CONFIG.DND5E.currencies for configuration. - * @returns {Promise} - * @deprecated since DnD5e 3.0, targeted for removal in DnD5e 3.2. - */ - convertCurrency() { - foundry.utils.logCompatibilityWarning( - "Actor5e.convertCurrency has been moved to CurrencyManager.convertCurrency.", - { since: "DnD5e 3.0", until: "DnD5e 3.2" } - ); - return CurrencyManager.convertCurrency(this); - } - - /* -------------------------------------------- */ - /** * Fetch stats from the original actor for data preparation. * @returns {{ originalSaves: object|null, originalSkills: object|null }} diff --git a/module/documents/actor/trait.mjs b/module/documents/actor/trait.mjs index 58aadf5754..6e234553f4 100644 --- a/module/documents/actor/trait.mjs +++ b/module/documents/actor/trait.mjs @@ -274,7 +274,7 @@ export function getBaseItem(identifier, { indexOnly=false, fullItem=false }={}) foundry.utils.setProperty(entry, "system.type.value", val); foundry.utils.logCompatibilityWarning( `The '${field}' property has been deprecated in favor of a standardized \`system.type.value\` property.`, - { since: "DnD5e 3.0", until: "DnD5e 3.2", once: true } + { since: "DnD5e 3.0", until: "DnD5e 3.4", once: true } ); } } diff --git a/module/documents/token.mjs b/module/documents/token.mjs index 82a1d94693..6971955b58 100644 --- a/module/documents/token.mjs +++ b/module/documents/token.mjs @@ -1,5 +1,4 @@ import TokenSystemFlags from "../data/token/token-system-flags.mjs"; -import { staticID } from "../utils.mjs"; import SystemFlagsMixin from "./mixins/flags.mjs"; /** From b5f374065f9f7be93ef18288fe963116a5554abc Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 13:17:07 -0700 Subject: [PATCH 109/199] Add back in limitedUseFormulaPeriods --- module/config.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/module/config.mjs b/module/config.mjs index 0a1ec280fa..62fd2a5fc8 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -849,6 +849,19 @@ preLocalize("itemRarity"); /* -------------------------------------------- */ +/** + * The limited use periods that support a recovery formula. + * @deprecated since DnD5e 3.1, available until DnD5e 3.3 + * @enum {string} + */ +DND5E.limitedUseFormulaPeriods = { + charges: "DND5E.Charges", + dawn: "DND5E.Dawn", + dusk: "DND5E.Dusk" +}; + +/* -------------------------------------------- */ + /** * Configuration data for limited use periods. * From 6eb47226e37817b9f6a6cc3800230673a1fc9767 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 13:36:27 -0700 Subject: [PATCH 110/199] [#3242] Setup Giant Insect, Create Undead, & Dancing Lights summons --- .../_source/monsters/summons/arcane-eye.json | 8 +- .../_source/monsters/summons/arcane-hand.json | 8 +- .../monsters/summons/arcane-sword.json | 8 +- .../summons/dancing-lights-medium.json | 600 ++++++++++++++++++ .../monsters/summons/dancing-lights-tiny.json | 600 ++++++++++++++++++ packs/_source/monsters/summons/mage-hand.json | 8 +- .../monsters/summons/unseen-servant.json | 8 +- .../spells/4th-level/giant-insect.json | 61 +- .../spells/6th-level/create-undead.json | 61 +- .../spells/cantrip/dancing-lights.json | 41 +- 10 files changed, 1371 insertions(+), 32 deletions(-) create mode 100644 packs/_source/monsters/summons/dancing-lights-medium.json create mode 100644 packs/_source/monsters/summons/dancing-lights-tiny.json diff --git a/packs/_source/monsters/summons/arcane-eye.json b/packs/_source/monsters/summons/arcane-eye.json index fee1d242dd..8e1f7dcce8 100644 --- a/packs/_source/monsters/summons/arcane-eye.json +++ b/packs/_source/monsters/summons/arcane-eye.json @@ -3,7 +3,7 @@ "name": "Arcane Eye", "type": "npc", "_id": "tuHyePQ9GazMTyc0", - "img": "", + "img": null, "system": { "currency": { "pp": 0, @@ -507,7 +507,7 @@ "appendNumber": false, "prependAdjective": false, "texture": { - "src": "", + "src": null, "scaleX": 1, "scaleY": 1, "offsetX": 0, @@ -612,7 +612,7 @@ "_key": "!actors.effects!tuHyePQ9GazMTyc0.VUI3PxyI4LH2J8tm" } ], - "sort": 0, + "sort": 500000, "ownership": { "default": 0 }, @@ -622,7 +622,7 @@ "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1712086234744, - "modifiedTime": 1712086336550, + "modifiedTime": 1714077048621, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!tuHyePQ9GazMTyc0" diff --git a/packs/_source/monsters/summons/arcane-hand.json b/packs/_source/monsters/summons/arcane-hand.json index 66e4a96047..f14926fadb 100644 --- a/packs/_source/monsters/summons/arcane-hand.json +++ b/packs/_source/monsters/summons/arcane-hand.json @@ -3,7 +3,7 @@ "name": "Arcane Hand", "type": "npc", "_id": "iHj5Tkm6HRgXuaWP", - "img": "", + "img": null, "system": { "currency": { "pp": 0, @@ -507,7 +507,7 @@ "appendNumber": false, "prependAdjective": false, "texture": { - "src": "", + "src": null, "scaleX": 1, "scaleY": 1, "offsetX": 0, @@ -993,7 +993,7 @@ } ], "effects": [], - "sort": 0, + "sort": 400000, "ownership": { "default": 0 }, @@ -1003,7 +1003,7 @@ "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1712086409403, - "modifiedTime": 1712086936053, + "modifiedTime": 1714077048621, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!iHj5Tkm6HRgXuaWP" diff --git a/packs/_source/monsters/summons/arcane-sword.json b/packs/_source/monsters/summons/arcane-sword.json index 0e75c51298..9034f78292 100644 --- a/packs/_source/monsters/summons/arcane-sword.json +++ b/packs/_source/monsters/summons/arcane-sword.json @@ -3,7 +3,7 @@ "name": "Arcane Sword", "type": "npc", "_id": "Tac7eq0AXJco0nml", - "img": "", + "img": null, "system": { "currency": { "pp": 0, @@ -507,7 +507,7 @@ "appendNumber": false, "prependAdjective": false, "texture": { - "src": "", + "src": null, "scaleX": 1, "scaleY": 1, "offsetX": 0, @@ -688,7 +688,7 @@ } ], "effects": [], - "sort": 0, + "sort": 200000, "ownership": { "default": 0 }, @@ -698,7 +698,7 @@ "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1712086414546, - "modifiedTime": 1712087113879, + "modifiedTime": 1714077048621, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!Tac7eq0AXJco0nml" diff --git a/packs/_source/monsters/summons/dancing-lights-medium.json b/packs/_source/monsters/summons/dancing-lights-medium.json new file mode 100644 index 0000000000..b0bc051de4 --- /dev/null +++ b/packs/_source/monsters/summons/dancing-lights-medium.json @@ -0,0 +1,600 @@ +{ + "folder": "89daM0oezivxN75Y", + "name": "Dancing Lights (medium)", + "type": "npc", + "img": "", + "system": { + "currency": { + "pp": 0, + "gp": 0, + "ep": 0, + "sp": 0, + "cp": 0 + }, + "abilities": { + "str": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "dex": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "con": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "int": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "wis": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "cha": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + } + }, + "bonuses": { + "mwak": { + "attack": "", + "damage": "" + }, + "rwak": { + "attack": "", + "damage": "" + }, + "msak": { + "attack": "", + "damage": "" + }, + "rsak": { + "attack": "", + "damage": "" + }, + "abilities": { + "check": "", + "save": "", + "skill": "" + }, + "spell": { + "dc": "" + } + }, + "skills": { + "acr": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ani": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "arc": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ath": { + "ability": "str", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "dec": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "his": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ins": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "itm": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "inv": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "med": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "nat": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "prc": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "prf": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "per": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "rel": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "slt": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ste": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "sur": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + } + }, + "tools": {}, + "spells": { + "spell1": { + "value": 0, + "override": null + }, + "spell2": { + "value": 0, + "override": null + }, + "spell3": { + "value": 0, + "override": null + }, + "spell4": { + "value": 0, + "override": null + }, + "spell5": { + "value": 0, + "override": null + }, + "spell6": { + "value": 0, + "override": null + }, + "spell7": { + "value": 0, + "override": null + }, + "spell8": { + "value": 0, + "override": null + }, + "spell9": { + "value": 0, + "override": null + }, + "pact": { + "value": 0, + "override": null + } + }, + "attributes": { + "init": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "bonus": "" + }, + "movement": { + "burrow": null, + "climb": null, + "fly": 60, + "swim": null, + "walk": null, + "units": null, + "hover": true + }, + "attunement": { + "max": 3 + }, + "senses": { + "darkvision": null, + "blindsight": null, + "tremorsense": null, + "truesight": null, + "units": null, + "special": "" + }, + "spellcasting": "int", + "exhaustion": 0, + "concentration": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "bonuses": { + "save": "" + }, + "limit": 1 + }, + "ac": { + "flat": 0, + "calc": "flat" + }, + "hd": { + "spent": 0 + }, + "hp": { + "value": 0, + "max": 0, + "temp": 0, + "tempmax": 0, + "formula": "" + }, + "death": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "success": 0, + "failure": 0 + } + }, + "details": { + "biography": { + "value": "", + "public": "" + }, + "alignment": "", + "race": null, + "type": {}, + "environment": "", + "cr": 0, + "spellLevel": 0, + "source": {} + }, + "resources": { + "legact": { + "value": 0, + "max": 0 + }, + "legres": { + "value": 0, + "max": 0 + }, + "lair": { + "value": false, + "initiative": null + } + }, + "traits": { + "size": "med", + "di": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dr": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dv": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dm": { + "amount": {}, + "bypasses": [] + }, + "ci": { + "value": [], + "custom": "" + }, + "languages": { + "value": [], + "custom": "" + } + } + }, + "prototypeToken": { + "name": "Dancing Lights", + "displayName": 0, + "actorLink": false, + "appendNumber": false, + "prependAdjective": false, + "texture": { + "src": "", + "scaleX": 1, + "scaleY": 1, + "offsetX": 0, + "offsetY": 0, + "rotation": 0, + "tint": null + }, + "width": 1, + "height": 1, + "lockRotation": false, + "rotation": 0, + "alpha": 1, + "disposition": -1, + "displayBars": 0, + "bar1": { + "attribute": "attributes.hp" + }, + "bar2": { + "attribute": null + }, + "light": { + "alpha": 0.5, + "angle": 360, + "bright": 0, + "coloration": 1, + "dim": 10, + "attenuation": 0.5, + "luminosity": 0.5, + "saturation": 0, + "contrast": 0, + "shadows": 0, + "animation": { + "type": null, + "speed": 5, + "intensity": 5, + "reverse": false + }, + "darkness": { + "min": 0, + "max": 1 + }, + "color": null + }, + "sight": { + "enabled": false, + "range": 0, + "angle": 360, + "visionMode": "basic", + "attenuation": 0.1, + "brightness": 0, + "saturation": 0, + "contrast": 0, + "color": null + }, + "detectionModes": [], + "flags": { + "dnd5e": { + "tokenRing": { + "enabled": false, + "textures": { + "subject": "" + }, + "scaleCorrection": 1, + "colors": { + "ring": "", + "background": "" + }, + "effects": 1 + } + } + }, + "randomImg": false + }, + "items": [], + "effects": [], + "ownership": { + "default": 0 + }, + "flags": {}, + "_stats": { + "systemId": "dnd5e", + "systemVersion": "3.2.0", + "coreVersion": "11.315", + "createdTime": 1714076980309, + "modifiedTime": 1714077051730, + "lastModifiedBy": "dnd5ebuilder0000" + }, + "_id": "JNmHqTwkWayvkH37", + "sort": 300000, + "_key": "!actors!JNmHqTwkWayvkH37" +} diff --git a/packs/_source/monsters/summons/dancing-lights-tiny.json b/packs/_source/monsters/summons/dancing-lights-tiny.json new file mode 100644 index 0000000000..8e99cc1cc7 --- /dev/null +++ b/packs/_source/monsters/summons/dancing-lights-tiny.json @@ -0,0 +1,600 @@ +{ + "folder": "89daM0oezivxN75Y", + "name": "Dancing Lights (tiny)", + "type": "npc", + "_id": "UrXp9O9N7DvdH9nL", + "img": "", + "system": { + "currency": { + "pp": 0, + "gp": 0, + "ep": 0, + "sp": 0, + "cp": 0 + }, + "abilities": { + "str": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "dex": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "con": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "int": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "wis": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + }, + "cha": { + "value": 0, + "proficient": 0, + "max": null, + "bonuses": { + "check": "", + "save": "" + } + } + }, + "bonuses": { + "mwak": { + "attack": "", + "damage": "" + }, + "rwak": { + "attack": "", + "damage": "" + }, + "msak": { + "attack": "", + "damage": "" + }, + "rsak": { + "attack": "", + "damage": "" + }, + "abilities": { + "check": "", + "save": "", + "skill": "" + }, + "spell": { + "dc": "" + } + }, + "skills": { + "acr": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ani": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "arc": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ath": { + "ability": "str", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "dec": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "his": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ins": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "itm": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "inv": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "med": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "nat": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "prc": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "prf": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "per": { + "ability": "cha", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "rel": { + "ability": "int", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "slt": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "ste": { + "ability": "dex", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + }, + "sur": { + "ability": "wis", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "value": 0, + "bonuses": { + "check": "", + "passive": "" + } + } + }, + "tools": {}, + "spells": { + "spell1": { + "value": 0, + "override": null + }, + "spell2": { + "value": 0, + "override": null + }, + "spell3": { + "value": 0, + "override": null + }, + "spell4": { + "value": 0, + "override": null + }, + "spell5": { + "value": 0, + "override": null + }, + "spell6": { + "value": 0, + "override": null + }, + "spell7": { + "value": 0, + "override": null + }, + "spell8": { + "value": 0, + "override": null + }, + "spell9": { + "value": 0, + "override": null + }, + "pact": { + "value": 0, + "override": null + } + }, + "attributes": { + "init": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "bonus": "" + }, + "movement": { + "burrow": null, + "climb": null, + "fly": 60, + "swim": null, + "walk": null, + "units": null, + "hover": true + }, + "attunement": { + "max": 3 + }, + "senses": { + "darkvision": null, + "blindsight": null, + "tremorsense": null, + "truesight": null, + "units": null, + "special": "" + }, + "spellcasting": "int", + "exhaustion": 0, + "concentration": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "bonuses": { + "save": "" + }, + "limit": 1 + }, + "ac": { + "flat": 0, + "calc": "flat" + }, + "hd": { + "spent": 0 + }, + "hp": { + "value": 0, + "max": 0, + "temp": 0, + "tempmax": 0, + "formula": "" + }, + "death": { + "ability": "", + "roll": { + "min": null, + "max": null, + "mode": 0 + }, + "success": 0, + "failure": 0 + } + }, + "details": { + "biography": { + "value": "", + "public": "" + }, + "alignment": "", + "race": null, + "type": {}, + "environment": "", + "cr": 0, + "spellLevel": 0, + "source": {} + }, + "resources": { + "legact": { + "value": 0, + "max": 0 + }, + "legres": { + "value": 0, + "max": 0 + }, + "lair": { + "value": false, + "initiative": null + } + }, + "traits": { + "size": "tiny", + "di": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dr": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dv": { + "bypasses": [], + "value": [], + "custom": "" + }, + "dm": { + "amount": {}, + "bypasses": [] + }, + "ci": { + "value": [], + "custom": "" + }, + "languages": { + "value": [], + "custom": "" + } + } + }, + "prototypeToken": { + "name": "Dancing Lights", + "displayName": 0, + "actorLink": false, + "appendNumber": false, + "prependAdjective": false, + "texture": { + "src": "", + "scaleX": 1, + "scaleY": 1, + "offsetX": 0, + "offsetY": 0, + "rotation": 0, + "tint": null + }, + "width": 0.5, + "height": 0.5, + "lockRotation": false, + "rotation": 0, + "alpha": 1, + "disposition": -1, + "displayBars": 0, + "bar1": { + "attribute": "attributes.hp" + }, + "bar2": { + "attribute": null + }, + "light": { + "alpha": 0.5, + "angle": 360, + "bright": 0, + "coloration": 1, + "dim": 10, + "attenuation": 0.5, + "luminosity": 0.5, + "saturation": 0, + "contrast": 0, + "shadows": 0, + "animation": { + "type": null, + "speed": 5, + "intensity": 5, + "reverse": false + }, + "darkness": { + "min": 0, + "max": 1 + }, + "color": null + }, + "sight": { + "enabled": false, + "range": 0, + "angle": 360, + "visionMode": "basic", + "attenuation": 0.1, + "brightness": 0, + "saturation": 0, + "contrast": 0, + "color": null + }, + "detectionModes": [], + "flags": { + "dnd5e": { + "tokenRing": { + "enabled": false, + "textures": { + "subject": "" + }, + "scaleCorrection": 1, + "colors": { + "ring": "", + "background": "" + }, + "effects": 1 + } + } + }, + "randomImg": false + }, + "items": [], + "effects": [], + "sort": 700000, + "ownership": { + "default": 0 + }, + "flags": {}, + "_stats": { + "systemId": "dnd5e", + "systemVersion": "3.2.0", + "coreVersion": "11.315", + "createdTime": 1714076980309, + "modifiedTime": 1714077054767, + "lastModifiedBy": "dnd5ebuilder0000" + }, + "_key": "!actors!UrXp9O9N7DvdH9nL" +} diff --git a/packs/_source/monsters/summons/mage-hand.json b/packs/_source/monsters/summons/mage-hand.json index 5fb5673c3e..ec32de6232 100644 --- a/packs/_source/monsters/summons/mage-hand.json +++ b/packs/_source/monsters/summons/mage-hand.json @@ -3,7 +3,7 @@ "name": "Mage Hand", "type": "npc", "_id": "zwT2WjWo7cTm2631", - "img": "", + "img": null, "system": { "currency": { "pp": 0, @@ -507,7 +507,7 @@ "appendNumber": false, "prependAdjective": false, "texture": { - "src": "", + "src": null, "scaleX": 1, "scaleY": 1, "offsetX": 0, @@ -565,7 +565,7 @@ }, "items": [], "effects": [], - "sort": 0, + "sort": 600000, "ownership": { "default": 0 }, @@ -575,7 +575,7 @@ "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1712088725222, - "modifiedTime": 1712088790215, + "modifiedTime": 1714077048621, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!zwT2WjWo7cTm2631" diff --git a/packs/_source/monsters/summons/unseen-servant.json b/packs/_source/monsters/summons/unseen-servant.json index bf59c3fa64..83904dd9a7 100644 --- a/packs/_source/monsters/summons/unseen-servant.json +++ b/packs/_source/monsters/summons/unseen-servant.json @@ -3,7 +3,7 @@ "name": "Unseen Servant", "type": "npc", "_id": "BTQz2q4JJVQn8W5W", - "img": "", + "img": null, "system": { "currency": { "pp": 0, @@ -507,7 +507,7 @@ "appendNumber": false, "prependAdjective": false, "texture": { - "src": "", + "src": null, "scaleX": 1, "scaleY": 1, "offsetX": 0, @@ -594,7 +594,7 @@ "_key": "!actors.effects!BTQz2q4JJVQn8W5W.huO2iAgLJJHGXWix" } ], - "sort": 0, + "sort": 100000, "ownership": { "default": 0 }, @@ -604,7 +604,7 @@ "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1712088776020, - "modifiedTime": 1712088831735, + "modifiedTime": 1714077048621, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!actors!BTQz2q4JJVQn8W5W" diff --git a/packs/_source/spells/4th-level/giant-insect.json b/packs/_source/spells/4th-level/giant-insect.json index d326c6d786..9206f56c54 100644 --- a/packs/_source/spells/4th-level/giant-insect.json +++ b/packs/_source/spells/4th-level/giant-insect.json @@ -52,7 +52,7 @@ "scale": false }, "ability": "", - "actionType": "util", + "actionType": "summ", "attackBonus": "", "chatFlavor": "", "critical": { @@ -90,7 +90,60 @@ "somatic", "concentration" ], - "crewed": false + "crewed": false, + "summons": { + "prompt": true, + "bonuses": { + "ac": "", + "hd": "", + "hp": "", + "attackDamage": "", + "saveDamage": "", + "healing": "" + }, + "profiles": [ + { + "count": "10", + "name": "", + "_id": "xLVPgOloyb3QJ0i1", + "uuid": "Compendium.dnd5e.monsters.Actor.Hn8FFLEzscgT7azz", + "level": { + "min": null, + "max": null + } + }, + { + "count": "", + "name": "", + "_id": "zzh3sTByUIvUYVvE", + "uuid": "Compendium.dnd5e.monsters.Actor.GxgIVRX5GbVTifiF", + "level": { + "min": null, + "max": null + } + }, + { + "count": "3", + "name": "", + "_id": "Mjp51GLFAYyA6NgW", + "uuid": "Compendium.dnd5e.monsters.Actor.v99wOosUJjUgcUNF", + "level": { + "min": null, + "max": null + } + }, + { + "count": "5", + "name": "", + "_id": "LkxYBiqNIyzUJxuH", + "uuid": "Compendium.dnd5e.monsters.Actor.4tl0s2SnaLjkoDiI", + "level": { + "min": null, + "max": null + } + } + ] + } }, "sort": 0, "flags": {}, @@ -99,10 +152,10 @@ "folder": "5auWSClKMDUIV5Ni", "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234177, - "modifiedTime": 1704823524872, + "modifiedTime": 1714077185843, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!czXrVRx6XYRWsHAi" diff --git a/packs/_source/spells/6th-level/create-undead.json b/packs/_source/spells/6th-level/create-undead.json index fb6a65d82a..eaa255eb0e 100644 --- a/packs/_source/spells/6th-level/create-undead.json +++ b/packs/_source/spells/6th-level/create-undead.json @@ -52,7 +52,7 @@ "scale": false }, "ability": "", - "actionType": "util", + "actionType": "summ", "attackBonus": "", "chatFlavor": "", "critical": { @@ -90,7 +90,60 @@ "somatic", "material" ], - "crewed": false + "crewed": false, + "summons": { + "prompt": true, + "bonuses": { + "ac": "", + "hd": "", + "hp": "", + "attackDamage": "", + "saveDamage": "", + "healing": "" + }, + "profiles": [ + { + "count": "@item.level - 6", + "name": "", + "_id": "GGQa2fXx4sJFOhCv", + "uuid": "Compendium.dnd5e.monsters.Actor.IyIybE5t2adMEVUM", + "level": { + "min": 8, + "max": null + } + }, + { + "count": "@item.level - 3", + "name": "", + "_id": "439tNP7qngefOmVd", + "uuid": "Compendium.dnd5e.monsters.Actor.OBujQLLPSmlJiZnL", + "level": { + "min": null, + "max": null + } + }, + { + "count": "@item.level - 7", + "name": "", + "_id": "8377f4szK4aEYLqD", + "uuid": "Compendium.dnd5e.monsters.Actor.t8WYD7ak07X7xpx8", + "level": { + "min": 9, + "max": null + } + }, + { + "count": "@item.level - 6", + "name": "", + "_id": "1wXs9fpxffiMlT1j", + "uuid": "Compendium.dnd5e.monsters.Actor.rbyp54px2D0ql4QK", + "level": { + "min": 8, + "max": null + } + } + ] + } }, "sort": 0, "flags": {}, @@ -99,10 +152,10 @@ "folder": "0pdesvXqKd55VOh2", "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234100, - "modifiedTime": 1704823522820, + "modifiedTime": 1714076897049, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!E4NXux0RHvME1XgP" diff --git a/packs/_source/spells/cantrip/dancing-lights.json b/packs/_source/spells/cantrip/dancing-lights.json index 92482d107f..e977b0b2de 100644 --- a/packs/_source/spells/cantrip/dancing-lights.json +++ b/packs/_source/spells/cantrip/dancing-lights.json @@ -52,7 +52,7 @@ "scale": false }, "ability": "", - "actionType": "util", + "actionType": "summ", "attackBonus": "", "chatFlavor": "", "critical": { @@ -91,7 +91,40 @@ "material", "concentration" ], - "crewed": false + "crewed": false, + "summons": { + "prompt": true, + "bonuses": { + "ac": "", + "hd": "", + "hp": "", + "attackDamage": "", + "saveDamage": "", + "healing": "" + }, + "profiles": [ + { + "count": "", + "name": "", + "_id": "VCE7UzvbkVLU3yGQ", + "uuid": "Compendium.dnd5e.monsters.Actor.JNmHqTwkWayvkH37", + "level": { + "min": null, + "max": null + } + }, + { + "count": "4", + "name": "", + "_id": "HC2PKEK56d2l8mFi", + "uuid": "Compendium.dnd5e.monsters.Actor.UrXp9O9N7DvdH9nL", + "level": { + "min": null, + "max": null + } + } + ] + } }, "sort": 0, "flags": {}, @@ -100,10 +133,10 @@ "folder": "Z8kZlnggh1PtuF3Y", "_stats": { "systemId": "dnd5e", - "systemVersion": "3.0.0", + "systemVersion": "3.2.0", "coreVersion": "11.315", "createdTime": 1661787234094, - "modifiedTime": 1704823522707, + "modifiedTime": 1714077073151, "lastModifiedBy": "dnd5ebuilder0000" }, "_key": "!items!CAxSzHWizrafT033" From 5e0f267720c7b9272f14e52a77ab5272282dc688 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 24 Apr 2024 10:54:24 -0700 Subject: [PATCH 111/199] [#3492] Add tooltips to active effects --- lang/en.json | 6 ++++ less/v2/inventory.less | 23 +++++++------- .../applications/actor/character-sheet-2.mjs | 8 +++-- module/documents/active-effect.mjs | 30 +++++++++++++++++++ module/documents/item.mjs | 11 +++++++ module/tooltips.mjs | 4 +-- templates/effects/parts/effect-tooltip.hbs | 28 +++++++++++++++++ templates/shared/active-effects2.hbs | 2 +- 8 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 templates/effects/parts/effect-tooltip.hbs diff --git a/lang/en.json b/lang/en.json index de219ac3ee..292b25fbac 100644 --- a/lang/en.json +++ b/lang/en.json @@ -814,6 +814,12 @@ "DND5E.EffectPassive": "Passive Effects", "DND5E.EffectInactive": "Inactive Effects", "DND5E.EffectNew": "New Effect", +"DND5E.EffectType": { + "Inactive": "Inactive", + "Passive": "Passive", + "Temporary": "Temporary", + "Unavailable": "Unavailable" +}, "DND5E.EffectUnavailable": "Unavailable Effects", "DND5E.EffectUnavailableInfo": "Source item must be equipped or attuned to activate these", "DND5E.Encumbrance": "Encumbrance", diff --git a/less/v2/inventory.less b/less/v2/inventory.less index 58a44f6d0a..a03f2f65a1 100644 --- a/less/v2/inventory.less +++ b/less/v2/inventory.less @@ -410,17 +410,6 @@ /* Effect Duration */ .effect-name { .name { flex: 1; } - .duration { - padding: .25rem; - border: 1px dashed var(--color-border-light-1); - border-radius: 4px; - font-family: var(--dnd5e-font-roboto-condensed); - text-transform: uppercase; - font-size: var(--font-size-11); - color: var(--color-text-dark-5); - - .separator, .least-significant { color: var(--color-text-light-6); } - } } /* Effect Source */ @@ -500,6 +489,18 @@ &:last-child { border: none; } } } + + .effect-name .duration, &.effect-tooltip .duration { + padding: .25rem; + border: 1px dashed var(--color-border-light-1); + border-radius: 4px; + font-family: var(--dnd5e-font-roboto-condensed); + text-transform: uppercase; + font-size: var(--font-size-11); + color: var(--color-text-dark-5); + + .separator, .least-significant { color: var(--color-text-light-6); } + } } /* ---------------------------------- */ diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index ac8e234dd5..e9f4f87238 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -1003,11 +1003,15 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { */ _applyItemTooltips(element) { if ( "tooltip" in element.dataset ) return; - const target = element.closest("[data-item-id], [data-uuid]"); + const target = element.closest("[data-item-id], [data-effect-id], [data-uuid]"); let uuid = target.dataset.uuid; - if ( !uuid ) { + if ( !uuid && target.dataset.itemId ) { const item = this.actor.items.get(target.dataset.itemId); uuid = item?.uuid; + } else if ( !uuid && target.dataset.effectId ) { + const { effectId, parentId } = target.dataset; + const collection = parentId ? this.actor.items.get(parentId).effects : this.actor.effects; + uuid = collection.get(effectId)?.uuid; } if ( !uuid ) return; element.dataset.tooltip = ` diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index de73ae0868..3e0614d8d7 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -623,4 +623,34 @@ export default class ActiveEffect5e extends ActiveEffect { const delta = current.symmetricDifference(source); for ( const choice of delta ) overrides.push(`${prefix}.${choice}`); } + + /* -------------------------------------------- */ + + /** + * Render a rich tooltip for this effect. + * @param {EnrichmentOptions} [enrichmentOptions={}] Options for text enrichment. + * @returns {Promise<{content: string, classes: string[]}>} + */ + async richTooltip(enrichmentOptions={}) { + const properties = []; + if ( this.isSuppressed ) properties.push("DND5E.EffectType.Unavailable"); + else if ( this.disabled ) properties.push("DND5E.EffectType.Inactive"); + else if ( this.isTemporary ) properties.push("DND5E.EffectType.Temporary"); + else properties.push("DND5E.EffectType.Passive"); + if ( this.getFlag("dnd5e", "type") === "enchantment" ) properties.push("DND5E.Enchantment.Label"); + + return { + content: await renderTemplate( + "systems/dnd5e/templates/effects/parts/effect-tooltip.hbs", { + effect: this, + description: await TextEditor.enrichHTML(this.description ?? "", { + async: true, relativeTo: this, ...enrichmentOptions + }), + durationParts: this.duration.remaining ? this.duration.label.split(", ") : [], + properties: properties.map(p => game.i18n.localize(p)) + } + ), + classes: ["dnd5e2", "dnd5e-tooltip", "effect-tooltip"] + }; + } } diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 58ae0ce450..e7709a59eb 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -953,6 +953,17 @@ export default class Item5e extends SystemDocumentMixin(Item) { /* -------------------------------------------- */ + /** + * Render a rich tooltip for this item. + * @param {EnrichmentOptions} [enrichmentOptions={}] Options for text enrichment. + * @returns {Promise<{content: string, classes: string[]}>|null} + */ + richTooltip(enrichmentOptions={}) { + return this.system.richTooltip?.() ?? null; + } + + /* -------------------------------------------- */ + /** * Configuration data for an item usage being prepared. * diff --git a/module/tooltips.mjs b/module/tooltips.mjs index 35987c72cd..50fed18dad 100644 --- a/module/tooltips.mjs +++ b/module/tooltips.mjs @@ -103,8 +103,8 @@ export default class Tooltips5e { * @protected */ async _onHoverContentLink(doc) { - if ( !doc.system?.richTooltip ) return; - const { content, classes } = await doc.system.richTooltip(); + const { content, classes } = await doc.richTooltip?.() ?? {}; + if ( !content ) return; this.tooltip.innerHTML = content; classes?.forEach(c => this.tooltip.classList.add(c)); const { tooltipDirection } = game.tooltip.element.dataset; diff --git a/templates/effects/parts/effect-tooltip.hbs b/templates/effects/parts/effect-tooltip.hbs new file mode 100644 index 0000000000..61076eca2a --- /dev/null +++ b/templates/effects/parts/effect-tooltip.hbs @@ -0,0 +1,28 @@ +
        +
        +
        + {{ effect.name }} +
        + {{ effect.name }} + {{#if effect.duration.remaining}} + + + {{ durationParts.[0] }} + {{#if durationParts.[1]}} + | + {{ durationParts.[1] }} + {{/if}} + + {{/if}} +
        +
        +
        +
        {{{ description }}}
        +
          + {{#each properties}} +
        • + {{ this }} +
        • + {{/each}} +
        +
        diff --git a/templates/shared/active-effects2.hbs b/templates/shared/active-effects2.hbs index 61d56d57b0..feb5f4b2b0 100644 --- a/templates/shared/active-effects2.hbs +++ b/templates/shared/active-effects2.hbs @@ -33,7 +33,7 @@ {{#if parentId}}data-parent-id="{{ parentId }}"{{/if}}> {{!-- Effect Name --}} -
        +
        {{ name }}
        {{ name }} From 41978a35fdaafccae09b860765468cb941733e9a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 13:45:24 -0700 Subject: [PATCH 112/199] [#2773] Move uses column, fix pattern --- templates/actors/tabs/character-features.hbs | 2 +- templates/actors/tabs/character-spells.hbs | 22 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/templates/actors/tabs/character-features.hbs b/templates/actors/tabs/character-features.hbs index e349e18fb1..4bc34a867a 100644 --- a/templates/actors/tabs/character-features.hbs +++ b/templates/actors/tabs/character-features.hbs @@ -127,7 +127,7 @@
        {{#if ctx.hasUses}} + data-name="system.uses.value" inputmode="numeric" pattern="[+=\-]?\d*"> / {{ item.system.uses.max }} {{/if}} diff --git a/templates/actors/tabs/character-spells.hbs b/templates/actors/tabs/character-spells.hbs index a911413074..33a94b8ede 100644 --- a/templates/actors/tabs/character-spells.hbs +++ b/templates/actors/tabs/character-spells.hbs @@ -61,12 +61,12 @@ {{!-- Section Header --}}

        {{ localize label }}

        -
        {{ localize "DND5E.Uses" }}
        {{ localize "DND5E.SpellHeader.School" }}
        {{ localize "DND5E.SpellHeader.Time" }}
        {{ localize "DND5E.SpellHeader.Range" }}
        {{ localize "DND5E.SpellHeader.Target" }}
        {{ localize "DND5E.SpellHeader.Roll" }}
        +
        {{ localize "DND5E.Uses" }}
        {{ localize "DND5E.SpellHeader.Formula" }}
        {{#if (and usesSlots editable)}} @@ -128,16 +128,6 @@
        - {{!-- Spell Uses --}} -
        - {{#if ctx.hasUses}} - - / - {{ item.system.uses.max }} - {{/if}} -
        - {{!-- Spell School --}}
        @@ -185,6 +175,16 @@ {{/if}}
        + {{!-- Spell Uses --}} +
        + {{#if ctx.hasUses}} + + / + {{ item.system.uses.max }} + {{/if}} +
        + {{!-- Spell Formula --}}
        {{#each item.labels.derivedDamage}} From 6f163e34a0af8b5661c738a635ff80e77da8bdfc Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 25 Apr 2024 14:04:56 -0700 Subject: [PATCH 113/199] [#3492] Fix tooltips for conditions box on character sheet --- module/tooltips.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/tooltips.mjs b/module/tooltips.mjs index 50fed18dad..cfce2451f4 100644 --- a/module/tooltips.mjs +++ b/module/tooltips.mjs @@ -103,7 +103,7 @@ export default class Tooltips5e { * @protected */ async _onHoverContentLink(doc) { - const { content, classes } = await doc.richTooltip?.() ?? {}; + const { content, classes } = await (doc.richTooltip?.() ?? doc.system?.richTooltip?.() ?? {}); if ( !content ) return; this.tooltip.innerHTML = content; classes?.forEach(c => this.tooltip.classList.add(c)); From b47c2e2e35d678c12c9fbdff92c45328437fa16c Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 26 Apr 2024 15:27:25 -0700 Subject: [PATCH 114/199] [#3500] Allow module item types to appear in inventory --- module/applications/actor/character-sheet.mjs | 3 ++- module/data/abstract.mjs | 6 ++++-- module/data/item/consumable.mjs | 3 ++- module/data/item/container.mjs | 3 ++- module/data/item/equipment.mjs | 3 ++- module/data/item/loot.mjs | 3 ++- module/data/item/tool.mjs | 3 ++- module/data/item/weapon.mjs | 3 ++- 8 files changed, 18 insertions(+), 9 deletions(-) diff --git a/module/applications/actor/character-sheet.mjs b/module/applications/actor/character-sheet.mjs index 3ed7624eb8..e2394e42ef 100644 --- a/module/applications/actor/character-sheet.mjs +++ b/module/applications/actor/character-sheet.mjs @@ -54,7 +54,8 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Categorize items as inventory, spellbook, features, and classes const inventory = {}; - for ( const type of ["weapon", "equipment", "consumable", "tool", "container", "loot"] ) { + for ( const [type, model] of Object.entries(CONFIG.Item.dataModels) ) { + if ( !model.metadata?.inventoryItem ) continue; inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}}; } diff --git a/module/data/abstract.mjs b/module/data/abstract.mjs index 101253a60e..d4bb9e4987 100644 --- a/module/data/abstract.mjs +++ b/module/data/abstract.mjs @@ -360,13 +360,15 @@ export class ItemDataModel extends SystemDataModel { /** * @typedef {SystemDataModelMetadata} ItemDataModelMetadata - * @property {boolean} enchantable Can this item be modified by enchantment effects? - * @property {boolean} singleton Should only a single item of this type be allowed on an actor? + * @property {boolean} enchantable Can this item be modified by enchantment effects? + * @property {boolean} inventoryItem Should this item be listed with an actor's inventory? + * @property {boolean} singleton Should only a single item of this type be allowed on an actor? */ /** @type {ItemDataModelMetadata} */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: false, + inventoryItem: true, singleton: false }, {inplace: false})); diff --git a/module/data/item/consumable.mjs b/module/data/item/consumable.mjs index 9409d81f5f..574b2b72be 100644 --- a/module/data/item/consumable.mjs +++ b/module/data/item/consumable.mjs @@ -46,7 +46,8 @@ export default class ConsumableData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/container.mjs b/module/data/item/container.mjs index de82b9a19e..557f7df9a8 100644 --- a/module/data/item/container.mjs +++ b/module/data/item/container.mjs @@ -43,7 +43,8 @@ export default class ContainerData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/equipment.mjs b/module/data/item/equipment.mjs index 311d155e8d..19fa2a35b9 100644 --- a/module/data/item/equipment.mjs +++ b/module/data/item/equipment.mjs @@ -66,7 +66,8 @@ export default class EquipmentData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/loot.mjs b/module/data/item/loot.mjs index c265af96e5..6d91fee610 100644 --- a/module/data/item/loot.mjs +++ b/module/data/item/loot.mjs @@ -29,7 +29,8 @@ export default class LootData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/tool.mjs b/module/data/item/tool.mjs index 7e5b791bb8..fded56934d 100644 --- a/module/data/item/tool.mjs +++ b/module/data/item/tool.mjs @@ -45,7 +45,8 @@ export default class ToolData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 88dba437aa..04a1c99fb4 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -47,7 +47,8 @@ export default class WeaponData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { - enchantable: true + enchantable: true, + inventoryItem: true }, {inplace: false})); /* -------------------------------------------- */ From 54c7c383f4a6642de806ebc72dd678cdd9d397fa Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:15:40 +0200 Subject: [PATCH 115/199] [#3454-#3457] Fully generalize spell slot concepts (#3458) Co-authored-by: Zhell Co-authored-by: Kim Mantas --- dnd5e.mjs | 14 ++- module/applications/actor/base-sheet.mjs | 31 ++++--- .../applications/actor/character-sheet-2.mjs | 24 +++-- module/config.mjs | 9 +- module/documents/actor/actor.mjs | 91 +++++++++++-------- module/utils.mjs | 2 +- 6 files changed, 107 insertions(+), 64 deletions(-) diff --git a/dnd5e.mjs b/dnd5e.mjs index 08adce6db9..6f5dc229bd 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -198,11 +198,16 @@ function _configureTrackableAttributes() { ] }; + const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => { + if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}`); + return acc; + }, []); + const creature = { bar: [ ...common.bar, "attributes.hp", - "spells.pact", + ...altSpells, ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(l => `spells.spell${l}`) ], value: [ @@ -240,6 +245,11 @@ function _configureTrackableAttributes() { * @internal */ function _configureConsumableAttributes() { + const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => { + if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}.value`); + return acc; + }, []); + CONFIG.DND5E.consumableResources = [ ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`), "attributes.ac.flat", @@ -250,7 +260,7 @@ function _configureConsumableAttributes() { "details.xp.value", "resources.primary.value", "resources.secondary.value", "resources.tertiary.value", "resources.legact.value", "resources.legres.value", - "spells.pact.value", + ...altSpells, ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(level => `spells.spell${level}.value`) ]; } diff --git a/module/applications/actor/base-sheet.mjs b/module/applications/actor/base-sheet.mjs index 9df2f063bd..c1d87da65e 100644 --- a/module/applications/actor/base-sheet.mjs +++ b/module/applications/actor/base-sheet.mjs @@ -433,7 +433,7 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { } } - // Pact magic users have cantrips and a pact magic section + // Create spellbook sections for all alternative spell preparation modes that have spell slots. for ( const [k, v] of Object.entries(CONFIG.DND5E.spellPreparationModes) ) { if ( !(k in levels) || !v.upcast || !levels[k].max ) continue; @@ -1060,36 +1060,41 @@ export default class ActorSheet5e extends ActorSheetMixin(ActorSheet) { const { level, preparationMode } = header?.closest("[data-level]")?.dataset ?? {}; // Determine the actor's spell slot progressions, if any. + const spellcastKeys = Object.keys(CONFIG.DND5E.spellcastingTypes); const progs = Object.values(this.document.classes).reduce((acc, cls) => { - if ( cls.spellcasting?.type === "pact" ) acc.pact = true; - else if ( cls.spellcasting?.type === "leveled" ) acc.leveled = true; + const type = cls.spellcasting?.type; + if ( spellcastKeys.includes(type) ) acc.add(type); return acc; - }, {pact: false, leveled: false}); + }, new Set()); + + const prep = itemData.system.preparation; // Case 1: Drop a cantrip. if ( itemData.system.level === 0 ) { - if ( ["pact", "prepared"].includes(preparationMode) ) { - itemData.system.preparation.mode = "prepared"; + const modes = CONFIG.DND5E.spellPreparationModes; + if ( modes[preparationMode]?.cantrips ) { + prep.mode = "prepared"; } else if ( !preparationMode ) { - const isCaster = this.document.system.details.spellLevel || progs.pact || progs.leveled; - itemData.system.preparation.mode = isCaster ? "prepared" : "innate"; + const isCaster = this.document.system.details.spellLevel || progs.size; + prep.mode = isCaster ? "prepared" : "innate"; } else { - itemData.system.preparation.mode = preparationMode; + prep.mode = preparationMode; } - if ( itemData.system.preparation.mode === "prepared" ) itemData.system.preparation.prepared = true; + if ( modes[prep.mode]?.prepares ) prep.prepared = true; } // Case 2: Drop a leveled spell in a section without a mode. else if ( (level === "0") || !preparationMode ) { if ( this.document.type === "npc" ) { - itemData.system.preparation.mode = this.document.system.details.spellLevel ? "prepared" : "innate"; + prep.mode = this.document.system.details.spellLevel ? "prepared" : "innate"; } else { - itemData.system.preparation.mode = progs.leveled ? "prepared" : progs.pact ? "pact" : "innate"; + const m = progs.has("leveled") ? "prepared" : (progs.first() ?? "innate"); + prep.mode = progs.has(prep.mode) ? prep.mode : m; } } // Case 3: Drop a leveled spell in a specific section. - else itemData.system.preparation.mode = preparationMode; + else prep.mode = preparationMode; } /* -------------------------------------------- */ diff --git a/module/applications/actor/character-sheet-2.mjs b/module/applications/actor/character-sheet-2.mjs index e9f4f87238..a2145b9928 100644 --- a/module/applications/actor/character-sheet-2.mjs +++ b/module/applications/actor/character-sheet-2.mjs @@ -753,16 +753,18 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { requestAnimationFrame(() => game.tooltip.deactivate()); game.tooltip.deactivate(); + const modes = CONFIG.DND5E.spellPreparationModes; + const { key } = event.target.closest("[data-key]")?.dataset ?? {}; const { level, preparationMode } = event.target.closest("[data-level]")?.dataset ?? {}; const isSlots = event.target.closest("[data-favorite-id]") || event.target.classList.contains("spell-header"); let type; if ( key in CONFIG.DND5E.skills ) type = "skill"; else if ( key in CONFIG.DND5E.toolIds ) type = "tool"; - else if ( preparationMode && (level !== "0") && isSlots ) type = "slots"; + else if ( modes[preparationMode]?.upcast && (level !== "0") && isSlots ) type = "slots"; if ( !type ) return super._onDragStart(event); const dragData = { dnd5e: { action: "favorite", type } }; - if ( type === "slots" ) dragData.dnd5e.id = preparationMode === "pact" ? "pact" : `spell${level}`; + if ( type === "slots" ) dragData.dnd5e.id = (preparationMode === "prepared") ? `spell${level}` : preparationMode; else dragData.dnd5e.id = key; event.dataTransfer.setData("application/json", JSON.stringify(dragData)); } @@ -1274,7 +1276,7 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { itemId: type === "item" ? favorite.id : null, effectId: type === "effect" ? favorite.id : null, parentId: (type === "effect") && (favorite.parent !== favorite.target) ? favorite.parent.id: null, - preparationMode: type === "slots" ? id === "pact" ? "pact" : "prepared" : null, + preparationMode: (type === "slots") ? (/spell\d+/.test(id) ? "prepared" : id) : null, key: (type === "skill") || (type === "tool") ? id : null, toggle: toggle === undefined ? null : { applicable: true, value: toggle }, quantity: quantity > 1 ? quantity : "", @@ -1301,19 +1303,23 @@ export default class ActorSheet5eCharacter2 extends ActorSheet5eCharacter { if ( type === "slots" ) { const { value, max, level } = this.actor.system.spells[id] ?? {}; const uses = { value, max, name: `system.spells.${id}.value` }; - if ( id === "pact" ) return { + if ( !/spell\d+/.test(id) ) return { uses, level, - title: game.i18n.localize("DND5E.SpellSlotsPact"), - subtitle: [game.i18n.localize(`DND5E.SpellLevel${level}`), game.i18n.localize("DND5E.AbbreviationSR")], - img: "icons/magic/unholy/silhouette-robe-evil-power.webp" + title: game.i18n.localize(`DND5E.SpellSlots${id.capitalize()}`), + subtitle: [ + game.i18n.localize(`DND5E.SpellLevel${level}`), + game.i18n.localize(`DND5E.Abbreviation${CONFIG.DND5E.spellcastingTypes[id]?.shortRest ? "SR" : "LR"}`) + ], + img: CONFIG.DND5E.spellcastingTypes[id]?.img || CONFIG.DND5E.spellcastingTypes.pact.img }; const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" }); + const isSR = CONFIG.DND5E.spellcastingTypes.leveled.shortRest; return { uses, level, title: game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level }), - subtitle: game.i18n.localize("DND5E.AbbreviationLR"), - img: `systems/dnd5e/icons/spell-tiers/${id}.webp` + subtitle: game.i18n.localize(`DND5E.Abbreviation${isSR ? "SR" : "LR"}`), + img: CONFIG.DND5E.spellcastingTypes.leveled.img.replace("{id}", id) }; } diff --git a/module/config.mjs b/module/config.mjs index 62fd2a5fc8..73c53e7a7a 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -2038,7 +2038,9 @@ DND5E.spellUpcastModes = ["always", "pact", "prepared"]; * Configuration data for different types of spellcasting supported. * * @typedef {object} SpellcastingTypeConfiguration - * @property {string} label Localized label. + * @property {string} label Localized label. + * @property {string} img Image used when rendered as a favorite on the sheet. + * @property {boolean} [shortRest] Are these spell slots additionally restored on a short rest? * @property {Object} [progression] Any progression modes for this type. */ @@ -2058,6 +2060,7 @@ DND5E.spellUpcastModes = ["always", "pact", "prepared"]; DND5E.spellcastingTypes = { leveled: { label: "DND5E.SpellProgLeveled", + img: "systems/dnd5e/icons/spell-tiers/{id}.webp", progression: { full: { label: "DND5E.SpellProgFull", @@ -2079,7 +2082,9 @@ DND5E.spellcastingTypes = { } }, pact: { - label: "DND5E.SpellProgPact" + label: "DND5E.SpellProgPact", + img: "icons/magic/unholy/silhouette-robe-evil-power.webp", + shortRest: true } }; preLocalize("spellcastingTypes", { key: "label", sort: true }); diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index d1ed37a1fc..9819908304 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -742,42 +742,55 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /* -------------------------------------------- */ /** - * Prepare pact spell slots using progression data. + * Prepare non-leveled spell slots using progression data. * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.* * @param {Actor5e} actor Actor for whom the data is being prepared. * @param {object} progression Spellcasting progression data. + * @param {string} key The internal key for these spell slots on the actor. + * @param {object} table The table used for determining the progression of slots. */ - static preparePactSlots(spells, actor, progression) { - // Pact spell data: - // - pact.level: Slot level for pact casting - // - pact.max: Total number of pact slots - // - pact.value: Currently available pact slots - // - pact.override: Override number of available spell slots + static prepareAltSlots(spells, actor, progression, key, table) { + // Spell data: + // - x.level: Slot level for casting + // - x.max: Total number of slots + // - x.value: Currently available slots + // - x.override: Override number of available spell slots - let pactLevel = Math.clamped(progression.pact, 0, CONFIG.DND5E.maxLevel); - spells.pact ??= {}; - const override = Number.isNumeric(spells.pact.override) ? parseInt(spells.pact.override) : null; + let keyLevel = Math.clamped(progression[key], 0, CONFIG.DND5E.maxLevel); + spells[key] ??= {}; + const override = Number.isNumeric(spells[key].override) ? parseInt(spells[key].override) : null; - // Pact slot override - if ( (pactLevel === 0) && (actor.type === "npc") && (override !== null) ) { - pactLevel = actor.system.details.spellLevel; + // Slot override + if ( (keyLevel === 0) && (actor.type === "npc") && (override !== null) ) { + keyLevel = actor.system.details.spellLevel; } - const [, pactConfig] = Object.entries(CONFIG.DND5E.pactCastingProgression) - .reverse().find(([l]) => Number(l) <= pactLevel) ?? []; - if ( pactConfig ) { - spells.pact.level = pactConfig.level; - if ( override === null ) spells.pact.max = pactConfig.slots; - else spells.pact.max = Math.max(override, 1); - spells.pact.value = Math.min(spells.pact.value, spells.pact.max); + const [, keyConfig] = Object.entries(table).reverse().find(([l]) => Number(l) <= keyLevel) ?? []; + if ( keyConfig ) { + spells[key].level = keyConfig.level; + if ( override === null ) spells[key].max = keyConfig.slots; + else spells[key].max = Math.max(override, 1); + spells[key].value = Math.min(spells[key].value, spells[key].max); } else { - spells.pact.max = override || 0; - spells.pact.level = spells.pact.max > 0 ? 1 : 0; + spells[key].max = override || 0; + spells[key].level = (spells[key].max > 0) ? 1 : 0; } } + /* -------------------------------------------- */ + + /** + * Convenience method for preparing pact slots specifically. + * @param {object} spells The `data.spells` object within actor's data. *Will be mutated.* + * @param {Actor5e} actor Actor for whom the data is being prepared. + * @param {object} progression Spellcasting progression data. + */ + static preparePactSlots(spells, actor, progression) { + this.prepareAltSlots(spells, actor, progression, "pact", CONFIG.DND5E.pactCastingProgression); + } + /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ @@ -2193,7 +2206,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /* -------------------------------------------- */ /** - * Take a short rest, possibly spending hit dice and recovering resources, item uses, and pact slots. + * Take a short rest, possibly spending hit dice and recovering resources, item uses, and relevant spell slots. * @param {RestConfiguration} [config] Configuration options for a short rest. * @returns {Promise} A Promise which resolves once the short rest workflow has completed. */ @@ -2338,7 +2351,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { ...(hdActorUpdates ?? {}), ...hpActorUpdates, ...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }), - ...this._getRestSpellRecovery({ recoverSpells: longRest }) + ...this._getRestSpellRecovery({ recoverLong: longRest }) }, updateItems: [ ...(hdItemUpdates ?? []), @@ -2501,25 +2514,29 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /* -------------------------------------------- */ /** - * Recovers spell slots and pact slots. + * Recovers expended spell slots. * @param {object} [options] - * @param {boolean} [options.recoverPact=true] Recover all expended pact slots. - * @param {boolean} [options.recoverSpells=true] Recover all expended spell slots. + * @param {boolean} [options.recoverShort=true] Recover slots that return on short rests. + * @param {boolean} [options.recoverLong=true] Recover slots that return on long rests. * @returns {object} Updates to the actor. * @protected */ - _getRestSpellRecovery({recoverPact=true, recoverSpells=true}={}) { - const spells = this.system.spells ?? {}; + _getRestSpellRecovery({recoverShort=true, recoverLong=true}={}) { + const spells = this.system.spells; let updates = {}; - if ( recoverPact ) { - const pact = spells.pact; - updates["system.spells.pact.value"] = pact.override || pact.max; - } - if ( recoverSpells ) { - for ( let [k, v] of Object.entries(spells) ) { - updates[`system.spells.${k}.value`] = Number.isNumeric(v.override) ? v.override : (v.max ?? 0); + if ( !spells ) return updates; + + Object.entries(CONFIG.DND5E.spellPreparationModes).forEach(([k, v]) => { + const isSR = CONFIG.DND5E.spellcastingTypes[k === "prepared" ? "leveled" : k]?.shortRest; + if ( v.upcast && ((recoverShort && isSR) || recoverLong) ) { + if ( k === "prepared" ) { + Object.entries(spells).forEach(([m, n]) => { + if ( /^spell\d+/.test(m) && n.level ) updates[`system.spells.${m}.value`] = n.max; + }); + } + else if ( k !== "always" ) updates[`system.spells.${k}.value`] = spells[k].max; } - } + }); return updates; } diff --git a/module/utils.mjs b/module/utils.mjs index 9f5aa3949f..075dd9504b 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -562,7 +562,7 @@ export function getHumanReadableAttributeLabel(attr, { actor }={}) { // Spell slots. else if ( attr.startsWith("spells.") ) { const [, key] = attr.split("."); - if ( key === "pact" ) label = "DND5E.SpellSlotsPact"; + if ( !/spell\d+/.test(key) ) label = `DND5E.SpellSlots${key.capitalize()}`; else { const plurals = new Intl.PluralRules(game.i18n.lang, {type: "ordinal"}); const level = Number(key.slice(5)); From f4a15a3b4d77504fa91bd4a97a27e29a2cd07fa5 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Apr 2024 10:06:05 -0700 Subject: [PATCH 116/199] [#3500] Fix default value for inventoryItem --- module/data/abstract.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/data/abstract.mjs b/module/data/abstract.mjs index d4bb9e4987..8c8a4118cf 100644 --- a/module/data/abstract.mjs +++ b/module/data/abstract.mjs @@ -368,7 +368,7 @@ export class ItemDataModel extends SystemDataModel { /** @type {ItemDataModelMetadata} */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: false, - inventoryItem: true, + inventoryItem: false, singleton: false }, {inplace: false})); From 31df146179435eb658b14cf7a6e71f5a3e83ccc9 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 22 Apr 2024 15:26:14 -0700 Subject: [PATCH 117/199] [#1787] Support potentially unlinked spells in spell lists Adds a new `unlinkedSpells` array to spell list journal pages for holding information on spells that may potentially become unlinked. This is useful for when a spell list might need to include spells that aren't in the current module or in the SRD. In order to properly display, this data structure contains the name of the spell, its level, and its school. It also contains the source object for tracking where the spell originated. This source object can contain a UUID so if the module that contains the spell is active it will display linked like the rest, but if the module is inactive then it will display as a static entry. --- lang/en.json | 6 ++ less/v1/journal.less | 15 +++- .../journal/spells-page-sheet.mjs | 83 +++++++++++++++---- .../journal/spells-unlinked-config.mjs | 65 +++++++++++++++ module/data/journal/spells.mjs | 31 ++++++- module/data/shared/source-field.mjs | 6 +- module/utils.mjs | 26 +++--- templates/journal/page-spell-list-edit.hbs | 21 ++++- .../page-spell-list-unlinked-config.hbs | 44 ++++++++++ templates/journal/page-spell-list-view.hbs | 22 ++--- 10 files changed, 271 insertions(+), 48 deletions(-) create mode 100644 module/applications/journal/spells-unlinked-config.mjs create mode 100644 templates/journal/page-spell-list-unlinked-config.hbs diff --git a/lang/en.json b/lang/en.json index 292b25fbac..99c76419e7 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1931,6 +1931,12 @@ "Type": { "Label": "Spell List Type", "Other": "Uncategorized" + }, + "UnlinkedSpells": { + "Label": "Unlinked Spells", + "Add": "Add Unlinked Spell", + "Configuration": "Spell Configuration", + "Edit": "Edit Unlinked Spell" } }, "Subclass": { diff --git a/less/v1/journal.less b/less/v1/journal.less index 215868ad31..e4287e7fbb 100644 --- a/less/v1/journal.less +++ b/less/v1/journal.less @@ -162,11 +162,24 @@ p:empty:has(+ .content-embed), .content-embed + p:empty { padding-block: 0.35em 0; padding-inline: 2px; - .item-controls { flex: 0; } + .item-controls { + flex: 0; + flex-wrap: nowrap; + gap: 8px; + } } .items-list .item:not(:last-of-type) { border-bottom: 1px solid var(--color-border-light-secondary); } + + h3 { + justify-content: space-between; + a { + flex: 0; + padding-inline-end: 12px; + font-size: var(--font-size-14); + } + } } } diff --git a/module/applications/journal/spells-page-sheet.mjs b/module/applications/journal/spells-page-sheet.mjs index ba77ddb2b1..0d53002edb 100644 --- a/module/applications/journal/spells-page-sheet.mjs +++ b/module/applications/journal/spells-page-sheet.mjs @@ -1,6 +1,7 @@ import SpellListJournalPageData from "../../data/journal/spells.mjs"; import { linkForUuid, sortObjectEntries } from "../../utils.mjs"; import Items5e from "../../data/collection/items-collection.mjs"; +import SpellsUnlinkedConfig from "./spells-unlinked-config.mjs"; /** * Journal entry page the displays a list of spells for a class, subclass, background, or something else. @@ -69,7 +70,8 @@ export default class JournalSpellListPageSheet extends JournalPageSheet { context.spells = await this.prepareSpells(context.grouping); context.sections = {}; - for ( const spell of context.spells ) { + for ( const data of context.spells ) { + const spell = data.spell ?? data.unlinked; let section; switch ( context.grouping ) { case "level": @@ -87,7 +89,7 @@ export default class JournalSpellListPageSheet extends JournalPageSheet { default: continue; } - section.spells.push(spell); + section.spells.push(data); } if ( context.grouping === "school" ) context.sections = sortObjectEntries(context.sections, "header"); @@ -114,18 +116,46 @@ export default class JournalSpellListPageSheet extends JournalPageSheet { default: fields = []; break; } + const unlinkedData = {}; + const uuids = new Set(this.document.system.spells); + for ( const unlinked of this.document.system.unlinkedSpells ) { + if ( unlinked.source.uuid ) { + uuids.add(unlinked.source.uuid); + unlinkedData[unlinked.source.uuid] = unlinked; + } + } + let collections = new Collection(); - for ( const uuid of this.document.system.spells ) { + for ( const uuid of uuids ) { const { collection } = foundry.utils.parseUuid(uuid); if ( collection && !collections.has(collection) ) { if ( collection instanceof Items5e ) collections.set(collection, collection); else collections.set(collection, collection.getIndex({ fields })); - } + } else if ( !collection ) uuids.delete(uuid); + } + + const spells = (await Promise.all(collections.values())).flatMap(c => c.filter(s => uuids.has(s.uuid))); + + for ( const unlinked of this.document.system.unlinkedSpells ) { + if ( !uuids.has(unlinked.source.uuid) ) spells.push({ unlinked }); } - return (await Promise.all(collections.values())) - .flatMap(c => c.filter(s => this.document.system.spells.has(s.uuid))) - .sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang)); + return spells + .map(spell => { + const data = spell.unlinked ? spell : { spell }; + data.unlinked ??= unlinkedData[data.spell?.uuid]; + data.name = data.spell?.name ?? data.unlinked?.name; + if ( data.spell ) { + data.display = linkForUuid(data.spell.uuid, { + tooltip: '
        ' + }); + } else { + data.display = `${data.unlinked.name ?? "—"}*`; + } + return data; + }) + .sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)); } /* -------------------------------------------- */ @@ -141,25 +171,42 @@ export default class JournalSpellListPageSheet extends JournalPageSheet { this.grouping = (event.target.value === this.document.system.grouping) ? null : event.target.value; this.object.parent.sheet.render(); }); - html.querySelectorAll(".item-delete").forEach(e => { - e.addEventListener("click", this._onDeleteItem.bind(this)); + html.querySelectorAll("[data-action]").forEach(e => { + e.addEventListener("click", this._onAction.bind(this)); }); } /* -------------------------------------------- */ /** - * Handle deleting a dropped spell. - * @param {Event} event This triggering click event. - * @returns {JournalSpellListPageSheet} + * Handle performing an action. + * @param {PointerEvent} event This triggering click event. */ - async _onDeleteItem(event) { + async _onAction(event) { event.preventDefault(); - const uuidToDelete = event.currentTarget.closest("[data-item-uuid]")?.dataset.itemUuid; - if ( !uuidToDelete ) return this; - const spellSet = this.document.system.spells.filter(s => s !== uuidToDelete); - await this.document.update({"system.spells": Array.from(spellSet)}); - this.render(); + const { action } = event.target.dataset; + + const { itemUuid, unlinkedId } = event.target.closest(".item")?.dataset ?? {}; + switch ( action ) { + case "add-unlinked": + await this.document.update({"system.unlinkedSpells": [...this.document.system.unlinkedSpells, {}]}); + const id = this.document.toObject().system.unlinkedSpells.pop()._id; + new SpellsUnlinkedConfig(id, this.document).render(true); + break; + case "delete": + if ( itemUuid ) { + const spellSet = this.document.system.spells.filter(s => s !== itemUuid); + await this.document.update({"system.spells": Array.from(spellSet)}); + } else if ( unlinkedId ) { + const unlinkedSet = this.document.system.unlinkedSpells.filter(s => s._id !== unlinkedId); + await this.document.update({"system.unlinkedSpells": Array.from(unlinkedSet)}); + } + this.render(); + break; + case "edit-unlinked": + if ( unlinkedId ) new SpellsUnlinkedConfig(unlinkedId, this.document).render(true); + break; + } } /* -------------------------------------------- */ diff --git a/module/applications/journal/spells-unlinked-config.mjs b/module/applications/journal/spells-unlinked-config.mjs new file mode 100644 index 0000000000..afeda29bba --- /dev/null +++ b/module/applications/journal/spells-unlinked-config.mjs @@ -0,0 +1,65 @@ +/** + * Application for configuring a single unlinked spell in a spell list. + */ +export default class SpellsUnlinkedConfig extends DocumentSheet { + constructor(unlinkedId, object, options={}) { + super(object, options); + this.unlinkedId = unlinkedId; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["dnd5e", "unlinked-spell-config"], + template: "systems/dnd5e/templates/journal/page-spell-list-unlinked-config.hbs", + width: 400, + height: "auto", + sheetConfig: false + }); + } + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * ID of the unlinked spell entry being edited. + * @type {string} + */ + unlinkedId; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + get title() { + return `${game.i18n.localize( + "JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Configuration")}: ${this.document.name}`; + } + + /* -------------------------------------------- */ + /* Rendering */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + getData() { + const context = { + ...super.getData(), + ...this.document.system.unlinkedSpells.find(u => u._id === this.unlinkedId), + appId: this.id, + CONFIG: CONFIG.DND5E + }; + return context; + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _updateObject(event, formData) { + const unlinkedSpells = this.document.toObject().system.unlinkedSpells; + const editing = unlinkedSpells.find(s => s._id === this.unlinkedId); + foundry.utils.mergeObject(editing, formData); + this.document.update({"system.unlinkedSpells": unlinkedSpells}); + } +} diff --git a/module/data/journal/spells.mjs b/module/data/journal/spells.mjs index 14d72dd7e9..6d38ce97ee 100644 --- a/module/data/journal/spells.mjs +++ b/module/data/journal/spells.mjs @@ -1,6 +1,23 @@ import { IdentifierField } from "../fields.mjs"; +import SourceField from "../shared/source-field.mjs"; -const { HTMLField, SchemaField, SetField, StringField } = foundry.data.fields; +const { ArrayField, DocumentIdField, HTMLField, NumberField, SchemaField, SetField, StringField } = foundry.data.fields; + +/** + * Data needed to display spells that aren't able to be linked (outside SRD & current module). + * + * @typedef {object} UnlinkedSpellConfiguration + * @property {string} _id Unique ID for this entry. + * @property {string} name Name of the spell. + * @property {object} system + * @property {number} system.level Spell level. + * @property {string} system.school Spell school. + * @property {object} source + * @property {string} source.book Book/publication where the spell originated. + * @property {string} source.page Page or section where the spell can be found. + * @property {string} source.custom Fully custom source label. + * @property {string} source.uuid UUID of the spell, if available in another module. + */ /** * Data model for spell list data. @@ -11,6 +28,7 @@ const { HTMLField, SchemaField, SetField, StringField } = foundry.data.fields; * @property {object} description * @property {string} description.value Description to display before spell list. * @property {Set} spells UUIDs of spells to display. + * @property {UnlinkedSpellConfiguration[]} unlinkedSpells Unavailable spells that are entered manually. */ export default class SpellListJournalPageData extends foundry.abstract.DataModel { static defineSchema() { @@ -27,7 +45,16 @@ export default class SpellListJournalPageData extends foundry.abstract.DataModel description: new SchemaField({ value: new HTMLField({label: "DND5E.Description"}) }), - spells: new SetField(new StringField(), {label: "DND5E.ItemTypeSpellPl"}) + spells: new SetField(new StringField(), {label: "DND5E.ItemTypeSpellPl"}), + unlinkedSpells: new ArrayField(new SchemaField({ + _id: new DocumentIdField({initial: () => foundry.utils.randomID()}), + name: new StringField({label: "Name"}), + system: new SchemaField({ + level: new NumberField({min: 0, integer: true, label: "DND5E.Level"}), + school: new StringField({label: "DND5E.School"}) + }), + source: new SourceField({license: false, uuid: new StringField()}) + }), {label: "JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Label"}) }; } diff --git a/module/data/shared/source-field.mjs b/module/data/shared/source-field.mjs index 81523f7b09..474bb7fa71 100644 --- a/module/data/shared/source-field.mjs +++ b/module/data/shared/source-field.mjs @@ -10,13 +10,15 @@ const { SchemaField, StringField } = foundry.data.fields; */ export default class SourceField extends SchemaField { constructor(fields={}, options={}) { - super({ + fields = { book: new StringField({label: "DND5E.SourceBook"}), page: new StringField({label: "DND5E.SourcePage"}), custom: new StringField({label: "DND5E.SourceCustom"}), license: new StringField({label: "DND5E.SourceLicense"}), ...fields - }, { label: "DND5E.Source", ...options }); + }; + Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null); + super(fields, { label: "DND5E.Source", ...options }); } /* -------------------------------------------- */ diff --git a/module/utils.mjs b/module/utils.mjs index 075dd9504b..fca45e7012 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -175,16 +175,18 @@ export function indexFromUuid(uuid) { /** * Creates an HTML document link for the provided UUID. - * @param {string} uuid UUID for which to produce the link. - * @returns {string} Link to the item or empty string if item wasn't found. + * @param {string} uuid UUID for which to produce the link. + * @param {object} [options] + * @param {string} [options.tooltip] Tooltip to add to the link. + * @returns {string} Link to the item or empty string if item wasn't found. */ -export function linkForUuid(uuid) { - if ( game.release.generation < 12 ) { - return TextEditor._createContentLink(["", "UUID", uuid]).outerHTML; - } +export function linkForUuid(uuid, { tooltip }={}) { + let element; + + if ( game.release.generation < 12 ) element = TextEditor._createContentLink(["", "UUID", uuid]); // TODO: When v11 support is dropped we can make this method async and return to using TextEditor._createContentLink. - if ( uuid.startsWith("Compendium.") ) { + else if ( uuid.startsWith("Compendium.") ) { let [, scope, pack, documentName, id] = uuid.split("."); if ( !CONST.PRIMARY_DOCUMENT_TYPES.includes(documentName) ) id = documentName; const data = { @@ -193,9 +195,13 @@ export function linkForUuid(uuid) { }; TextEditor._createLegacyContentLink("Compendium", [scope, pack, id].join("."), "", data); data.dataset.link = ""; - return TextEditor.createAnchor(data).outerHTML; + element = TextEditor.createAnchor(data); } - return fromUuidSync(uuid).toAnchor().outerHTML; + + else element = fromUuidSync(uuid).toAnchor(); + + if ( tooltip ) element.dataset.tooltip = tooltip; + return element.outerHTML; } /* -------------------------------------------- */ @@ -414,7 +420,7 @@ export function registerHandlebarsHelpers() { "dnd5e-concealSection": concealSection, "dnd5e-dataset": dataset, "dnd5e-groupedSelectOptions": groupedSelectOptions, - "dnd5e-linkForUuid": linkForUuid, + "dnd5e-linkForUuid": (uuid, options) => linkForUuid(uuid, options.hash), "dnd5e-itemContext": itemContext, "dnd5e-numberFormat": (context, options) => formatNumber(context, options.hash), "dnd5e-textFormat": formatText diff --git a/templates/journal/page-spell-list-edit.hbs b/templates/journal/page-spell-list-edit.hbs index d0db84434e..d0b763c2eb 100644 --- a/templates/journal/page-spell-list-edit.hbs +++ b/templates/journal/page-spell-list-edit.hbs @@ -40,13 +40,26 @@
        -

        {{ localize "ITEM.TypeSpellPl" }}

        +

        + {{ localize "ITEM.TypeSpellPl" }} + + + +

          {{#each spells}} -
        1. -
          {{{ dnd5e-linkForUuid this.uuid }}}
          +
        2. +
          {{{ display }}}
          - + + + {{/if}} + diff --git a/templates/journal/page-spell-list-unlinked-config.hbs b/templates/journal/page-spell-list-unlinked-config.hbs new file mode 100644 index 0000000000..18fbf24797 --- /dev/null +++ b/templates/journal/page-spell-list-unlinked-config.hbs @@ -0,0 +1,44 @@ + +
          + {{ localize "TYPES.Item.spell" }} +
          + + +
          +
          + + +
          +
          + + +
          +
          +
          + {{ localize "DND5E.Source" }} +
          + + + + {{selectOptions CONFIG.sourceBooks}} + +
          +
          + + +
          +
          + + +
          +
          + + +
          +
          + + diff --git a/templates/journal/page-spell-list-view.hbs b/templates/journal/page-spell-list-view.hbs index 93b1186b1a..c12873841b 100644 --- a/templates/journal/page-spell-list-view.hbs +++ b/templates/journal/page-spell-list-view.hbs @@ -13,21 +13,21 @@ {{/unless}} - + {{{ description }}} - + {{#each sections}} - {{ header }} -
            + {{ header }} +
              {{#each spells}} -
            • {{{ dnd5e-linkForUuid uuid }}}
            • +
            • {{{ display }}}
            • {{/each}} -
            +
          {{else}} -
            - {{#each spells}} -
          • {{{ dnd5e-linkForUuid uuid }}}
          • - {{/each}} -
          +
            + {{#each spells}} +
          • {{{ display }}}
          • + {{/each}} +
          {{/each}}
          From 0d020b6a3088d9f6b6f271b998d2585f303941eb Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Apr 2024 10:10:50 -0700 Subject: [PATCH 118/199] [#1787] Fix issue with sorting blank names for unlinked spells --- module/applications/journal/spells-page-sheet.mjs | 2 +- module/data/journal/spells.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/applications/journal/spells-page-sheet.mjs b/module/applications/journal/spells-page-sheet.mjs index 0d53002edb..2e85fd914d 100644 --- a/module/applications/journal/spells-page-sheet.mjs +++ b/module/applications/journal/spells-page-sheet.mjs @@ -144,7 +144,7 @@ export default class JournalSpellListPageSheet extends JournalPageSheet { .map(spell => { const data = spell.unlinked ? spell : { spell }; data.unlinked ??= unlinkedData[data.spell?.uuid]; - data.name = data.spell?.name ?? data.unlinked?.name; + data.name = data.spell?.name ?? data.unlinked?.name ?? ""; if ( data.spell ) { data.display = linkForUuid(data.spell.uuid, { tooltip: '
          ' diff --git a/module/data/journal/spells.mjs b/module/data/journal/spells.mjs index 6d38ce97ee..68327af0e6 100644 --- a/module/data/journal/spells.mjs +++ b/module/data/journal/spells.mjs @@ -48,7 +48,7 @@ export default class SpellListJournalPageData extends foundry.abstract.DataModel spells: new SetField(new StringField(), {label: "DND5E.ItemTypeSpellPl"}), unlinkedSpells: new ArrayField(new SchemaField({ _id: new DocumentIdField({initial: () => foundry.utils.randomID()}), - name: new StringField({label: "Name"}), + name: new StringField({required: true, label: "Name"}), system: new SchemaField({ level: new NumberField({min: 0, integer: true, label: "DND5E.Level"}), school: new StringField({label: "DND5E.School"}) From d7742180bbada900c7b80203c20f210a6a5e35a7 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Apr 2024 10:17:22 -0700 Subject: [PATCH 119/199] [#3071] Fix tooltip position if none is explicitly defined --- module/tooltips.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/module/tooltips.mjs b/module/tooltips.mjs index b5e360e028..a527b7ed7c 100644 --- a/module/tooltips.mjs +++ b/module/tooltips.mjs @@ -172,10 +172,15 @@ export default class Tooltips5e { /** * Position a tooltip after rendering. - * @param {string} [direction="LEFT"] The direction to position the tooltip. + * @param {string} [direction] The direction to position the tooltip. * @protected */ - _positionItemTooltip(direction=TooltipManager.TOOLTIP_DIRECTIONS.LEFT) { + _positionItemTooltip(direction) { + if ( !direction ) { + direction = TooltipManager.TOOLTIP_DIRECTIONS.LEFT; + game.tooltip._setAnchor(direction); + } + const pos = this.tooltip.getBoundingClientRect(); const dirs = TooltipManager.TOOLTIP_DIRECTIONS; switch ( direction ) { From 37da17150e629406012808c12848006f77c659c2 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 16 Apr 2024 15:57:38 -0700 Subject: [PATCH 120/199] [#3305] Enforce enchantment restrictions when dropping onto item When adding an enchantment to an item that accepts enchantments, the restrictions specified in the enchantment source will now be enforced. It will also prevent the creation of an enchantment effect directly on an actor. Enchanted items in the world will register themselves with a static registry of all enchanted items so the enchantment items can easily determine what items they have enchanted. Support has been added for items that might offer enchantments or receive them (to support a weapon that can enchant other weapons for example). This uses the `origin` data to track whether the enchantment effect is applied to the item or not. To this end the default origin for new enchantments isn't set like for normal effets. The origin is only set when an enchantment is dragged to another item. This ensures that the enchantment origin always matches the item doing the enchantment, even if that item was duplicated. --- lang/en.json | 6 ++ module/applications/components/effects.mjs | 18 ++--- module/applications/item/item-sheet.mjs | 23 +++--- module/data/item/feat.mjs | 14 +--- module/data/item/fields/enchantment-field.mjs | 73 ++++++++++++++++++ module/data/item/spell.mjs | 13 ---- module/data/item/templates/action.mjs | 47 +++++++----- module/documents/active-effect.mjs | 74 +++++++++++++++---- module/documents/item.mjs | 2 +- templates/items/parts/item-action.hbs | 19 ++++- 10 files changed, 208 insertions(+), 81 deletions(-) diff --git a/lang/en.json b/lang/en.json index 99c76419e7..e542aa8b22 100644 --- a/lang/en.json +++ b/lang/en.json @@ -725,8 +725,14 @@ } }, "Label": "Enchantment", + "Items": { + "Label": "Enchanted Items", + "Entry": "{item} on {actor}", + "None": "Nothing Enchanted" + }, "Warning": { "NoMagicalItems": "Items that are already magical cannot be enchanted.", + "NotOnActor": "Enchantments can only be added to items, not directly to actors.", "Override": "This value is being modified by an Enchantment and cannot be edited. Disable the enchantment in the effects tab to edit it.", "WrongType": "{incorrectType} items cannot be enchanted by this enchantment, only {allowedType} items are allowed." } diff --git a/module/applications/components/effects.mjs b/module/applications/components/effects.mjs index 15da7caf5e..26b6ad996c 100644 --- a/module/applications/components/effects.mjs +++ b/module/applications/components/effects.mjs @@ -123,20 +123,19 @@ export default class EffectsElement extends HTMLElement { }; // Iterate over active effects, classifying them into categories - const enchantmentParent = parent?.system.isEnchantment; for ( const e of effects ) { if ( (e.parent.system?.identified === false) && !game.user.isGM ) continue; - if ( e.getFlag("dnd5e", "type") === "enchantment" ) { - if ( enchantmentParent ) categories.enchantment.effects.push(e); - else if ( e.disabled ) categories.enchantmentInactive.effects.push(e); + if ( e.isAppliedEnchantment ) { + if ( e.disabled ) categories.enchantmentInactive.effects.push(e); else categories.enchantmentActive.effects.push(e); } + else if ( e.getFlag("dnd5e", "type") === "enchantment" ) categories.enchantment.effects.push(e); else if ( e.isSuppressed ) categories.suppressed.effects.push(e); else if ( e.disabled ) categories.inactive.effects.push(e); else if ( e.isTemporary ) categories.temporary.effects.push(e); else categories.passive.effects.push(e); } - categories.enchantment.hidden = !enchantmentParent; + categories.enchantment.hidden = !parent?.system.isEnchantment; categories.enchantmentActive.hidden = !categories.enchantmentActive.effects.length; categories.enchantmentInactive.hidden = !categories.enchantmentInactive.effects.length; categories.suppressed.hidden = !categories.suppressed.effects.length; @@ -277,17 +276,16 @@ export default class EffectsElement extends HTMLElement { * @returns {Promise} */ async _onCreate(target) { - const isActor = this.document instanceof Actor; const li = target.closest("li"); - const flags = {}; - if ( li.dataset.effectType.startsWith("enchantment") ) flags["dnd5e.type"] = "enchantment"; + const isActor = this.document instanceof Actor; + const isEnchantment = li.dataset.effectType.startsWith("enchantment"); return this.document.createEmbeddedDocuments("ActiveEffect", [{ name: isActor ? game.i18n.localize("DND5E.EffectNew") : this.document.name, icon: isActor ? "icons/svg/aura.svg" : this.document.img, - origin: this.document.uuid, + origin: isEnchantment ? undefined : this.document.uuid, "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, disabled: ["inactive", "enchantmentInactive"].includes(li.dataset.effectType), - flags + "flags.dnd5e.type": isEnchantment ? "enchantment" : undefined }]); } diff --git a/module/applications/item/item-sheet.mjs b/module/applications/item/item-sheet.mjs index df5db89a06..1ae8b3352a 100644 --- a/module/applications/item/item-sheet.mjs +++ b/module/applications/item/item-sheet.mjs @@ -113,10 +113,6 @@ export default class ItemSheet5e extends ItemSheet { isPhysical: item.system.hasOwnProperty("quantity"), // Action Details - availableActionTypes: Object.entries(CONFIG.DND5E.itemActionTypes).reduce((obj, [k, v]) => { - if ( k !== "ench" || !this.item.system.metadata?.enchantable ) obj[k] = v; - return obj; - }, {}), isHealing: item.system.actionType === "heal", isFlatDC: item.system.save?.scaling === "flat", isLine: ["line", "wall"].includes(item.system.target?.type), @@ -136,6 +132,9 @@ export default class ItemSheet5e extends ItemSheet { // Advancement advancement: this._getItemAdvancement(item), + // Enchantment + enchantedItems: await item.system.enchantment?.getItems(), + // Prepare Active Effects effects: EffectsElement.prepareCategories(item.effects, { parent: this.item }), elements: this.options.elements, @@ -735,12 +734,16 @@ export default class ItemSheet5e extends ItemSheet { */ async _onDropActiveEffect(event, data) { const effect = await ActiveEffect.implementation.fromDropData(data); - if ( !this.item.isOwner || !effect ) return false; - if ( (this.item.uuid === effect.parent?.uuid) || (this.item.uuid === effect.origin) ) return false; - return ActiveEffect.create({ - ...effect.toObject(), - origin: this.item.uuid - }, {parent: this.item}); + if ( !this.item.isOwner || !effect + || (this.item.uuid === effect.parent?.uuid) + || (this.item.uuid === effect.origin) ) return false; + const effectData = effect.toObject(); + let keepOrigin = false; + if ( effect.getFlag("dnd5e", "type") === "enchantment" ) { + effectData.origin ??= effect.parent.uuid; + keepOrigin = true; + } + return ActiveEffect.create(effectData, {parent: this.item, keepOrigin}); } /* -------------------------------------------- */ diff --git a/module/data/item/feat.mjs b/module/data/item/feat.mjs index 340f90202c..0ff1ad8ffa 100644 --- a/module/data/item/feat.mjs +++ b/module/data/item/feat.mjs @@ -3,7 +3,7 @@ import ActionTemplate from "./templates/action.mjs"; import ActivatedEffectTemplate from "./templates/activated-effect.mjs"; import ItemDescriptionTemplate from "./templates/item-description.mjs"; import ItemTypeTemplate from "./templates/item-type.mjs"; -import {default as EnchantmentField, EnchantmentData} from "./fields/enchantment-field.mjs"; +import { EnchantmentData } from "./fields/enchantment-field.mjs"; import ItemTypeField from "./fields/item-type-field.mjs"; const { BooleanField, NumberField, SchemaField, SetField, StringField } = foundry.data.fields; @@ -15,7 +15,6 @@ const { BooleanField, NumberField, SchemaField, SetField, StringField } = foundr * @mixes ActivatedEffectTemplate * @mixes ActionTemplate * - * @property {EnchantmentData} enchantment Enchantment configuration associated with this type. * @property {object} prerequisites * @property {number} prerequisites.level Character or class level required to choose this feature. * @property {Set} properties General properties of a feature item. @@ -35,7 +34,6 @@ export default class FeatData extends ItemDataModel.mixin( static defineSchema() { return this.mergeSchema(super.defineSchema(), { type: new ItemTypeField({baseItem: false}, {label: "DND5E.ItemFeatureType"}), - enchantment: new EnchantmentField(), prerequisites: new SchemaField({ level: new NumberField({integer: true, min: 0}) }), @@ -151,16 +149,6 @@ export default class FeatData extends ItemDataModel.mixin( /* -------------------------------------------- */ - /** - * Is this feature an enchantment? - * @type {boolean} - */ - get isEnchantment() { - return EnchantmentData.isEnchantment(this); - } - - /* -------------------------------------------- */ - /** * Does this feature represent a group of individual enchantments (e.g. the "Infuse Item" feature stores data about * all of the character's infusions). diff --git a/module/data/item/fields/enchantment-field.mjs b/module/data/item/fields/enchantment-field.mjs index 888c975b44..f804fe8d79 100644 --- a/module/data/item/fields/enchantment-field.mjs +++ b/module/data/item/fields/enchantment-field.mjs @@ -58,6 +58,16 @@ export class EnchantmentData extends foundry.abstract.DataModel { /* -------------------------------------------- */ + /** + * Enchantments available or applied. + * @type {ActiveEffect5e[]} + */ + get enchantments() { + return this.item.effects.filter(ae => ae.getFlag("dnd5e", "type") === "enchantment"); + } + + /* -------------------------------------------- */ + /** * Is this feature an enchantment? * @param {FeatData} data Data for the feature. @@ -98,6 +108,16 @@ export class EnchantmentData extends foundry.abstract.DataModel { return EnchantmentData.isEnchantmentSource(this.parent); } + /* -------------------------------------------- */ + + /** + * Item to which this enchantment information belongs. + * @type {Item5e} + */ + get item() { + return this.parent.parent; + } + /* -------------------------------------------- */ /* Helpers */ /* -------------------------------------------- */ @@ -123,6 +143,59 @@ export class EnchantmentData extends foundry.abstract.DataModel { return errors.length ? errors : true; } + + /* -------------------------------------------- */ + + /** + * Fetch the tracked enchanted items. + * @returns {Promise} + */ + async getItems() { + return (await Promise.all( + Array.from(this.constructor.#appliedEnchantments.get(this.item.uuid) ?? []) + .map(uuid => fromUuid(uuid).then(ae => ae?.parent)) + )).filter(i => i); + } + + /* -------------------------------------------- */ + /* Static Registry */ + /* -------------------------------------------- */ + + /** + * Registration of enchanted items mapped to a specific enchantment source. The map is keyed by the UUID of + * enchantment sources while the set contains UUID of items that source has enchanted. + * @type {Map>} + */ + static #appliedEnchantments = new Map(); + + /* -------------------------------------------- */ + + /** + * Add a new enchantment effect to the list of tracked enchantments. Will not track enchanted items in compendiums. + * @param {string} source UUID of the active effect origin for the enchantment. + * @param {string} enchanted UUID of the enchantment to track. + */ + static trackEnchantment(source, enchanted) { + if ( enchanted.startsWith("Compendium.") ) return; + if ( !this.#appliedEnchantments.has(source) ) { + this.#appliedEnchantments.set(source, new Set()); + } + const applied = this.#appliedEnchantments.get(source); + applied.add(enchanted); + } + + /* -------------------------------------------- */ + + /** + * Stop tracking an enchantment. + * @param {string} source UUID of the active effect origin for the enchantment. + * @param {string} enchanted UUID of the enchantment to stop tracking. + */ + static untrackEnchantment(source, enchanted) { + if ( !this.#appliedEnchantments.has(source) ) return; + const applied = this.#appliedEnchantments.get(source); + applied.delete(enchanted); + } } /** diff --git a/module/data/item/spell.mjs b/module/data/item/spell.mjs index edff05e590..6675ee5ae4 100644 --- a/module/data/item/spell.mjs +++ b/module/data/item/spell.mjs @@ -1,7 +1,6 @@ import { filteredKeys } from "../../utils.mjs"; import { ItemDataModel } from "../abstract.mjs"; import { FormulaField } from "../fields.mjs"; -import {default as EnchantmentField, EnchantmentData} from "./fields/enchantment-field.mjs"; import ActionTemplate from "./templates/action.mjs"; import ActivatedEffectTemplate from "./templates/activated-effect.mjs"; import ItemDescriptionTemplate from "./templates/item-description.mjs"; @@ -15,7 +14,6 @@ import ItemDescriptionTemplate from "./templates/item-description.mjs"; * @property {number} level Base level of the spell. * @property {string} school Magical school to which this spell belongs. * @property {Set} properties General components and tags for this spell. - * @property {EnchantmentData} enchantment Enchantment configuration associated with this type. * @property {object} materials Details on material components required for this spell. * @property {string} materials.value Description of the material components required for casting. * @property {boolean} materials.consumed Are these material components consumed during casting? @@ -41,7 +39,6 @@ export default class SpellData extends ItemDataModel.mixin( properties: new foundry.data.fields.SetField(new foundry.data.fields.StringField(), { label: "DND5E.SpellComponents" }), - enchantment: new EnchantmentField(), materials: new foundry.data.fields.SchemaField({ value: new foundry.data.fields.StringField({required: true, label: "DND5E.SpellMaterialsDescription"}), consumed: new foundry.data.fields.BooleanField({required: true, label: "DND5E.SpellMaterialsConsumed"}), @@ -171,16 +168,6 @@ export default class SpellData extends ItemDataModel.mixin( /* -------------------------------------------- */ - /** - * Is this spell an enchantment? - * @type {boolean} - */ - get isEnchantment() { - return EnchantmentData.isEnchantment(this); - } - - /* -------------------------------------------- */ - /** * The proficiency multiplier for this item. * @returns {number} diff --git a/module/data/item/templates/action.mjs b/module/data/item/templates/action.mjs index 164e53b43c..36baf9afe4 100644 --- a/module/data/item/templates/action.mjs +++ b/module/data/item/templates/action.mjs @@ -1,5 +1,6 @@ import { ItemDataModel } from "../../abstract.mjs"; import { FormulaField } from "../../fields.mjs"; +import {default as EnchantmentField, EnchantmentData} from "../fields/enchantment-field.mjs"; import SummonsField from "../fields/summons-field.mjs"; const { ArrayField, BooleanField, NumberField, SchemaField, StringField } = foundry.data.fields; @@ -7,23 +8,24 @@ const { ArrayField, BooleanField, NumberField, SchemaField, StringField } = foun /** * Data model template for item actions. * - * @property {string} ability Ability score to use when determining modifier. - * @property {string} actionType Action type as defined in `DND5E.itemActionTypes`. - * @property {object} attack Information how attacks are handled. - * @property {string} attack.bonus Numeric or dice bonus to attack rolls. - * @property {boolean} attack.flat Is the attack bonus the only bonus to attack rolls? - * @property {string} chatFlavor Extra text displayed in chat. - * @property {object} critical Information on how critical hits are handled. - * @property {number} critical.threshold Minimum number on the dice to roll a critical hit. - * @property {string} critical.damage Extra damage on critical hit. - * @property {object} damage Item damage formulas. - * @property {string[][]} damage.parts Array of damage formula and types. - * @property {string} damage.versatile Special versatile damage formula. - * @property {string} formula Other roll formula. - * @property {object} save Item saving throw data. - * @property {string} save.ability Ability required for the save. - * @property {number} save.dc Custom saving throw value. - * @property {string} save.scaling Method for automatically determining saving throw DC. + * @property {string} ability Ability score to use when determining modifier. + * @property {string} actionType Action type as defined in `DND5E.itemActionTypes`. + * @property {object} attack Information how attacks are handled. + * @property {string} attack.bonus Numeric or dice bonus to attack rolls. + * @property {boolean} attack.flat Is the attack bonus the only bonus to attack rolls? + * @property {string} chatFlavor Extra text displayed in chat. + * @property {object} critical Information on how critical hits are handled. + * @property {number} critical.threshold Minimum number on the dice to roll a critical hit. + * @property {string} critical.damage Extra damage on critical hit. + * @property {object} damage Item damage formulas. + * @property {string[][]} damage.parts Array of damage formula and types. + * @property {string} damage.versatile Special versatile damage formula. + * @property {EnchantmentData} enchantment Enchantment configuration associated with this type. + * @property {string} formula Other roll formula. + * @property {object} save Item saving throw data. + * @property {string} save.ability Ability required for the save. + * @property {number} save.dc Custom saving throw value. + * @property {string} save.scaling Method for automatically determining saving throw DC. * @property {SummonsData} summons * @mixin */ @@ -48,6 +50,7 @@ export default class ActionTemplate extends ItemDataModel { parts: new ArrayField(new ArrayField(new StringField({nullable: true})), {required: true}), versatile: new FormulaField({required: true, label: "DND5E.VersatileDamage"}) }, {label: "DND5E.Damage"}), + enchantment: new EnchantmentField(), formula: new FormulaField({required: true, label: "DND5E.OtherFormula"}), save: new SchemaField({ ability: new StringField({required: true, blank: true, label: "DND5E.Ability"}), @@ -238,6 +241,16 @@ export default class ActionTemplate extends ItemDataModel { /* -------------------------------------------- */ + /** + * Can this item enchant other items? + * @type {boolean} + */ + get isEnchantment() { + return EnchantmentData.isEnchantment(this); + } + + /* -------------------------------------------- */ + /** * Does the Item provide an amount of healing instead of conventional damage? * @type {boolean} diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 3e0614d8d7..004b548ecd 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -1,4 +1,5 @@ import { FormulaField } from "../data/fields.mjs"; +import { EnchantmentData } from "../data/item/fields/enchantment-field.mjs"; import { staticID } from "../utils.mjs"; /** @@ -24,6 +25,18 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ + /** + * Is this effect an enchantment on an item that accepts enchantment? + * @type {boolean} + */ + get isAppliedEnchantment() { + return (this.getFlag("dnd5e", "type") === "enchantment") + && (this.parent.system.metadata?.enchantable === true) + && !!this.origin && (this.origin !== this.parent.uuid); + } + + /* -------------------------------------------- */ + /** * Is this active effect currently suppressed? * @type {boolean} @@ -309,6 +322,7 @@ export default class ActiveEffect5e extends ActiveEffect { super.prepareDerivedData(); if ( this.getFlag("dnd5e", "type") === "enchantment" ) this.transfer = false; if ( this.id === this.constructor.ID.EXHAUSTION ) this._prepareExhaustionLevel(); + if ( this.isAppliedEnchantment ) EnchantmentData.trackEnchantment(this.origin, this.uuid); } /* -------------------------------------------- */ @@ -333,6 +347,49 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ + /** + * Prepare effect favorite data. + * @returns {Promise} + */ + async getFavoriteData() { + return { + img: this.img, + title: this.name, + subtitle: this.duration.remaining ? this.duration.label : "", + toggle: !this.disabled, + suppressed: this.isSuppressed + }; + } + + /* -------------------------------------------- */ + /* Socket Event Handlers */ + /* -------------------------------------------- */ + + /** @inheritDoc */ + async _preCreate(data, options, user) { + if ( await super._preCreate(data, options, user) === false ) return false; + if ( options.keepOrigin === false ) this.updateSource({ origin: this.parent.uuid }); + + // Enchantments cannot be added directly to actors + if ( (this.getFlag("dnd5e", "type") === "enchantment") && (this.parent instanceof Actor) ) { + ui.notifications.error("DND5E.Enchantment.Warning.NotOnActor", { localize: true }); + return false; + } + + if ( !this.isAppliedEnchantment ) return; + + // Otherwise validate against the enchantment's restraints on the origin item + const origin = await fromUuid(this.origin); + const errors = origin?.system.enchantment?.canEnchant(this.parent); + this.updateSource({ disabled: false }); + if ( !errors?.length ) return; + + errors.forEach(err => ui.notifications.error(err.message)); + return false; + } + + /* -------------------------------------------- */ + /** @inheritDoc */ _onUpdate(data, options, userId) { super._onUpdate(data, options, userId); @@ -381,22 +438,7 @@ export default class ActiveEffect5e extends ActiveEffect { _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} - */ - async getFavoriteData() { - return { - img: this.img, - title: this.name, - subtitle: this.duration.remaining ? this.duration.label : "", - toggle: !this.disabled, - suppressed: this.isSuppressed - }; + if ( this.isAppliedEnchantment ) EnchantmentData.untrackEnchantment(this.origin, this.uuid); } /* -------------------------------------------- */ diff --git a/module/documents/item.mjs b/module/documents/item.mjs index e7709a59eb..f8879389c1 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -400,7 +400,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { */ *allApplicableEffects() { for ( const effect of this.effects ) { - if ( effect.flags.dnd5e?.type === "enchantment" ) yield effect; + if ( effect.isAppliedEnchantment ) yield effect; } } diff --git a/templates/items/parts/item-action.hbs b/templates/items/parts/item-action.hbs index 0dca9317de..c16842655b 100644 --- a/templates/items/parts/item-action.hbs +++ b/templates/items/parts/item-action.hbs @@ -2,7 +2,7 @@
          {{#if system.actionType}} @@ -131,6 +131,23 @@
        + +
        + +
          + {{#each enchantedItems}} +
        • + {{#if actor}} + {{ localize "DND5E.Enchantment.Items.Entry" item=_source.name actor=actor.name }} + {{else}} + {{ _source.name }} + {{/if}} +
        • + {{else}} +
        • {{ localize "DND5E.Enchantment.Items.None" }}
        • + {{/each}} +
        +
        {{/if}} {{!-- Summoning --}} From 4851968a7c64fb9ebe36f73645089b719fa0f616 Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:30:43 +0200 Subject: [PATCH 121/199] [#2892] Allow for selecting token via any chat message (#3460) Co-authored-by: Zhell --- module/documents/chat-message.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs index 89c5d70afd..43bd026b75 100644 --- a/module/documents/chat-message.mjs +++ b/module/documents/chat-message.mjs @@ -182,8 +182,9 @@ export default class ChatMessage5e extends ChatMessage { nameText = this.user.name; } - const avatar = document.createElement("div"); + const avatar = document.createElement("a"); avatar.classList.add("avatar"); + avatar.dataset.uuid = actor.uuid; avatar.innerHTML = `${nameText}`; const name = document.createElement("span"); @@ -258,6 +259,10 @@ export default class ChatMessage5e extends ChatMessage { } else { html.querySelectorAll(".dice-roll").forEach(el => el.classList.add("secret-roll")); } + + avatar.addEventListener("click", this._onTargetMouseDown.bind(this)); + avatar.addEventListener("pointerover", this._onTargetHoverIn.bind(this)); + avatar.addEventListener("pointerout", this._onTargetHoverOut.bind(this)); } /* -------------------------------------------- */ @@ -317,8 +322,8 @@ export default class ChatMessage5e extends ChatMessage { }).sort((a, b) => (a[1] === b[1]) ? 0 : a[1] ? 1 : -1).reduce((str, [li]) => str + li, ""); evaluation.querySelectorAll("li.target").forEach(target => { target.addEventListener("click", this._onTargetMouseDown.bind(this)); - target.addEventListener("mouseover", this._onTargetHoverIn.bind(this)); - target.addEventListener("mouseout", this._onTargetHoverOut.bind(this)); + target.addEventListener("pointerover", this._onTargetHoverIn.bind(this)); + target.addEventListener("pointerout", this._onTargetHoverOut.bind(this)); }); html.querySelector(".message-content")?.appendChild(evaluation); } From 8009555e269f0d31efa50d7ef116de3313da2332 Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:39:24 +0200 Subject: [PATCH 122/199] [#1537, #3450] Add `ActivatedEffectTemplate` to tool items (#3468) Co-authored-by: Zhell --- lang/en.json | 2 ++ module/data/item/tool.mjs | 5 ++++- templates/items/tool.hbs | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lang/en.json b/lang/en.json index 99c76419e7..50fdb2efb6 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1056,10 +1056,12 @@ "DND5E.ItemRequiredStr": "Required Strength", "DND5E.ItemToolBase": "Base Tool", "DND5E.ItemToolBonus": "Tool Bonus", +"DND5E.ItemToolDetails": "Tool Details", "DND5E.ItemToolProficiency": "Tool Proficiency", "DND5E.ItemToolProperties": "Tool Properties", "DND5E.ItemToolStatus": "Tool Status", "DND5E.ItemToolType": "Tool Type", +"DND5E.ItemToolUsage": "Tool Usage", "DND5E.ItemView": "View Item", "DND5E.ItemWeaponAttack": "Weapon Attack", "DND5E.ItemWeaponBase": "Base Weapon", diff --git a/module/data/item/tool.mjs b/module/data/item/tool.mjs index 7e5b791bb8..c196e398b0 100644 --- a/module/data/item/tool.mjs +++ b/module/data/item/tool.mjs @@ -6,6 +6,7 @@ import ItemDescriptionTemplate from "./templates/item-description.mjs"; import ItemTypeTemplate from "./templates/item-type.mjs"; import PhysicalItemTemplate from "./templates/physical-item.mjs"; import ItemTypeField from "./fields/item-type-field.mjs"; +import ActivatedEffectTemplate from "./templates/activated-effect.mjs"; /** * Data definition for Tool items. @@ -14,6 +15,7 @@ import ItemTypeField from "./fields/item-type-field.mjs"; * @mixes IdentifiableTemplate * @mixes PhysicalItemTemplate * @mixes EquippableItemTemplate + * @mixes ActivatedEffectTemplate * * @property {string} ability Default ability when this tool is being used. * @property {string} chatFlavor Additional text added to chat when this tool is used. @@ -21,7 +23,8 @@ import ItemTypeField from "./fields/item-type-field.mjs"; * @property {string} bonus Bonus formula added to tool rolls. */ export default class ToolData extends ItemDataModel.mixin( - ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate, PhysicalItemTemplate, EquippableItemTemplate + ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate, + PhysicalItemTemplate, EquippableItemTemplate, ActivatedEffectTemplate ) { /** @inheritdoc */ static defineSchema() { diff --git a/templates/items/tool.hbs b/templates/items/tool.hbs index c453e36db1..af14285c24 100644 --- a/templates/items/tool.hbs +++ b/templates/items/tool.hbs @@ -73,6 +73,8 @@
        {{#dnd5e-concealSection concealDetails}} +

        {{ localize "DND5E.ItemToolDetails" }}

        + {{!-- Tool Type --}}
        @@ -130,6 +132,11 @@
        +

        {{ localize "DND5E.ItemToolUsage" }}

        + + {{!-- Item Activation Template --}} + {{> "dnd5e.item-activation"}} + {{!-- Chat Message Flavor --}}
        From 9879782e6d9e548d3e8a8f0b9af81770bba37a50 Mon Sep 17 00:00:00 2001 From: Zhell <50169243+krbz999@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:46:24 +0200 Subject: [PATCH 123/199] [#3497] Store the level a spell was cast at when concentrating (#3496) Co-authored-by: Zhell --- module/documents/active-effect.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 3e0614d8d7..86c6bb918d 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -428,6 +428,7 @@ export default class ActiveEffect5e extends ActiveEffect { statuses: [statusEffect.id].concat(statusEffect.statuses ?? []) }, data, {inplace: false}); delete effectData.id; + if ( item.type === "spell" ) effectData["flags.dnd5e.spellLevel"] = item.system.level; return effectData; } From 5f2940169fa80a51302e09c683e721edf6c30c44 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 19 Apr 2024 10:23:47 -0700 Subject: [PATCH 124/199] [#2299] Add replacement option for ItemChoiceAdvancement Migrates the `configuration.choices` data structure on item choice advancement to be an object with a new `replacement` boolean. This indicates that players can replace a previous choice at that level. The configuration app for `ItemChoiceAdvancement` has been modified into a three column layout to prevent it from getting too tall when there are a lot of choices such as for Warlock. A data model has been added for item choice with a new `replaced` object that indicates which previous choice was replaced for each level. The previous choices are kept within the `value.added` structure to accurately track choices across levels. The flow UI will display replaced choices as crossed out and has a radio button next to other previous choices so players can choose which of them to replace. --- lang/en.json | 4 + less/v1/advancement.less | 84 ++++++++++++---- .../advancement/advancement-config.mjs | 6 +- .../advancement/item-choice-config.mjs | 8 +- .../advancement/item-choice-flow.mjs | 56 ++++++++--- module/data/advancement/_module.mjs | 2 +- module/data/advancement/item-choice.mjs | 92 ++++++++++++++--- .../advancement/ability-score-improvement.mjs | 11 +-- module/documents/advancement/advancement.mjs | 17 ++++ module/documents/advancement/item-choice.mjs | 79 +++++++++++++-- module/documents/advancement/item-grant.mjs | 17 ++-- templates/advancement/item-choice-config.hbs | 98 ++++++++++--------- templates/advancement/item-choice-flow.hbs | 45 ++++++--- 13 files changed, 384 insertions(+), 135 deletions(-) diff --git a/lang/en.json b/lang/en.json index 50fdb2efb6..708e3f06d1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -197,10 +197,14 @@ "DND5E.AdvancementHitPointsRollButton": "Roll {die}", "DND5E.AdvancementItemChoiceTitle": "Choose Items", "DND5E.AdvancementItemChoiceHint": "Present the player with a choice of items (such as equipment, features, or spells) that they can choose for their character at one or more levels.", +"DND5E.AdvancementItemChoiceChoose": "choose {count}", "DND5E.AdvancementItemChoiceChosen": "Chosen: {current} of {max}", "DND5E.AdvancementItemChoiceFeatureLevelWarning": "Must be at least level {level} to take this feature.", "DND5E.AdvancementItemChoiceLevelsHint": "Specify how many choices are allowed at each level.", "DND5E.AdvancementItemChoicePreviouslyChosenWarning": "This item has already been chosen at a previous level.", +"DND5E.AdvancementItemChoiceReplacement": "Allow Replacement", +"DND5E.AdvancementItemChoiceReplacementNone": "No Replacement", +"DND5E.AdvancementItemChoiceReplacementTitle": "replace", "DND5E.AdvancementItemChoiceSpellLevelAvailable": "Any Available Level", "DND5E.AdvancementItemChoiceSpellLevelAvailableWarning": "Only {level} or lower spells can be chosen for this advancement.", "DND5E.AdvancementItemChoiceSpellLevelSpecificWarning": "Only {level} spells can be chosen for this advancement.", diff --git a/less/v1/advancement.less b/less/v1/advancement.less index 8241e99e49..5b989ce828 100644 --- a/less/v1/advancement.less +++ b/less/v1/advancement.less @@ -43,19 +43,14 @@ } /* ----------------------------------------- */ - /* Two Column Configurations */ + /* Column Configurations */ /* ----------------------------------------- */ - &.two-column { - --grid-two-column-left-size: 1fr; - --grid-two-column-right-size: 1fr; - + &.two-column, &.three-column { form { display: grid; - grid-template-columns: var(--grid-two-column-left-size) var(--grid-two-column-right-size); - grid-template-areas: "left right"; - grid-gap: 0.4em; + gap: 0.4em; - .left-column { + > .left-column { grid-area: left; display: flex; flex-direction: column; @@ -63,26 +58,59 @@ > .form-group { flex: none; } > .drop-target { flex: 1; } } - .right-column { + > .right-column { grid-area: right; - - &.level-list { - label { - flex: 0.5; - padding-right: 0.5rem; - text-align: end; - } - :is(input[type="text"], input[type="number"])::placeholder { - opacity: 0.5; - } + } + > .level-list { + label { + flex: 0.5; + padding-right: 0.5rem; + text-align: end; + } + :is(input[type="text"], input[type="number"])::placeholder { + opacity: 0.5; } } + } + } + + &.two-column { + --grid-two-column-left-size: 1fr; + --grid-two-column-right-size: 1fr; + + form { + grid-template-columns: var(--grid-two-column-left-size) var(--grid-two-column-right-size); + grid-template-areas: "left right"; + button[type="submit"] { grid-column-end: span 2; } } } + &.three-column { + --grid-three-column-left-size: 1fr; + --grid-three-column-center-size: 1fr; + --grid-three-column-right-size: 1fr; + + form { + grid-template-columns: + var(--grid-two-column-left-size) var(--grid-two-column-center-size) var(--grid-two-column-right-size); + grid-template-areas: "left center right"; + + > .center-column { + grid-area: center; + } + button[type="submit"] { + grid-column-end: span 3; + } + } + } + + /* ----------------------------------------- */ + /* Ability Score Improvement */ + /* ----------------------------------------- */ + form[data-type="AbilityScoreImprovement"] { .ability-scores { contain: layout; @@ -192,6 +220,8 @@ /* Item Choice */ /* ----------------------------------------- */ &.item-choice { + --grid-two-column-left-size: 1fr; + --grid-two-column-center-size: 0.7fr; --grid-two-column-right-size: 0.5fr; .level-list .hint { @@ -207,7 +237,7 @@ border: 1px solid var(--color-border-light-tertiary); font-family: var(--font-primary); font-size: var(--font-size-12); - height: 100px; + height: 140px; } } @@ -391,6 +421,18 @@ flex: 0 0 20px; margin-inline-end: 1px; } + input[type="radio"] { + flex: 0 0 20px; + width: 20px; + height: 20px; + margin: 3px 5px; + } + h4.form-header { + margin-block-start: 0.25em; + } + .replaced h4 { + text-decoration: rgb(200 0 0) line-through 2px; + } } form[data-type="ScaleValue"] { diff --git a/module/applications/advancement/advancement-config.mjs b/module/applications/advancement/advancement-config.mjs index 3a29962c53..c87006d25e 100644 --- a/module/applications/advancement/advancement-config.mjs +++ b/module/applications/advancement/advancement-config.mjs @@ -146,7 +146,11 @@ export default class AdvancementConfig extends FormApplication { */ static _cleanedObject(object) { return Object.entries(object).reduce((obj, [key, value]) => { - if ( value ) obj[key] = value; + let keep = false; + if ( foundry.utils.getType(value) === "Object" ) { + keep = Object.values(value).some(v => v); + } else if ( value ) keep = true; + if ( keep ) obj[key] = value; else obj[`-=${key}`] = null; return obj; }, {}); diff --git a/module/applications/advancement/item-choice-config.mjs b/module/applications/advancement/item-choice-config.mjs index 85d4d5d25d..4e1694481e 100644 --- a/module/applications/advancement/item-choice-config.mjs +++ b/module/applications/advancement/item-choice-config.mjs @@ -8,11 +8,11 @@ export default class ItemChoiceConfig extends AdvancementConfig { /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["dnd5e", "advancement", "item-choice", "two-column"], + classes: ["dnd5e", "advancement", "item-choice", "three-column"], dragDrop: [{ dropSelector: ".drop-target" }], dropKeyPath: "pool", template: "systems/dnd5e/templates/advancement/item-choice-config.hbs", - width: 540 + width: 780 }); } @@ -34,6 +34,10 @@ export default class ItemChoiceConfig extends AdvancementConfig { return obj; }, {}) }; + context.choices = Object.entries(context.levels).reduce((obj, [level, label]) => { + obj[level] = { label, ...this.advancement.configuration.choices[level] }; + return obj; + }, {}); if ( this.advancement.configuration.type === "feat" ) { const selectedType = CONFIG.DND5E.featureTypes[this.advancement.configuration.restriction.type]; context.typeRestriction = { diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 240d216c74..3672c73be7 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -24,6 +24,12 @@ export default class ItemChoiceFlow extends ItemGrantFlow { */ pool; + /** + * UUID of item to be replaced. + * @type {string} + */ + replacement; + /** * List of dropped items. * @type {Item5e[]} @@ -42,14 +48,19 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /* -------------------------------------------- */ + /** @inheritdoc */ + async retainData(data) { + await super.retainData(data); + this.replacement = data.replaced?.original; + this.selected = new Set(data.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId"))); + } + + /* -------------------------------------------- */ + /** @inheritdoc */ async getContext() { const context = {}; - - this.selected ??= new Set( - this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")) - ?? Object.values(this.advancement.value[this.level] ?? {}) - ); + this.selected ??= new Set(Object.values(this.advancement.value[this.level] ?? {})); this.pool ??= await Promise.all(this.advancement.configuration.pool.map(i => fromUuid(i.uuid))); if ( !this.dropped ) { this.dropped = []; @@ -62,15 +73,37 @@ export default class ItemChoiceFlow extends ItemGrantFlow { } } - const max = this.advancement.configuration.choices[this.level]; + const levelConfig = this.advancement.configuration.choices[this.level]; + let max = levelConfig.count ?? 0; + if ( levelConfig.replacement && this.replacement ) max++; + if ( this.selected.size > max ) { + this.selected = new Set(Array.from(this.selected).slice(0, max)); + } context.choices = { max, current: this.selected.size, full: this.selected.size >= max }; + context.replacement = levelConfig.replacement; + context.noReplacement = !this.replacement; context.previousLevels = {}; const previouslySelected = new Set(); - for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) { - if ( level > this.level ) continue; - context.previousLevels[level] = await Promise.all(Object.values(data).map(uuid => fromUuid(uuid))); - Object.values(data).forEach(uuid => previouslySelected.add(uuid)); + for ( const level of Array.fromRange(this.level - 1, 1) ) { + const added = this.advancement.value.added[level]; + if ( added ) context.previousLevels[level] = Object.entries(added).map(([id, uuid]) => { + const item = fromUuidSync(uuid); + previouslySelected.add(uuid); + return { + ...item, id, uuid, + checked: id === this.replacement, + replaced: false + }; + }); + const replaced = this.advancement.value.replaced[level]; + if ( replaced ) { + const match = context.previousLevels[replaced.level].find(v => v.id === replaced.original); + if ( match ) { + match.replaced = true; + previouslySelected.delete(match.uuid); + } + } } context.items = [...this.pool, ...this.dropped].reduce((items, i) => { @@ -106,6 +139,7 @@ export default class ItemChoiceFlow extends ItemGrantFlow { if ( event.target.checked ) this.selected.add(event.target.name); else this.selected.delete(event.target.name); } + else if ( event.target.type === "radio" ) this.replacement = event.target.value; else if ( event.target.name === "ability" ) this.ability = event.target.value; this.render(); } @@ -130,7 +164,7 @@ export default class ItemChoiceFlow extends ItemGrantFlow { /** @inheritdoc */ async _onDrop(event) { - if ( this.selected.size >= this.advancement.configuration.choices[this.level] ) return false; + if ( this.selected.size >= this.advancement.configuration.choices[this.level].count ) return false; // Try to extract the data let data; diff --git a/module/data/advancement/_module.mjs b/module/data/advancement/_module.mjs index 72b0388fd9..1e8c8a5ce5 100644 --- a/module/data/advancement/_module.mjs +++ b/module/data/advancement/_module.mjs @@ -2,7 +2,7 @@ export {default as BaseAdvancement} from "./base-advancement.mjs"; export {default as SpellConfigurationData} from "./spell-config.mjs"; export * from "./ability-score-improvement.mjs"; -export {default as ItemChoiceConfigurationData} from "./item-choice.mjs"; +export * from "./item-choice.mjs"; export {default as ItemGrantConfigurationData} from "./item-grant.mjs"; export * as scaleValue from "./scale-value.mjs"; export * from "./size.mjs"; diff --git a/module/data/advancement/item-choice.mjs b/module/data/advancement/item-choice.mjs index 372dae766c..1d6c98729a 100644 --- a/module/data/advancement/item-choice.mjs +++ b/module/data/advancement/item-choice.mjs @@ -1,30 +1,66 @@ import { MappingField } from "../fields.mjs"; import SpellConfigurationData from "./spell-config.mjs"; -export default class ItemChoiceConfigurationData extends foundry.abstract.DataModel { +const { + ArrayField, BooleanField, EmbeddedDataField, ForeignDocumentField, NumberField, SchemaField, StringField +} = foundry.data.fields; + +/** + * Configuration data for choice levels. + * + * @typedef {object} ItemChoiceLevelConfig + * @property {number} count Number of items a player can select at this level. + * @property {boolean} replacement Can a player replace previous selections at this level? + */ + +/** + * Configuration data for an individual pool entry. + * + * @typedef {object} ItemChoicePoolEntry + * @property {string} uuid UUID of the item to present as a choice. + */ + +/** + * Configuration data for Item Choice advancement. + * + * @property {string} hint Brief hint about the choice to be made. + * @property {Record} choices Choices & config for specific levels. + * @property {boolean} allowDrops Should players be able to drop non-listed items? + * @property {string} type Type of item allowed, if it should be restricted. + * @property {ItemChoicePoolEntry[]} pool Items that can be chosen. + * @property {SpellConfigurationData} spell Mutations applied to spell items. + * @property {object} restriction + * @property {string} restriction.type Specific item type allowed. + * @property {string} restriction.subtype Item sub-type allowed. + * @property {"available"|number} restriction.level Level of spell allowed. + */ +export class ItemChoiceConfigurationData extends foundry.abstract.DataModel { /** @inheritDoc */ static defineSchema() { return { - hint: new foundry.data.fields.StringField({label: "DND5E.AdvancementHint"}), - choices: new MappingField(new foundry.data.fields.NumberField(), { + hint: new StringField({label: "DND5E.AdvancementHint"}), + choices: new MappingField(new SchemaField({ + count: new NumberField({integer: true, min: 0}), + replacement: new BooleanField({label: "DND5E.AdvancementItemChoiceReplacement"}) + }), { hint: "DND5E.AdvancementItemChoiceLevelsHint" }), - allowDrops: new foundry.data.fields.BooleanField({ + allowDrops: new BooleanField({ initial: true, label: "DND5E.AdvancementConfigureAllowDrops", hint: "DND5E.AdvancementConfigureAllowDropsHint" }), - type: new foundry.data.fields.StringField({ + type: new StringField({ blank: false, nullable: true, initial: null, label: "DND5E.AdvancementItemChoiceType", hint: "DND5E.AdvancementItemChoiceTypeHint" }), - pool: new foundry.data.fields.ArrayField(new foundry.data.fields.SchemaField({ - uuid: new foundry.data.fields.StringField() + pool: new ArrayField(new SchemaField({ + uuid: new StringField() }), {label: "DOCUMENT.Items"}), - spell: new foundry.data.fields.EmbeddedDataField(SpellConfigurationData, {nullable: true, initial: null}), - restriction: new foundry.data.fields.SchemaField({ - type: new foundry.data.fields.StringField({label: "DND5E.Type"}), - subtype: new foundry.data.fields.StringField({label: "DND5E.Subtype"}), - level: new foundry.data.fields.StringField({label: "DND5E.SpellLevel"}) + spell: new EmbeddedDataField(SpellConfigurationData, {nullable: true, initial: null}), + restriction: new SchemaField({ + type: new StringField({label: "DND5E.Type"}), + subtype: new StringField({label: "DND5E.Subtype"}), + level: new StringField({label: "DND5E.SpellLevel"}) }) }; } @@ -35,9 +71,41 @@ export default class ItemChoiceConfigurationData extends foundry.abstract.DataMo /** @inheritDoc */ static migrateData(source) { + if ( "choices" in source ) Object.entries(source.choices).forEach(([k, c]) => { + if ( foundry.utils.getType(c) === "number" ) source.choices[k] = { count: c }; + }); if ( "pool" in source ) { source.pool = source.pool.map(i => foundry.utils.getType(i) === "string" ? { uuid: i } : i); } return source; } } + +/** + * Data for a replacement. + * + * @typedef {object} ItemChoiceReplacement + * @property {number} level Level at which the original item originated. + * @property {string} original ID of the original item that was replaced. + * @property {string} replacement ID of the replacement item. + */ + +/** + * Value data for Item Choice advancement. + * + * @property {Record>} added Mapping of IDs to UUIDs for items added at each level. + * @property {Record} replaced Information on items replaced at each level. + */ +export class ItemChoiceValueData extends foundry.abstract.DataModel { + /** @inheritDoc */ + static defineSchema() { + return { + added: new MappingField(new MappingField(new StringField())), + replaced: new MappingField(new SchemaField({ + level: new NumberField({integer: true, min: 0}), + original: new ForeignDocumentField(foundry.documents.BaseItem, {idOnly: true}), + replacement: new ForeignDocumentField(foundry.documents.BaseItem, {idOnly: true}) + })) + }; + } +} diff --git a/module/documents/advancement/ability-score-improvement.mjs b/module/documents/advancement/ability-score-improvement.mjs index 2e4ecd86bd..f4eda47f9d 100644 --- a/module/documents/advancement/ability-score-improvement.mjs +++ b/module/documents/advancement/ability-score-improvement.mjs @@ -153,16 +153,7 @@ export default class AbilityScoreImprovementAdvancement extends Advancement { else { let itemData = data.retainedItems?.[data.featUuid]; - if ( !itemData ) { - const source = await fromUuid(data.featUuid); - if ( source ) { - itemData = source.clone({ - _id: foundry.utils.randomID(), - "flags.dnd5e.sourceId": data.featUuid, - "flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}` - }, {keepId: true}).toObject(); - } - } + if ( !itemData ) itemData = await this.createItemData(data.featUuid); data.assignments = null; if ( itemData ) { data.feat = { [itemData._id]: data.featUuid }; diff --git a/module/documents/advancement/advancement.mjs b/module/documents/advancement/advancement.mjs index 9f443ea934..483282191c 100644 --- a/module/documents/advancement/advancement.mjs +++ b/module/documents/advancement/advancement.mjs @@ -340,4 +340,21 @@ export default class Advancement extends BaseAdvancement { */ async reverse(level) { } + /* -------------------------------------------- */ + + /** + * Fetch an item and create a clone with the proper flags. + * @param {string} uuid UUID of the item to fetch. + * @param {string} [id] Optional ID to use instead of a random one. + * @returns {object|null} + */ + async createItemData(uuid, id) { + const source = await fromUuid(uuid); + if ( !source ) return null; + return source.clone({ + _id: id ?? foundry.utils.randomID(), + "flags.dnd5e.sourceId": uuid, + "flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}` + }, {keepId: true}).toObject(); + } } diff --git a/module/documents/advancement/item-choice.mjs b/module/documents/advancement/item-choice.mjs index 5d0755462f..794a68c828 100644 --- a/module/documents/advancement/item-choice.mjs +++ b/module/documents/advancement/item-choice.mjs @@ -1,7 +1,7 @@ -import ItemGrantAdvancement from "./item-grant.mjs"; import ItemChoiceConfig from "../../applications/advancement/item-choice-config.mjs"; import ItemChoiceFlow from "../../applications/advancement/item-choice-flow.mjs"; -import ItemChoiceConfigurationData from "../../data/advancement/item-choice.mjs"; +import { ItemChoiceConfigurationData, ItemChoiceValueData } from "../../data/advancement/item-choice.mjs"; +import ItemGrantAdvancement from "./item-grant.mjs"; /** * Advancement that presents the player with a choice of multiple items that they can take. Keeps track of which @@ -13,7 +13,8 @@ export default class ItemChoiceAdvancement extends ItemGrantAdvancement { static get metadata() { return foundry.utils.mergeObject(super.metadata, { dataModels: { - configuration: ItemChoiceConfigurationData + configuration: ItemChoiceConfigurationData, + value: ItemChoiceValueData }, order: 50, icon: "systems/dnd5e/icons/svg/item-choice.svg", @@ -42,14 +43,19 @@ export default class ItemChoiceAdvancement extends ItemGrantAdvancement { /** @inheritdoc */ configuredForLevel(level) { - return this.value.added?.[level] !== undefined; + return (this.value.added?.[level] !== undefined) || !this.configuration.choices[level]?.count; } /* -------------------------------------------- */ /** @inheritdoc */ titleForLevel(level, { configMode=false }={}) { - return `${this.title} (${game.i18n.localize("DND5E.AdvancementChoices")})`; + const data = this.configuration.choices[level] ?? {}; + let tag; + if ( data.count ) tag = game.i18n.format("DND5E.AdvancementItemChoiceChoose", { count: data.count }); + else if ( data.replacement ) tag = game.i18n.localize("DND5E.AdvancementItemChoiceReplacementTitle"); + else return this.title; + return `${this.title} (${tag})`; } /* -------------------------------------------- */ @@ -65,13 +71,74 @@ export default class ItemChoiceAdvancement extends ItemGrantAdvancement { /* Application Methods */ /* -------------------------------------------- */ - /** @inheritdoc */ + /** @override */ storagePath(level) { return `value.added.${level}`; } /* -------------------------------------------- */ + /** @inheritDoc */ + async apply(level, data, retainedData={}) { + let original = data.replace; + let replacement; + if ( retainedData.replaced ) { + original = retainedData.replaced.original; + replacement = retainedData.replaced.replacement; + } + delete data.replaced; + + const updates = await super.apply(level, data, retainedData); + + replacement ??= Object.keys(updates).pop(); + if ( original && replacement ) { + const replacedLevel = Object.entries(this.value.added).reverse().reduce((level, [l, v]) => { + if ( (original in v) && (Number(l) > level) ) return Number(l); + return level; + }, 0); + if ( Number.isFinite(replacedLevel) ) { + this.actor.items.delete(original); + this.updateSource({ [`value.replaced.${level}`]: { level: replacedLevel, original, replacement } }); + } + } + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + restore(level, data) { + super.restore(level, data); + + if ( data.replaced ) { + this.actor.items.delete(data.replaced.original); + this.updateSource({ [`value.replaced.${level}`]: data.replaced }); + } + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + async reverse(level) { + const retainedData = await super.reverse(level); + + const replaced = retainedData.replaced = this.value.replaced[level]; + if ( replaced ) { + const uuid = this.value.added[replaced.level][replaced.original]; + const itemData = await this.createItemData(uuid, replaced.original); + if ( itemData ) { + if ( itemData.type === "spell" ) { + foundry.utils.mergeObject(itemData, this.configuration.spell?.spellChanges ?? {}); + } + this.actor.updateSource({ items: [itemData] }); + this.updateSource({ [`value.replaced.-=${level}`]: null }); + } + } + + return retainedData; + } + + /* -------------------------------------------- */ + /** * Verify that the provided item can be used with this advancement based on the configuration. * @param {Item5e} item Item that needs to be tested. diff --git a/module/documents/advancement/item-grant.mjs b/module/documents/advancement/item-grant.mjs index fcc899992c..b668ace349 100644 --- a/module/documents/advancement/item-grant.mjs +++ b/module/documents/advancement/item-grant.mjs @@ -1,7 +1,8 @@ -import Advancement from "./advancement.mjs"; +import { filteredKeys } from "../../utils.mjs"; import ItemGrantConfig from "../../applications/advancement/item-grant-config.mjs"; import ItemGrantFlow from "../../applications/advancement/item-grant-flow.mjs"; import ItemGrantConfigurationData from "../../data/advancement/item-grant.mjs"; +import Advancement from "./advancement.mjs"; /** * Advancement that automatically grants one or more items to the player. Presents the player with the option of @@ -81,6 +82,7 @@ export default class ItemGrantAdvancement extends Advancement { * @param {object} data Data from the advancement form. * @param {object} [retainedData={}] Item data grouped by UUID. If present, this data will be used rather than * fetching new data from the source. + * @returns {object} */ async apply(level, data, retainedData={}) { const items = []; @@ -88,18 +90,12 @@ export default class ItemGrantAdvancement extends Advancement { const spellChanges = this.configuration.spell?.getSpellChanges({ ability: data.ability ?? this.retainedData?.ability ?? this.value?.ability }) ?? {}; - for ( const [uuid, selected] of Object.entries(data) ) { - if ( !selected ) continue; + for ( const uuid of filteredKeys(data) ) { let itemData = retainedData[uuid]; if ( !itemData ) { - const source = await fromUuid(uuid); - if ( !source ) continue; - itemData = source.clone({ - _id: foundry.utils.randomID(), - "flags.dnd5e.sourceId": uuid, - "flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}` - }, {keepId: true}).toObject(); + itemData = await this.createItemData(uuid); + if ( !itemData ) continue; } if ( itemData.type === "spell" ) foundry.utils.mergeObject(itemData, spellChanges); @@ -111,6 +107,7 @@ export default class ItemGrantAdvancement extends Advancement { "value.ability": data.ability, [this.storagePath(level)]: updates }); + return updates; } /* -------------------------------------------- */ diff --git a/templates/advancement/item-choice-config.hbs b/templates/advancement/item-choice-config.hbs index b2edea5f48..dd0c501460 100644 --- a/templates/advancement/item-choice-config.hbs +++ b/templates/advancement/item-choice-config.hbs @@ -1,28 +1,28 @@ -
        +
        - {{> "dnd5e.advancement-controls"}} + {{> "dnd5e.advancement-controls" }}
        - - + +
        - +
        - +
        -

        {{localize "DND5E.AdvancementConfigureAllowDropsHint"}}

        +

        {{ localize "DND5E.AdvancementConfigureAllowDropsHint" }}

        - +
        -

        {{localize "DND5E.AdvancementItemChoiceTypeHint"}}

        +

        {{ localize "DND5E.AdvancementItemChoiceTypeHint" }}

        {{#if typeRestriction}} @@ -30,18 +30,19 @@
        {{#if typeRestriction.subtypeOptions}}
        - +
        @@ -50,15 +51,17 @@ {{#if showSpellConfig}}
        - +
        @@ -67,40 +70,43 @@ {{> "dnd5e.advancement-spell-config"}} {{/if}} +
        -
        -
          -
        1. {{localize "DOCUMENT.Items"}}

        2. -
            - {{#each configuration.pool}} -
          1. -
            {{{ dnd5e-linkForUuid uuid }}}
            -
            - - - -
            -
          2. - {{/each}} -
          +
          +
            +
          1. {{ localize "DOCUMENT.Items" }}

          2. +
              + {{#each configuration.pool}} +
            1. +
              {{{ dnd5e-linkForUuid uuid }}}
              +
              + + + +
              +
            2. + {{/each}}
            - - {{#if showContainerWarning}} -

            {{localize "DND5E.AdvancementItemGrantContainerWarning"}}

            - {{/if}} -

            {{localize "DND5E.AdvancementConfigureDropAreaHint"}}

            -
          +
        + + {{#if showContainerWarning}} +

        {{ localize "DND5E.AdvancementItemGrantContainerWarning" }}

        + {{/if}} +

        {{ localize "DND5E.AdvancementConfigureDropAreaHint" }}

        -

        {{localize "DND5E.AdvancementItemChoiceLevelsHint"}}

        - {{#each levels as |label level|}} +

        {{ localize "DND5E.AdvancementItemChoiceLevelsHint" }}

        + {{#each choices}}
        - +
        - {{numberInput (lookup ../configuration.choices level) placeholder="0" - name=(concat "configuration.choices." level) min=1 step=1}} + {{ numberInput count name=(concat "configuration.choices." @key ".count") + placeholder="0" min=1 step=1 }} +
        {{/each}} diff --git a/templates/advancement/item-choice-flow.hbs b/templates/advancement/item-choice-flow.hbs index 2e57bcaa59..6a8e1b8fd9 100644 --- a/templates/advancement/item-choice-flow.hbs +++ b/templates/advancement/item-choice-flow.hbs @@ -1,10 +1,19 @@ -

        {{{title}}}

        +

        {{{ title }}}

        {{#if advancement.configuration.hint}} -

        {{advancement.configuration.hint}}

        +

        {{ advancement.configuration.hint }}

        + {{/if}} + + {{#if replacement}} +
        + +
        {{/if}} {{#if abilities.options}} @@ -17,40 +26,46 @@ {{/if}} {{#each previousLevels}} -

        {{localize "DND5E.AdvancementLevelHeader" level=@key}}

        +

        {{ localize "DND5E.AdvancementLevelHeader" level=@key }}

        {{#each this}} -
        -
        - +
        +
        +
        {{/each}} {{/each}}

        - {{localize "DND5E.AdvancementItemChoiceChosen" current=choices.current max=choices.max}} + {{ localize "DND5E.AdvancementItemChoiceChosen" current=choices.current max=choices.max }}

        {{#each items}}
        -
        +
        {{/each}} {{#if advancement.configuration.allowDrops}} -

        {{localize "DND5E.AdvancementFlowDropAreaHint"}}

        +

        {{ localize "DND5E.AdvancementFlowDropAreaHint" }}

        {{/if}}
        From 444fdf29af6e5af80c3c8872cf67869eeb13cc9a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Apr 2024 14:29:36 -0700 Subject: [PATCH 125/199] [#2299] Fix code and template issues --- lang/en.json | 1 + less/v1/advancement.less | 7 ++++-- .../advancement/advancement-config.mjs | 1 + module/data/advancement/item-choice.mjs | 2 +- module/documents/advancement/item-choice.mjs | 9 ++----- templates/advancement/item-choice-config.hbs | 24 +++++++++++++------ 6 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lang/en.json b/lang/en.json index 708e3f06d1..8fb3c2629d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -197,6 +197,7 @@ "DND5E.AdvancementHitPointsRollButton": "Roll {die}", "DND5E.AdvancementItemChoiceTitle": "Choose Items", "DND5E.AdvancementItemChoiceHint": "Present the player with a choice of items (such as equipment, features, or spells) that they can choose for their character at one or more levels.", +"DND5E.AdvancementItemChoiceChoices": "Choices", "DND5E.AdvancementItemChoiceChoose": "choose {count}", "DND5E.AdvancementItemChoiceChosen": "Chosen: {current} of {max}", "DND5E.AdvancementItemChoiceFeatureLevelWarning": "Must be at least level {level} to take this feature.", diff --git a/less/v1/advancement.less b/less/v1/advancement.less index 5b989ce828..e483be57c1 100644 --- a/less/v1/advancement.less +++ b/less/v1/advancement.less @@ -224,8 +224,11 @@ --grid-two-column-center-size: 0.7fr; --grid-two-column-right-size: 0.5fr; - .level-list .hint { - text-align: end; + .level-list { + .items-header { padding-inline: 2px; } + .level-header { flex: 0.5; } + .choices-header { flex: 3; } + .replacement-header { flex: 0 0 20px; } } .form-group:has(textarea) { diff --git a/module/applications/advancement/advancement-config.mjs b/module/applications/advancement/advancement-config.mjs index c87006d25e..d6e16b45ba 100644 --- a/module/applications/advancement/advancement-config.mjs +++ b/module/applications/advancement/advancement-config.mjs @@ -79,6 +79,7 @@ export default class AdvancementConfig extends FormApplication { if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0]; else levels[0] = game.i18n.localize("DND5E.AdvancementLevelAnyHeader"); const context = { + appId: this.id, CONFIG: CONFIG.DND5E, ...this.advancement.toObject(false), src: this.advancement.toObject(), diff --git a/module/data/advancement/item-choice.mjs b/module/data/advancement/item-choice.mjs index 1d6c98729a..e16a0e23b2 100644 --- a/module/data/advancement/item-choice.mjs +++ b/module/data/advancement/item-choice.mjs @@ -85,7 +85,7 @@ export class ItemChoiceConfigurationData extends foundry.abstract.DataModel { * Data for a replacement. * * @typedef {object} ItemChoiceReplacement - * @property {number} level Level at which the original item originated. + * @property {number} level Level at which the original item was chosen. * @property {string} original ID of the original item that was replaced. * @property {string} replacement ID of the replacement item. */ diff --git a/module/documents/advancement/item-choice.mjs b/module/documents/advancement/item-choice.mjs index 794a68c828..49f470e31e 100644 --- a/module/documents/advancement/item-choice.mjs +++ b/module/documents/advancement/item-choice.mjs @@ -79,14 +79,9 @@ export default class ItemChoiceAdvancement extends ItemGrantAdvancement { /* -------------------------------------------- */ /** @inheritDoc */ - async apply(level, data, retainedData={}) { - let original = data.replace; + async apply(level, { replace: original, ...data }, retainedData={}) { let replacement; - if ( retainedData.replaced ) { - original = retainedData.replaced.original; - replacement = retainedData.replaced.replacement; - } - delete data.replaced; + if ( retainedData.replaced ) ({ original, replacement } = retainedData.replaced); const updates = await super.apply(level, data, retainedData); diff --git a/templates/advancement/item-choice-config.hbs b/templates/advancement/item-choice-config.hbs index dd0c501460..7253f4a93e 100644 --- a/templates/advancement/item-choice-config.hbs +++ b/templates/advancement/item-choice-config.hbs @@ -75,7 +75,6 @@
        1. {{ localize "DOCUMENT.Items" }}

        2. -
            {{#each configuration.pool}}
          1. {{{ dnd5e-linkForUuid uuid }}}
            @@ -87,7 +86,6 @@
      • {{/each}} - {{#if showContainerWarning}} @@ -97,16 +95,28 @@
        -

        {{ localize "DND5E.AdvancementItemChoiceLevelsHint" }}

        +
          +
        1. + + {{ localize "DND5E.AbbreviationLevel" }} + + + {{ localize "DND5E.AdvancementItemChoiceChoices" }} + + + + +
        2. +
        {{#each choices}}
        - {{ numberInput count name=(concat "configuration.choices." @key ".count") - placeholder="0" min=1 step=1 }} + + {{ checked replacement }} aria-labelledby="{{ @root.appId }}-replacement">
        {{/each}} From 78a1afedcb8c817e28aafb31b819a1c0a9487f0f Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 30 Apr 2024 15:29:01 -0700 Subject: [PATCH 126/199] [#2299] Properly handle modifying earlier replacements --- lang/en.json | 1 + .../applications/advancement/item-choice-flow.mjs | 6 +++--- module/documents/advancement/item-choice.mjs | 6 ++++++ module/documents/advancement/item-grant.mjs | 13 +++++++------ templates/advancement/item-choice-flow.hbs | 4 ++-- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lang/en.json b/lang/en.json index 8fb3c2629d..776c540c30 100644 --- a/lang/en.json +++ b/lang/en.json @@ -202,6 +202,7 @@ "DND5E.AdvancementItemChoiceChosen": "Chosen: {current} of {max}", "DND5E.AdvancementItemChoiceFeatureLevelWarning": "Must be at least level {level} to take this feature.", "DND5E.AdvancementItemChoiceLevelsHint": "Specify how many choices are allowed at each level.", +"DND5E.AdvancementItemChoiceNoOriginalError": "Previously selected choice no longer available for replacement.", "DND5E.AdvancementItemChoicePreviouslyChosenWarning": "This item has already been chosen at a previous level.", "DND5E.AdvancementItemChoiceReplacement": "Allow Replacement", "DND5E.AdvancementItemChoiceReplacementNone": "No Replacement", diff --git a/module/applications/advancement/item-choice-flow.mjs b/module/applications/advancement/item-choice-flow.mjs index 3672c73be7..8e91e98e06 100644 --- a/module/applications/advancement/item-choice-flow.mjs +++ b/module/applications/advancement/item-choice-flow.mjs @@ -75,13 +75,13 @@ export default class ItemChoiceFlow extends ItemGrantFlow { const levelConfig = this.advancement.configuration.choices[this.level]; let max = levelConfig.count ?? 0; - if ( levelConfig.replacement && this.replacement ) max++; + context.replaceable = levelConfig.replacement; + context.noReplacement = !this.advancement.actor.items.has(this.replacement); + if ( context.replaceable && !context.noReplacement ) max++; if ( this.selected.size > max ) { this.selected = new Set(Array.from(this.selected).slice(0, max)); } context.choices = { max, current: this.selected.size, full: this.selected.size >= max }; - context.replacement = levelConfig.replacement; - context.noReplacement = !this.replacement; context.previousLevels = {}; const previouslySelected = new Set(); diff --git a/module/documents/advancement/item-choice.mjs b/module/documents/advancement/item-choice.mjs index 49f470e31e..a8c1216940 100644 --- a/module/documents/advancement/item-choice.mjs +++ b/module/documents/advancement/item-choice.mjs @@ -102,9 +102,15 @@ export default class ItemChoiceAdvancement extends ItemGrantAdvancement { /** @inheritdoc */ restore(level, data) { + const original = this.actor.items.get(data.replaced?.original); + if ( data.replaced && !original ) data.items = data.items.filter(i => i._id !== data.replaced.replacement); + super.restore(level, data); if ( data.replaced ) { + if ( !original ) { + throw new ItemChoiceAdvancement.ERROR(game.i18n.localize("DND5E.AdvancementItemChoiceNoOriginalError")); + } this.actor.items.delete(data.replaced.original); this.updateSource({ [`value.replaced.${level}`]: data.replaced }); } diff --git a/module/documents/advancement/item-grant.mjs b/module/documents/advancement/item-grant.mjs index b668ace349..7368e04c99 100644 --- a/module/documents/advancement/item-grant.mjs +++ b/module/documents/advancement/item-grant.mjs @@ -91,7 +91,6 @@ export default class ItemGrantAdvancement extends Advancement { ability: data.ability ?? this.retainedData?.ability ?? this.value?.ability }) ?? {}; for ( const uuid of filteredKeys(data) ) { - let itemData = retainedData[uuid]; if ( !itemData ) { itemData = await this.createItemData(uuid); @@ -102,11 +101,13 @@ export default class ItemGrantAdvancement extends Advancement { items.push(itemData); updates[itemData._id] = uuid; } - this.actor.updateSource({items}); - this.updateSource({ - "value.ability": data.ability, - [this.storagePath(level)]: updates - }); + if ( items.length ) { + this.actor.updateSource({ items }); + this.updateSource({ + "value.ability": data.ability, + [this.storagePath(level)]: updates + }); + } return updates; } diff --git a/templates/advancement/item-choice-flow.hbs b/templates/advancement/item-choice-flow.hbs index 6a8e1b8fd9..e2d74c1b7b 100644 --- a/templates/advancement/item-choice-flow.hbs +++ b/templates/advancement/item-choice-flow.hbs @@ -7,7 +7,7 @@

        {{ advancement.configuration.hint }}

        {{/if}} - {{#if replacement}} + {{#if replaceable}}
        {{/if}} + {{#if (ne applyEnchantment null)}} +
        + + {{#if enchantmentOptions.enchantments}} +
        + +
        + {{else}} + + {{/if}} +
        + {{/if}} + {{#if (ne createSummons null)}}
        From b133e1a4b9a987ce966e2edac77ab7aa9c9c0cc8 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 3 May 2024 15:18:53 -0700 Subject: [PATCH 141/199] [#3305] Remove tray, track enchantments in chat card When enchantments are added via the chat card, the enchantment UUIDs are now stored back in the card in a flag and all items will be displayed on the card. This gives a "undo" control to remove the added enchantment from an item. --- lang/en.json | 3 +- less/v2/chat.less | 76 +++++----- .../components/enchantment-application.mjs | 132 ++++++++---------- .../applications/item/ability-use-dialog.mjs | 4 +- module/documents/active-effect.mjs | 15 ++ 5 files changed, 121 insertions(+), 109 deletions(-) diff --git a/lang/en.json b/lang/en.json index c8c8e57c3e..4b0a83c57b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -695,7 +695,8 @@ "Delete": "Delete Enchantment", "Disable": "Disable Enchantment", "Edit": "Edit Enchantment", - "Enable": "Enable Enchantment" + "Enable": "Enable Enchantment", + "Remove": "Remove Enchantment" }, "Category": { "Active": "Active Enchantments", diff --git a/less/v2/chat.less b/less/v2/chat.less index f149e50f1c..eb4e2cdf23 100644 --- a/less/v2/chat.less +++ b/less/v2/chat.less @@ -596,40 +596,6 @@ width: calc(100% - 6px); margin-block-start: 6px; } - - &.enchantment-tray { - .drop-area { - display: grid; - min-block-size: 40px; - margin: 4px; - border: 1px dashed var(--dnd5e-color-crimson); - border-radius: 4px; - padding-inline: 4px; - background: var(--dnd5e-color-card); - - p { - place-content: center; - text-align: center; - } - - .preview { - display: flex; - align-items: center; - gap: 6px; - - > img { - block-size: 32px; - inline-size: 32px; - flex: 0 0 32px; - } - > .name { - flex: 1; - font-family: var(--dnd5e-font-roboto-slab); - font-weight: bold; - } - } - } - } } .hidden-dc { display: contents; } @@ -639,6 +605,48 @@ .visible-dc { display: contents; } } +/* ---------------------------------- */ +/* Enchantment */ +/* ---------------------------------- */ + +enchantment-application { + .drop-area { + display: grid; + gap: 4px; + min-block-size: 40px; + margin: 4px; + border: 1px dashed var(--dnd5e-color-crimson); + border-radius: 4px; + padding: 4px; + background: var(--dnd5e-color-card); + + p { + place-content: center; + text-align: center; + } + + .preview { + display: flex; + align-items: center; + gap: 6px; + + > img { + block-size: 32px; + inline-size: 32px; + flex: 0 0 32px; + } + > .name { + flex: 1; + font-family: var(--dnd5e-font-roboto-slab); + font-weight: bold; + } + > a { + flex: 0 0 20px; + } + } + } +} + /* ---------------------------------- */ /* Target Evaluations */ /* ---------------------------------- */ diff --git a/module/applications/components/enchantment-application.mjs b/module/applications/components/enchantment-application.mjs index c8d923e13e..f81ed92373 100644 --- a/module/applications/components/enchantment-application.mjs +++ b/module/applications/components/enchantment-application.mjs @@ -23,31 +23,6 @@ export default class EnchantmentApplicationElement extends HTMLElement { /* -------------------------------------------- */ - /** - * Dropped item to be enchanted. - * @type {Item5e} - */ - #droppedItem; - - get droppedItem() { - return this.#droppedItem; - } - - set droppedItem(item) { - this.#droppedItem = item; - if ( this.#droppedItem ) { - this.dropArea.innerHTML = ` -
        - ${item.name} - ${item.name} -
        - `; - } - else this.dropArea.innerHTML = `

        ${game.i18n.localize("DND5E.Enchantment.DropArea")}

        `; - } - - /* -------------------------------------------- */ - /** * Item providing the enchantment that will be applied. * @type {Item5e} @@ -68,33 +43,49 @@ export default class EnchantmentApplicationElement extends HTMLElement { // Build the frame HTML only once if ( !this.dropArea ) { const div = document.createElement("div"); - div.classList.add("card-tray", "enchantment-tray", "collapsible", "collapsed"); - div.innerHTML = ` - -
        -
        -
        - -
        -
        - `; + div.classList.add("enchantment-control"); + div.innerHTML = '
        '; this.replaceChildren(div); - div.querySelector(".apply-enchantment").addEventListener("click", this._onApplyEnchantment.bind(this)); this.dropArea = div.querySelector(".drop-area"); - div.querySelector(".collapsible-content").addEventListener("click", event => { - event.stopImmediatePropagation(); - }); this.addEventListener("drop", this._onDrop.bind(this)); + this.addEventListener("click", this._onRemoveEnchantment.bind(this)); } - this.droppedItem = null; + this.buildItemList(); + } + + /* -------------------------------------------- */ + + /** + * Build a list of enchanted items. + */ + async buildItemList() { + const enchantedItems = (await Promise.all( + (this.chatMessage.getFlag("dnd5e", "enchanted") ?? []).map(uuid => fromUuid(uuid)) + )).filter(i => i).map(enchantment => { + const item = enchantment.parent; + const div = document.createElement("div"); + div.classList.add("preview"); + div.dataset.enchantmentUuid = enchantment.uuid; + div.innerHTML = ` + ${item.name} + ${item.name} + `; + if ( item.isOwner ) { + const control = document.createElement("a"); + control.ariaLabel = game.i18n.localize("DND5E.Enchantment.Action.Remove"); + control.dataset.action = "removeEnchantment"; + control.dataset.tooltip = "DND5E.Enchantment.Action.Remove"; + control.innerHTML = ''; + div.append(control); + } + return div; + }); + if ( enchantedItems.length ) { + this.dropArea.replaceChildren(...enchantedItems); + } else { + this.dropArea.innerHTML = `

        ${game.i18n.localize("DND5E.Enchantment.DropArea")}

        `; + } } /* -------------------------------------------- */ @@ -102,15 +93,24 @@ export default class EnchantmentApplicationElement extends HTMLElement { /* -------------------------------------------- */ /** - * Handle clicking the apply enchantment button. - * @param {PointerEvent} event Triggering click event. + * Handle dropping an item onto the control. + * @param {Event} event Triggering drop event. */ - async _onApplyEnchantment(event) { + async _onDrop(event) { event.preventDefault(); - + const data = TextEditor.getDragEventData(event); const effect = this.enchantmentItem.effects.get(this.chatMessage.getFlag("dnd5e", "use.enchantmentProfile")); - if ( !effect ) return; + if ( (data.type !== "Item") || !effect ) return; + const droppedItem = await Item.implementation.fromDropData(data); + // Validate against the enchantment's restraints on the origin item + const errors = this.enchantmentItem.system.enchantment?.canEnchant(droppedItem); + if ( errors?.length ) { + errors.forEach(err => ui.notifications.error(err.message)); + return; + } + + // If concentration is required, ensure it is still being maintained & GM is present const concentrationId = this.chatMessage.getFlag("dnd5e", "use.concentrationId"); const concentration = effect.parent.actor.effects.get(concentrationId); if ( concentrationId && !concentration ) { @@ -124,32 +124,22 @@ export default class EnchantmentApplicationElement extends HTMLElement { const effectData = effect.toObject(); effectData.origin = this.enchantmentItem.uuid; - const applied = await ActiveEffect.create(effectData, { parent: this.droppedItem, keepOrigin: true }); + const applied = await ActiveEffect.create(effectData, { + parent: droppedItem, keepOrigin: true, chatMessageOrigin: this.chatMessage.id + }); if ( concentration ) await concentration.addDependent(applied); - - this.droppedItem = null; - this.querySelector(".collapsible").dispatchEvent(new PointerEvent("click", { bubbles: true, cancelable: true })); } /* -------------------------------------------- */ /** - * Handle dropping an item onto the control. + * Handle removing an enchantment. * @param {Event} event Triggering drop event. */ - async _onDrop(event) { - event.preventDefault(); - const data = TextEditor.getDragEventData(event); - if ( data.type !== "Item" ) return; - const droppedItem = await Item.implementation.fromDropData(data); - - // Validate against the enchantment's restraints on the origin item - const errors = this.enchantmentItem.system.enchantment?.canEnchant(droppedItem); - if ( errors?.length ) { - errors.forEach(err => ui.notifications.error(err.message)); - return; - } - - this.droppedItem = droppedItem; + async _onRemoveEnchantment(event) { + if ( event.target.dataset.action !== "removeEnchantment" ) return; + const enchantmentUuid = event.target.closest("[data-enchantment-uuid]")?.dataset.enchantmentUuid; + const enchantment = await fromUuid(enchantmentUuid); + enchantment?.delete({ chatMessageOrigin: this.chatMessage.id }); } } diff --git a/module/applications/item/ability-use-dialog.mjs b/module/applications/item/ability-use-dialog.mjs index 06e9c0a804..fcd2520745 100644 --- a/module/applications/item/ability-use-dialog.mjs +++ b/module/applications/item/ability-use-dialog.mjs @@ -190,9 +190,7 @@ export default class AbilityUseDialog extends Dialog { ); if ( !enchantments.length ) return null; const options = {}; - options.enchantments = Object.fromEntries( - enchantments.map(enchantment => [enchantment._id, enchantment.name]) - ); + options.enchantments = Object.fromEntries(enchantments.map(enchantment => [enchantment._id, enchantment.name])); if ( Object.values(options.enchantments).length <= 1 ) { options.enchantments = null; options.enchantment = enchantments[0]._id; diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index 7f6cf676d6..3d5a5671d1 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -423,6 +423,14 @@ export default class ActiveEffect5e extends ActiveEffect { _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( (userId === game.userId) && this.active && (this.parent instanceof Actor) ) this.createRiderConditions(); + if ( game.users.activeGM?.isSelf && options.chatMessageOrigin ) { + const message = game.messages.get(options.chatMessageOrigin); + if ( message ) { + const enchantmentUuids = message.getFlag("dnd5e", "enchanted") ?? []; + enchantmentUuids.push(this.uuid); + message.setFlag("dnd5e", "enchanted", enchantmentUuids); + } + } } /* -------------------------------------------- */ @@ -476,6 +484,13 @@ export default class ActiveEffect5e extends ActiveEffect { super._onDelete(options, userId); if ( game.user === game.users.activeGM ) this.getDependents().forEach(e => e.delete()); if ( this.isAppliedEnchantment ) EnchantmentData.untrackEnchantment(this.origin, this.uuid); + if ( game.users.activeGM?.isSelf && options.chatMessageOrigin ) { + const message = game.messages.get(options.chatMessageOrigin); + if ( message ) { + const enchantmentUuids = message.getFlag("dnd5e", "enchanted") ?? []; + message.setFlag("dnd5e", "enchanted", enchantmentUuids.filter(uuid => uuid !== this.uuid)); + } + } } /* -------------------------------------------- */ From 6a09ae9b421740f0adc52dd60fbe3a2ebc6fd512 Mon Sep 17 00:00:00 2001 From: Hoppyhob Date: Sat, 4 May 2024 16:52:09 -0500 Subject: [PATCH 142/199] [#3380] Added .content-link to dark/character.less Added .content-link to dark/character.less Sets the background to dnd5e-color-dark-gray and the boarder to dnd5e-border-dark predefined values. Closes #3380 --- less/v2/dark/character.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/less/v2/dark/character.less b/less/v2/dark/character.less index 8a6cd1700c..c802d5a893 100644 --- a/less/v2/dark/character.less +++ b/less/v2/dark/character.less @@ -56,4 +56,9 @@ .label { font-weight: bold; } .mod { color: var(--dnd5e-color-blue-white); } } + + .content-link { + background: var(--dnd5e-color-dark-gray); + border-color: var(--dnd5e-border-dark) + } } From 536e8628bb9832d8e16c5ef0caae9aee402fa669 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 6 May 2024 10:50:52 -0700 Subject: [PATCH 143/199] [#3534] Add summoner modifier into summoned flags --- module/data/item/fields/summons-field.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index ce862de05d..3664321f88 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -302,6 +302,7 @@ export class SummonsData extends foundry.abstract.DataModel { // Add flags actorUpdates["flags.dnd5e.summon"] = { level: this.relevantLevel, + mod: rollData.mod, origin: this.item.uuid, profile: profile._id }; From 578e03272c930ba492c60de85acffa2a0db73fcf Mon Sep 17 00:00:00 2001 From: "Andrew (Atropos)" Date: Sun, 5 May 2024 17:43:54 -0400 Subject: [PATCH 144/199] Propose resolution to synchronous UUID link generation bug --- module/utils.mjs | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/module/utils.mjs b/module/utils.mjs index fca45e7012..8e8af1a6c6 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -175,33 +175,23 @@ export function indexFromUuid(uuid) { /** * Creates an HTML document link for the provided UUID. + * Try to build links to compendium content synchronously to avoid DB lookups. * @param {string} uuid UUID for which to produce the link. * @param {object} [options] * @param {string} [options.tooltip] Tooltip to add to the link. * @returns {string} Link to the item or empty string if item wasn't found. */ export function linkForUuid(uuid, { tooltip }={}) { - let element; - - if ( game.release.generation < 12 ) element = TextEditor._createContentLink(["", "UUID", uuid]); - - // TODO: When v11 support is dropped we can make this method async and return to using TextEditor._createContentLink. - else if ( uuid.startsWith("Compendium.") ) { - let [, scope, pack, documentName, id] = uuid.split("."); - if ( !CONST.PRIMARY_DOCUMENT_TYPES.includes(documentName) ) id = documentName; - const data = { - classes: ["content-link"], - attrs: { draggable: "true" } - }; - TextEditor._createLegacyContentLink("Compendium", [scope, pack, id].join("."), "", data); - data.dataset.link = ""; - element = TextEditor.createAnchor(data); + let doc = fromUuidSync(uuid); + if ( uuid.startsWith("Compendium.") && !(doc instanceof foundry.abstract.Document) ) { + const {collection} = foundry.utils.parseUuid(uuid); + const cls = collection.documentClass; + // Minimal "shell" of a document using index data + doc = new cls(foundry.utils.deepClone(doc), {pack: collection.metadata.id}); } - - else element = fromUuidSync(uuid).toAnchor(); - - if ( tooltip ) element.dataset.tooltip = tooltip; - return element.outerHTML; + const a = doc.toAnchor(); + if ( tooltip ) a.dataset.tooltip = tooltip; + return a.outerHTML; } /* -------------------------------------------- */ From 350b8f4cada3d73f0d5103baa3f21fabf62a4c60 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 6 May 2024 11:01:34 -0700 Subject: [PATCH 145/199] [#3519] Fix enchantment links not working in compendiums Adjust how enchantments are listed in the enchantment configuration app. Now, the name is listed as plain text and an edit button on the right side allows for opening the active effect sheet. --- less/v1/items.less | 3 ++- module/applications/item/enchantment-config.mjs | 5 ++++- templates/apps/enchantment-config.hbs | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/less/v1/items.less b/less/v1/items.less index 925c212595..534b501863 100644 --- a/less/v1/items.less +++ b/less/v1/items.less @@ -571,10 +571,11 @@ } .separated-list { - .content-link, .drop-area { + .content-link, .drop-area, .name { flex: 0 0 175px; display: flex; align-items: center; + align-content: center; } .drop-area { border: 1px dashed black; diff --git a/module/applications/item/enchantment-config.mjs b/module/applications/item/enchantment-config.mjs index 5bc569accf..7115745296 100644 --- a/module/applications/item/enchantment-config.mjs +++ b/module/applications/item/enchantment-config.mjs @@ -69,6 +69,7 @@ export default class EnchantmentConfig extends DocumentSheet { async _updateObject(event, { action, enchantmentId, ...formData }) { await this.document.update({"system.enchantment": formData}); + const enchantment = this.document.effects.get(enchantmentId); switch ( action ) { case "add-enchantment": const effect = await ActiveEffect.implementation.create({ @@ -79,9 +80,11 @@ export default class EnchantmentConfig extends DocumentSheet { effect.sheet.render(true); break; case "delete-enchantment": - const enchantment = this.document.effects.get(enchantmentId); enchantment?.deleteDialog(); break; + case "edit-enchantment": + enchantment?.sheet.render(true); + break; } } } diff --git a/templates/apps/enchantment-config.hbs b/templates/apps/enchantment-config.hbs index a7295014ca..bfca2571d6 100644 --- a/templates/apps/enchantment-config.hbs +++ b/templates/apps/enchantment-config.hbs @@ -10,8 +10,13 @@ {{#each enchantments}}
      • - {{{ dnd5e-linkForUuid uuid }}} + {{ name }}
        +
        +
      • {{else}}
      • {{ localize "DND5E.Enchantment.Category.Empty" }}
      • {{/each}}
      + {{#unless @root.isSpell}} +
      + + +

      {{ localize "DND5E.Enchantment.FIELDS.enchantment.classIdentifier.hint" }}

      +
      + {{/unless}} +

      {{ localize "DND5E.Enchantment.FIELDS.enchantment.restrictions.label" }}

      diff --git a/templates/apps/summoning-config.hbs b/templates/apps/summoning-config.hbs index 1e238309f3..8cd8eeaf76 100644 --- a/templates/apps/summoning-config.hbs +++ b/templates/apps/summoning-config.hbs @@ -35,19 +35,19 @@
      From 0174e0b2484c03c118185af26d2b2a7815719a69 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 8 May 2024 13:48:14 -0700 Subject: [PATCH 151/199] [#3511] Adjust language to match rider conditions --- lang/en.json | 2 +- .../applications/item/enchantment-config.mjs | 16 +++--- module/documents/active-effect.mjs | 50 ++++++++++--------- module/documents/item.mjs | 4 +- templates/apps/enchantment-config.hbs | 8 +-- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/lang/en.json b/lang/en.json index d66a61b6a1..4fc9c09ce1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -750,7 +750,7 @@ "Label": "Enchant Prompt", "Hint": "Disable the enchantment prompt when item is used. First available enchantment will always be applied." }, - "RideAlong": { + "Riders": { "Label": "Ride Along Effects", "Hint": "These additional effects will be added when this enchantment is added, and removed when it is removed." }, diff --git a/module/applications/item/enchantment-config.mjs b/module/applications/item/enchantment-config.mjs index 26d30ec2cb..444986434a 100644 --- a/module/applications/item/enchantment-config.mjs +++ b/module/applications/item/enchantment-config.mjs @@ -64,8 +64,8 @@ export default class EnchantmentConfig extends DocumentSheet { uuid: effect.uuid, flags: effect.flags, collapsed: this.expandedEnchantments.get(effect.id) ? "" : "collapsed", - rideAlong: effects.map(({ id, name }) => ({ - id, name, selected: effect.flags.dnd5e?.enchantment?.rideAlong?.includes(id) ? "selected" : "" + riders: effects.map(({ id, name }) => ({ + id, name, selected: effect.flags.dnd5e?.enchantment?.riders?.includes(id) ? "selected" : "" })) })); @@ -110,21 +110,21 @@ export default class EnchantmentConfig extends DocumentSheet { await this.document.update({"system.enchantment": data}); - const rideAlongIds = new Set(); + const riderIds = new Set(); const effectsChanges = Object.entries(effects ?? {}).map(([_id, changes]) => { const updates = { _id, ...changes }; // Fix bug with in V11 - if ( !foundry.utils.hasProperty(updates, "flags.dnd5e.enchantment.rideAlong") ) { - foundry.utils.setProperty(updates, "flags.dnd5e.enchantment.rideAlong", []); + if ( !foundry.utils.hasProperty(updates, "flags.dnd5e.enchantment.riders") ) { + foundry.utils.setProperty(updates, "flags.dnd5e.enchantment.riders", []); } // End bug fix - rideAlongIds.add(...foundry.utils.getProperty(updates, "flags.dnd5e.enchantment.rideAlong", [])); + riderIds.add(...foundry.utils.getProperty(updates, "flags.dnd5e.enchantment.riders", [])); return updates; }); for ( const effect of this.document.effects ) { if ( effect.getFlag("dnd5e", "type") === "enchantment" ) continue; - if ( rideAlongIds.has(effect.id) ) effectsChanges.push({ _id: effect.id, "flags.dnd5e.rideAlong": true }); - else effectsChanges.push({ _id: effect.id, "flags.dnd5e.-=rideAlong": null }); + if ( riderIds.has(effect.id) ) effectsChanges.push({ _id: effect.id, "flags.dnd5e.rider": true }); + else effectsChanges.push({ _id: effect.id, "flags.dnd5e.-=rider": null }); } if ( effectsChanges.length ) await this.document.updateEmbeddedDocuments("ActiveEffect", effectsChanges); diff --git a/module/documents/active-effect.mjs b/module/documents/active-effect.mjs index bef6e417e3..3c700bff98 100644 --- a/module/documents/active-effect.mjs +++ b/module/documents/active-effect.mjs @@ -327,7 +327,7 @@ export default class ActiveEffect5e extends ActiveEffect { /** @inheritDoc */ prepareDerivedData() { super.prepareDerivedData(); - if ( this.getFlag("dnd5e", "type") === "enchantment" || this.getFlag("dnd5e", "rideAlong") ) this.transfer = false; + if ( this.getFlag("dnd5e", "type") === "enchantment" || this.getFlag("dnd5e", "rider") ) this.transfer = false; if ( this.id === this.constructor.ID.EXHAUSTION ) this._prepareExhaustionLevel(); if ( this.isAppliedEnchantment ) EnchantmentData.trackEnchantment(this.origin, this.uuid); } @@ -391,6 +391,26 @@ export default class ActiveEffect5e extends ActiveEffect { return Promise.all(Array.from(riders).map(createRider)); } + /* -------------------------------------------- */ + + /** + * Create additional effects that are applied separately from an enchantment. + */ + async createRiderEnchantments() { + const origin = await fromUuid(this.origin); + const riders = (this.getFlag("dnd5e", "enchantment.riders") ?? []).map(id => { + const effectData = origin.effects.get(id)?.toObject(); + if ( effectData ) { + delete effectData._id; + delete effectData.flags?.dnd5e?.rider; + effectData.origin = this.origin; + } + return effectData; + }).filter(e => e); + const created = await this.parent.createEmbeddedDocuments("ActiveEffect", riders); + if ( created.length ) this.addDependent(...created); + } + /* -------------------------------------------- */ /* Socket Event Handlers */ /* -------------------------------------------- */ @@ -420,9 +440,12 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ /** @inheritdoc */ - _onCreate(data, options, userId) { + async _onCreate(data, options, userId) { super._onCreate(data, options, userId); - if ( (userId === game.userId) && this.active && (this.parent instanceof Actor) ) this.createRiderConditions(); + if ( userId === game.userId ) { + if ( this.active && (this.parent instanceof Actor) ) await this.createRiderConditions(); + if ( this.isAppliedEnchantment ) await this.createRiderEnchantments(); + } if ( options.chatMessageOrigin ) { document.body.querySelectorAll(`[data-message-id="${options.chatMessageOrigin}"] enchantment-application`) .forEach(element => element.buildItemList()); @@ -431,27 +454,6 @@ export default class ActiveEffect5e extends ActiveEffect { /* -------------------------------------------- */ - /** @inheritDoc */ - async _onCreate(data, options, userId) { - await super._onCreate(data, options, userId); - if ( (userId !== game.user.id) || !this.isAppliedEnchantment ) return; - - const origin = await fromUuid(this.origin); - const rideAlongEffects = (this.getFlag("dnd5e", "enchantment.rideAlong") ?? []).map(id => { - const effectData = origin.effects.get(id)?.toObject(); - if ( effectData ) { - delete effectData._id; - delete effectData.flags?.dnd5e?.rideAlong; - effectData.origin = this.origin; - } - return effectData; - }).filter(e => e); - const created = await this.parent.createEmbeddedDocuments("ActiveEffect", rideAlongEffects); - if ( created.length ) this.addDependent(...created); - } - - /* -------------------------------------------- */ - /** @inheritDoc */ _onUpdate(data, options, userId) { super._onUpdate(data, options, userId); diff --git a/module/documents/item.mjs b/module/documents/item.mjs index df9a6651ff..806df47a74 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1498,9 +1498,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { config: CONFIG.DND5E, tokenId: token?.uuid || null, item: this, - effects: this.effects.filter(e => - (e.getFlag("dnd5e", "type") !== "enchantment") && !e.getFlag("dnd5e", "rideAlong") - ), + effects: this.effects.filter(e => (e.getFlag("dnd5e", "type") !== "enchantment") && !e.getFlag("dnd5e", "rider")), data: await this.system.getCardData(), labels: this.labels, hasAttack: this.hasAttack, diff --git a/templates/apps/enchantment-config.hbs b/templates/apps/enchantment-config.hbs index b6c5690373..25b3800ec0 100644 --- a/templates/apps/enchantment-config.hbs +++ b/templates/apps/enchantment-config.hbs @@ -41,13 +41,13 @@

      {{ localize "DND5E.Enchantment.Level.Hint" }}

      - - - {{#each rideAlong}} + + + {{#each riders}} {{/each}} -

      {{ localize "DND5E.Enchantment.RideAlong.Hint" }}

      +

      {{ localize "DND5E.Enchantment.Riders.Hint" }}

      From 4edf6ea9c8f040cc315a746683d51be561056e48 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 8 May 2024 13:58:49 -0700 Subject: [PATCH 152/199] [#3500] Allow ordering of inventory categories to be set Introduces a new `inventoryOrder` metadata property that is used to specify which order inventory categories will appear. This value will only be used if `inventoryItem` is also set in the metadata. If not specified, category will be sorted to the end of the list. --- module/applications/actor/character-sheet.mjs | 6 ++++-- module/data/abstract.mjs | 2 ++ module/data/item/consumable.mjs | 3 ++- module/data/item/container.mjs | 3 ++- module/data/item/equipment.mjs | 3 ++- module/data/item/loot.mjs | 3 ++- module/data/item/tool.mjs | 3 ++- module/data/item/weapon.mjs | 3 ++- 8 files changed, 18 insertions(+), 8 deletions(-) diff --git a/module/applications/actor/character-sheet.mjs b/module/applications/actor/character-sheet.mjs index e2394e42ef..ecbd9bdebd 100644 --- a/module/applications/actor/character-sheet.mjs +++ b/module/applications/actor/character-sheet.mjs @@ -54,8 +54,10 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Categorize items as inventory, spellbook, features, and classes const inventory = {}; - for ( const [type, model] of Object.entries(CONFIG.Item.dataModels) ) { - if ( !model.metadata?.inventoryItem ) continue; + const inventoryTypes = Object.entries(CONFIG.Item.dataModels) + .filter(([, model]) => model.metadata?.inventoryItem) + .sort(([, lhs], [, rhs]) => (lhs.metadata.inventoryOrder - rhs.metadata.inventoryOrder)); + for ( const [type] of inventoryTypes ) { inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}}; } diff --git a/module/data/abstract.mjs b/module/data/abstract.mjs index 8c8a4118cf..b05076f052 100644 --- a/module/data/abstract.mjs +++ b/module/data/abstract.mjs @@ -362,6 +362,7 @@ export class ItemDataModel extends SystemDataModel { * @typedef {SystemDataModelMetadata} ItemDataModelMetadata * @property {boolean} enchantable Can this item be modified by enchantment effects? * @property {boolean} inventoryItem Should this item be listed with an actor's inventory? + * @property {number} inventoryOrder Order this item appears in the actor's inventory, smaller numbers are earlier. * @property {boolean} singleton Should only a single item of this type be allowed on an actor? */ @@ -369,6 +370,7 @@ export class ItemDataModel extends SystemDataModel { static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: false, inventoryItem: false, + inventoryOrder: Infinity, singleton: false }, {inplace: false})); diff --git a/module/data/item/consumable.mjs b/module/data/item/consumable.mjs index 574b2b72be..696281da97 100644 --- a/module/data/item/consumable.mjs +++ b/module/data/item/consumable.mjs @@ -47,7 +47,8 @@ export default class ConsumableData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 300 }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/container.mjs b/module/data/item/container.mjs index d6945fd1ad..f3229aec61 100644 --- a/module/data/item/container.mjs +++ b/module/data/item/container.mjs @@ -44,7 +44,8 @@ export default class ContainerData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 500 }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/equipment.mjs b/module/data/item/equipment.mjs index 19fa2a35b9..91db96bfaf 100644 --- a/module/data/item/equipment.mjs +++ b/module/data/item/equipment.mjs @@ -67,7 +67,8 @@ export default class EquipmentData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 200 }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/loot.mjs b/module/data/item/loot.mjs index 6d91fee610..2faf66ca2d 100644 --- a/module/data/item/loot.mjs +++ b/module/data/item/loot.mjs @@ -30,7 +30,8 @@ export default class LootData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 600 }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/tool.mjs b/module/data/item/tool.mjs index 7540d1fa7d..36a701ae36 100644 --- a/module/data/item/tool.mjs +++ b/module/data/item/tool.mjs @@ -49,7 +49,8 @@ export default class ToolData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 400 }, {inplace: false})); /* -------------------------------------------- */ diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 04a1c99fb4..5d3caf203e 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -48,7 +48,8 @@ export default class WeaponData extends ItemDataModel.mixin( /** @inheritdoc */ static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, { enchantable: true, - inventoryItem: true + inventoryItem: true, + inventoryOrder: 100 }, {inplace: false})); /* -------------------------------------------- */ From c7033618066f69553f04228c9f29d70a56149404 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 8 May 2024 15:35:54 -0700 Subject: [PATCH 153/199] [#3305] Move enchantment area to above footer --- module/documents/chat-message.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs index 633b2f6300..8b97002c09 100644 --- a/module/documents/chat-message.mjs +++ b/module/documents/chat-message.mjs @@ -443,7 +443,8 @@ export default class ChatMessage5e extends ChatMessage { // Create the enchantment tray const enchantmentApplication = document.createElement("enchantment-application"); enchantmentApplication.classList.add("dnd5e2"); - html.querySelector(".message-content").appendChild(enchantmentApplication); + const afterElement = html.querySelector(".card-footer") ?? html.querySelector(".effects-tray"); + afterElement.insertAdjacentElement("beforebegin", enchantmentApplication); } /* -------------------------------------------- */ From d601eb5e1042d30df7ea519d00c67d8a82684e1a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 8 May 2024 16:28:49 -0700 Subject: [PATCH 154/199] [#3541] Add Consume Usage and Consume Resource buttons to chat Whenever an item has usage or resources to consume but those are not consumed during normal activation, this adds a pair of new chat buttons to "Consume Usage" or "Consume Resource" which performs the normal consumption when clicked. These buttons are removed from the chat card once they have been used to indicate that consumption has already occured. To track whether these consumptions have happened, new flags have been added to messages in `dnd5e.use`: `consumedUsage`, `consumedResource`, and `consumedSpellSlot` (though no matching button was added for spell slots at this time). --- lang/en.json | 2 + module/documents/chat-message.mjs | 12 ++-- module/documents/item.mjs | 114 +++++++++++++++++++----------- templates/chat/item-card.hbs | 16 +++++ 4 files changed, 100 insertions(+), 44 deletions(-) diff --git a/lang/en.json b/lang/en.json index afb0c11c27..3be4e3476e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -110,6 +110,7 @@ "DND5E.AbilityUseConsumableDestroyHint": "Using this {type} will consume its final charge and it will be destroyed.", "DND5E.AbilityUseConsumableDestroyResourceHint": "Using this {type} will consume the final charge off {name} and destroy it.", "DND5E.AbilityUseConsume": "Consume Available Usage?", +"DND5E.AbilityUseConsumeAction": "Consume Usage", "DND5E.AbilityUseChargesLabel": "{value} Charges", "DND5E.AbilityUseConsumableLabel": "{max} per {per}", "DND5E.AbilityUseCast": "Cast Spell", @@ -491,6 +492,7 @@ "DND5E.ConsumeWarningNoQuantity": "{name} has run out of its designated {type}!", "DND5E.ConsumeWarningZeroAttribute": "{name} has run out of its designated attribute resource pool!", "DND5E.ConsumeResource": "Consume Resource?", +"DND5E.ConsumeResourceAction": "Consume Resource", "DND5E.ConsumeRecharge": "Consume Recharge?", "DND5E.ConsumeScaling": "Resource Scaling", "DND5E.ConsumeScalingLabel": "Use Resources", diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs index 7b63f90366..bbed9c1dab 100644 --- a/module/documents/chat-message.mjs +++ b/module/documents/chat-message.mjs @@ -108,10 +108,14 @@ export default class ChatMessage5e extends ChatMessage { // If the user is the message author or the actor owner, proceed let actor = game.actors.get(this.speaker.actor); if ( game.user.isGM || actor?.isOwner || (this.user.id === game.user.id) ) { - const summonsButton = chatCard[0].querySelector('button[data-action="summon"]'); - if ( summonsButton && !SummonsData.canSummon ) summonsButton.style.display = "none"; - const template = chatCard[0].querySelector('button[data-action="placeTemplate"]'); - if ( template && !game.user.can("TEMPLATE_CREATE") ) template.style.display = "none"; + const optionallyHide = (selector, hide) => { + const element = chatCard[0].querySelector(selector); + if ( element && hide ) element.style.display = "none"; + }; + optionallyHide('button[data-action="summon"]', !SummonsData.canSummon); + optionallyHide('button[data-action="placeTemplate"]', !game.user.can("TEMPLATE_CREATE")); + optionallyHide('button[data-action="consumeUsage"]', this.getFlag("dnd5e", "use.consumedUsage")); + optionallyHide('button[data-action="consumeResource"]', this.getFlag("dnd5e", "use.consumedResource")); return; } diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 7ad0594e7a..e7443cac6a 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1075,44 +1075,8 @@ export default class Item5e extends SystemDocumentMixin(Item) { } if ( item.type === "spell" ) foundry.utils.mergeObject(options.flags, {"dnd5e.use.spellLevel": item.system.level}); - /** - * A hook event that fires before an item's resource consumption has been calculated. - * @function dnd5e.preItemUsageConsumption - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. - * @param {ItemUseOptions} options Additional options used for configuring item usage. - * @returns {boolean} Explicitly return `false` to prevent item from being used. - */ - if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return; - - // Determine whether the item can be used by testing the chosen values of the config. - const usage = item._getUsageUpdates(config); - if ( !usage ) return; - - /** - * A hook event that fires after an item's resource consumption has been calculated but before any - * changes have been made. - * @function dnd5e.itemUsageConsumption - * @memberof hookEvents - * @param {Item5e} item Item being used. - * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. - * @param {ItemUseOptions} options Additional options used for configuring item usage. - * @param {object} usage - * @param {object} usage.actorUpdates Updates that will be applied to the actor. - * @param {object} usage.itemUpdates Updates that will be applied to the item being used. - * @param {object[]} usage.resourceUpdates Updates that will be applied to other items on the actor. - * @param {Set} usage.deleteIds Item ids for those which consumption will delete. - * @returns {boolean} Explicitly return `false` to prevent item from being used. - */ - if ( Hooks.call("dnd5e.itemUsageConsumption", item, config, options, usage) === false ) return; - - // Commit pending data updates - const { actorUpdates, itemUpdates, resourceUpdates, deleteIds } = usage; - if ( !foundry.utils.isEmpty(itemUpdates) ) await item.update(itemUpdates); - if ( !foundry.utils.isEmpty(deleteIds) ) await this.actor.deleteEmbeddedDocuments("Item", [...deleteIds]); - if ( !foundry.utils.isEmpty(actorUpdates) ) await this.actor.update(actorUpdates); - if ( !foundry.utils.isEmpty(resourceUpdates) ) await this.actor.updateEmbeddedDocuments("Item", resourceUpdates); + // Calculate and consume item consumption + if ( await this.consume(item, config, options) === false ) return; // Initiate or end concentration. const effects = []; @@ -1171,6 +1135,63 @@ export default class Item5e extends SystemDocumentMixin(Item) { return cardData; } + /* -------------------------------------------- */ + + /** + * Handle item's consumption. + * @param {Item5e} item Item or clone to use when calculating updates. + * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. + * @param {ItemUseOptions} options Additional options used for configuring item usage. + * @returns {boolean|void} Returns `false` if any further usage should be canceled. + */ + async consume(item, config, options) { + /** + * A hook event that fires before an item's resource consumption has been calculated. + * @function dnd5e.preItemUsageConsumption + * @memberof hookEvents + * @param {Item5e} item Item being used. + * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. + * @param {ItemUseOptions} options Additional options used for configuring item usage. + * @returns {boolean} Explicitly return `false` to prevent item from being used. + */ + if ( Hooks.call("dnd5e.preItemUsageConsumption", item, config, options) === false ) return false; + + // Determine whether the item can be used by testing the chosen values of the config. + const usage = item._getUsageUpdates(config); + if ( !usage ) return false; + + options.flags ??= {}; + if ( config.consumeUsage ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedUsage", true); + if ( config.consumeResource ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedResource", true); + if ( config.consumeSpellSlot ) foundry.utils.setProperty(options.flags, "dnd5e.use.consumedSpellSlot", true); + + /** + * A hook event that fires after an item's resource consumption has been calculated but before any + * changes have been made. + * @function dnd5e.itemUsageConsumption + * @memberof hookEvents + * @param {Item5e} item Item being used. + * @param {ItemUseConfiguration} config Configuration data for the item usage being prepared. + * @param {ItemUseOptions} options Additional options used for configuring item usage. + * @param {object} usage + * @param {object} usage.actorUpdates Updates that will be applied to the actor. + * @param {object} usage.itemUpdates Updates that will be applied to the item being used. + * @param {object[]} usage.resourceUpdates Updates that will be applied to other items on the actor. + * @param {Set} usage.deleteIds Item ids for those which consumption will delete. + * @returns {boolean} Explicitly return `false` to prevent item from being used. + */ + if ( Hooks.call("dnd5e.itemUsageConsumption", item, config, options, usage) === false ) return false; + + // Commit pending data updates + const { actorUpdates, itemUpdates, resourceUpdates, deleteIds } = usage; + if ( !foundry.utils.isEmpty(itemUpdates) ) await item.update(itemUpdates); + if ( !foundry.utils.isEmpty(deleteIds) ) await this.actor.deleteEmbeddedDocuments("Item", [...deleteIds]); + if ( !foundry.utils.isEmpty(actorUpdates) ) await this.actor.update(actorUpdates); + if ( !foundry.utils.isEmpty(resourceUpdates) ) await this.actor.updateEmbeddedDocuments("Item", resourceUpdates); + } + + /* -------------------------------------------- */ + /** * Prepare an object of possible and default values for item usage. A value that is `null` is ignored entirely. * @returns {ItemUseConfiguration} Configuration data for the roll. @@ -1475,8 +1496,11 @@ export default class Item5e extends SystemDocumentMixin(Item) { // Render the chat card template const token = this.actor.token; + const consumeUsage = this.hasLimitedUses && !options.flags?.dnd5e?.use?.consumedUsage; + const consumeResource = this.hasResource && !options.flags?.dnd5e?.use?.consumedResource; const hasButtons = this.hasAttack || this.hasDamage || this.isVersatile || this.hasSave || this.system.formula - || this.hasAreaTarget || (this.type === "tool") || this.hasAbilityCheck || this.system.hasSummoning; + || this.hasAreaTarget || (this.type === "tool") || this.hasAbilityCheck || this.system.hasSummoning + || consumeUsage || consumeResource; const templateData = { hasButtons, actor: this.actor, @@ -1494,7 +1518,9 @@ export default class Item5e extends SystemDocumentMixin(Item) { hasSave: this.hasSave, hasAreaTarget: this.hasAreaTarget, isTool: this.type === "tool", - hasAbilityCheck: this.hasAbilityCheck + hasAbilityCheck: this.hasAbilityCheck, + consumeUsage, + consumeResource }; const html = await renderTemplate("systems/dnd5e/templates/chat/item-card.hbs", templateData); @@ -2122,6 +2148,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { // Handle different actions let targets; + let messageUpdates = {}; switch ( action ) { case "abilityCheck": targets = this._getChatCardTargets(card); @@ -2150,6 +2177,12 @@ export default class Item5e extends SystemDocumentMixin(Item) { spellLevel: spellLevel }); break; + case "consumeUsage": + await item.consume(item, { consumeUsage: true }, messageUpdates); + break; + case "consumeResource": + await item.consume(item, { consumeResource: true }, messageUpdates); + break; case "damage": case "versatile": await item.rollDamage({ @@ -2190,6 +2223,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { await item.rollToolCheck({event}); break; } + if ( !foundry.utils.isEmpty(messageUpdates) ) await message.update(messageUpdates); } catch(err) { Hooks.onError("Item5e._onChatCardAction", err, { log: "error", notify: "error" }); diff --git a/templates/chat/item-card.hbs b/templates/chat/item-card.hbs index 7282535420..25cafa3c44 100644 --- a/templates/chat/item-card.hbs +++ b/templates/chat/item-card.hbs @@ -112,6 +112,22 @@ {{ labels.abilityCheck }} {{/if}} + + {{!-- Consume Use --}} + {{#if consumeUsage}} + + {{/if}} + + {{!-- Consume Resource --}} + {{#if consumeResource}} + + {{/if}} {{/if}} From 21936023410348b54885698aba17b408f5c63e23 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 9 May 2024 10:10:26 -0700 Subject: [PATCH 155/199] [#3544] Add static registry of summoned creatures in world Similar to what is implemented for Enchantments, this introduces a static registry within `SummonsData` where each summoned actor can register to make it easy to determine what creatures each actor or item have summoned. Adds `SummonsData#summonedCreatures` which returns what creatures were summoned by that item, and `Actor5e#summonedCreatures` getter which returns all creatures summoned by that actor. Also fixes a bug where sometimes `flags.dnd5e.summons.origin` wouldn't get the proper UUID for summons created using the chat message button. --- module/data/item/fields/summons-field.mjs | 61 +++++++++++++++++++++++ module/documents/actor/actor.mjs | 26 +++++++++- module/documents/item.mjs | 2 +- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index ce862de05d..5ef342172a 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -150,6 +150,18 @@ export class SummonsData extends foundry.abstract.DataModel { return foundry.utils.getProperty(this.item.getRollData(), keyPath) ?? 0; } + /* -------------------------------------------- */ + + /** + * Creatures summoned by this item. + * @type {Actor5e[]} + */ + get summonedCreatures() { + if ( !this.item.actor ) return []; + return SummonsData.summonedCreatures(this.item.actor) + .filter(i => i?.getFlag("dnd5e", "summon.origin") === this.item.uuid); + } + /* -------------------------------------------- */ /* Summoning */ /* -------------------------------------------- */ @@ -510,4 +522,53 @@ export class SummonsData extends foundry.abstract.DataModel { return tokenDocument.toObject(); } + + /* -------------------------------------------- */ + /* Static Registry */ + /* -------------------------------------------- */ + + /** + * Registration of summoned creatures mapped to a specific summoner. The map is keyed by the UUID of + * summoned actor while the set contains UUID of actors that have been summoned. + * @type {Map>} + */ + static #summonedCreatures = new Map(); + + /* -------------------------------------------- */ + + /** + * Fetch creatures summoned by an actor. + * @param {Actor5e} actor Actor for which to find the summoned creatures. + * @returns {Actor5e[]} + */ + static summonedCreatures(actor) { + return Array.from(SummonsData.#summonedCreatures.get(actor.uuid) ?? []).map(uuid => fromUuidSync(uuid)); + } + + /* -------------------------------------------- */ + + /** + * Add a new summoned creature to the list of summoned creatures. + * @param {string} summoner UUID of the actor who performed the summoning. + * @param {string} summoned UUID of the summoned creature to track. + */ + static trackSummon(summoner, summoned) { + if ( summoned.startsWith("Compendium.") ) return; + if ( !SummonsData.#summonedCreatures.has(summoner) ) { + SummonsData.#summonedCreatures.set(summoner, new Set()); + } + SummonsData.#summonedCreatures.get(summoner).add(summoned); + } + + /* -------------------------------------------- */ + + /** + * Stop tracking a summoned creature. + * @param {string} summoner UUID of the actor who performed the summoning. + * @param {string} summoned UUID of the summoned creature to track. + */ + static untrackSummon(summoner, summoned) { + if ( !SummonsData.#summonedCreatures.has(summoner) ) return; + SummonsData.#summonedCreatures.get(summoner).delete(summoned); + } } diff --git a/module/documents/actor/actor.mjs b/module/documents/actor/actor.mjs index e67e940149..45e2fef36f 100644 --- a/module/documents/actor/actor.mjs +++ b/module/documents/actor/actor.mjs @@ -5,8 +5,9 @@ import { d20Roll } from "../../dice/dice.mjs"; import { simplifyBonus } from "../../utils.mjs"; import ShortRestDialog from "../../applications/actor/short-rest.mjs"; import LongRestDialog from "../../applications/actor/long-rest.mjs"; -import ActiveEffect5e from "../active-effect.mjs"; import PropertyAttribution from "../../applications/property-attribution.mjs"; +import { SummonsData } from "../../data/item/fields/summons-field.mjs"; +import ActiveEffect5e from "../active-effect.mjs"; import Item5e from "../item.mjs"; import { createRollLabel } from "../../enrichers.mjs"; @@ -104,6 +105,16 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { return concentration; } + /* -------------------------------------------- */ + + /** + * Creatures summoned by this actor. + * @type {Actor5e[]} + */ + get summonedCreatures() { + return SummonsData.summonedCreatures(this); + } + /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ @@ -166,6 +177,9 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /** @inheritDoc */ prepareDerivedData() { + const origin = this.getFlag("dnd5e", "summon.origin"); + if ( origin ) SummonsData.trackSummon(origin.split(".Item.")[0], this.uuid); + if ( (this.system.modelProvider !== dnd5e) || (this.type === "group") ) return; this.labels = {}; @@ -3305,6 +3319,16 @@ export default class Actor5e extends SystemDocumentMixin(Actor) { /* -------------------------------------------- */ + /** @inheritDoc */ + _onDelete(options, userId) { + super._onDelete(options, userId); + + const origin = this.getFlag("dnd5e", "summon.origin"); + if ( origin ) SummonsData.untrackSummon(origin.split(".Item.")[0], this.uuid); + } + + /* -------------------------------------------- */ + /** @inheritDoc */ async _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { if ( (userId === game.userId) && (collection === "items") ) await this.updateEncumbrance(options); diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 7ad0594e7a..36b0f325fd 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -2183,7 +2183,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { } break; case "summon": - if ( spellLevel ) item = item.clone({ "system.level": spellLevel }); + if ( spellLevel ) item = item.clone({ "system.level": spellLevel }, { keepId: true }); await this._onChatCardSummon(message, item); break; case "toolCheck": From d6b0811a335c5f6196bf97382e6ebfe96b2b2775 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Thu, 9 May 2024 11:17:48 -0700 Subject: [PATCH 156/199] [#3291] Add setting to control chat card tray expansion Adds a new setting allowing setting the initial expanded state of damage & effect application trays in chat cards. The settings are "Expand All" and "Collapse All" plus "Collapse Older", which expands newly created cards but collapses any cards more than five minutes old. To better support the expanding trays, a new CardTrayElement has been added which adds support for the `open` attribute on card trays to explicitly control its behavior. This attribute matches the behavior of the `open` attribute on `
      ` elements, so adding or removing the attribute will open and close the tray, and toggling the `open` property will cause the attribute to be added or removed. The tray also fires off a `toggle` event that matches the one fired by `
      `. --- lang/en.json | 7 +++ .../components/chat-tray-element.mjs | 60 +++++++++++++++++++ .../components/damage-application.mjs | 12 ++-- module/documents/chat-message.mjs | 28 ++++++++- module/settings.mjs | 15 +++++ 5 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 module/applications/components/chat-tray-element.mjs diff --git a/lang/en.json b/lang/en.json index afb0c11c27..6a6eb32cb8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2040,6 +2040,13 @@ "Name": "Allow Summoning", "Hint": "Allow players to use summoning abilities to summon actors. Players must also have the Create Token core permission for this to work." }, + "COLLAPSETRAYS": { + "Name": "Collapse Trays in Chat", + "Hint": "Automatically collapse damage, hit, and effect trays that appear in chat cards.", + "Always": "Collapse All", + "Older": "Collapse Older Trays", + "Never": "Expand All" + }, "THEME": { "Name": "Theme", "Hint": "Theme that will apply to the UI and all sheets by default. Automatic will be determined by your browser or operating system settings." diff --git a/module/applications/components/chat-tray-element.mjs b/module/applications/components/chat-tray-element.mjs new file mode 100644 index 0000000000..1c4d7fbcfd --- /dev/null +++ b/module/applications/components/chat-tray-element.mjs @@ -0,0 +1,60 @@ +/** + * Custom element designed to display as a collapsible tray in chat. + */ +export default class ChatTrayElement extends HTMLElement { + + static observedAttributes = ["open"]; + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * Is the tray expanded or collapsed? + * @type {boolean} + */ + get open() { + return this.hasAttribute("open"); + } + + set open(open) { + if ( open ) this.setAttribute("open", ""); + else this.removeAttribute("open"); + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + attributeChangedCallback(name, oldValue, newValue) { + if ( name === "open" ) this._handleToggleOpen(newValue !== null); + } + + /* -------------------------------------------- */ + + /** + * Handle clicks to the collapsible header. + * @param {PointerEvent} event Triggering click event. + */ + _handleClickHeader(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + if ( !event.target.closest(".collapsible-content") ) this.toggleAttribute("open"); + } + + /* -------------------------------------------- */ + + /** + * Handle changing the collapsed state of this element. + * @param {boolean} open Is the element open? + */ + _handleToggleOpen(open) { + this.dispatchEvent(new Event("toggle")); + + this.querySelector(".collapsible")?.classList.toggle("collapsed", !open); + + // Clear the height from the chat popout container so that it appropriately resizes. + const popout = this.closest(".chat-popout"); + if ( popout ) popout.style.height = ""; + } +} diff --git a/module/applications/components/damage-application.mjs b/module/applications/components/damage-application.mjs index b22302972c..51cfeaaf50 100644 --- a/module/applications/components/damage-application.mjs +++ b/module/applications/components/damage-application.mjs @@ -1,4 +1,5 @@ import { formatNumber } from "../../utils.mjs"; +import ChatTrayElement from "./chat-tray-element.mjs"; /** * List of multiplier options as tuples containing their numeric value and rendered text. @@ -9,7 +10,7 @@ const MULTIPLIERS = [[-1, "-1"], [0, "0"], [.25, "¼"], [.5, "½"], [1, "1"], [2 /** * Application to handle applying damage from a chat card. */ -export default class DamageApplicationElement extends HTMLElement { +export default class DamageApplicationElement extends ChatTrayElement { /* -------------------------------------------- */ /* Properties */ @@ -119,7 +120,8 @@ export default class DamageApplicationElement extends HTMLElement { // Build the frame HTML only once if ( !this.targetList ) { const div = document.createElement("div"); - div.classList.add("card-tray", "damage-tray", "collapsible", "collapsed"); + div.classList.add("card-tray", "damage-tray", "collapsible"); + if ( !this.open ) div.classList.add("collapsed"); div.innerHTML = `
    diff --git a/templates/actors/tabs/group-members.hbs b/templates/actors/tabs/group-members.hbs index 792f99a92d..9cebbeacd1 100644 --- a/templates/actors/tabs/group-members.hbs +++ b/templates/actors/tabs/group-members.hbs @@ -1,5 +1,10 @@ {{#if isGM}} +
  • + +