From 6b93e474a451116e88c87127718ff73b1fb8e023 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 8 Mar 2024 14:09:51 -0800 Subject: [PATCH 1/2] [#893] Improve permissions checks & allow compendium summoning Adds a `SummonsData#canSummon` property that determines whether a scene is open and whether the user has permissions to summon. If not, then no summoning control is displayed in the `AbilityUseDialog` and the chat button is removed. If user is trying to summon and actor that they do not have ownership of the will be shown an error. Also introduces a check for file browser permission when creating token data if the token is set to use a wildcard image. If the user lacks permission here, then the token will fall back to using the portrait image and a warning will be displayed in the console. Adds a new `SummonsData#fetchActor` data which handles locating the actor to be summoned. If the actor is in the world, then it just returns that version. If the associated actor is in a compendium, it will first search through world actors to find one with a matching `sourceId` and the `dnd5e.summonedCopy` flag set, summoning from the pre-imported version if available. Only then will it fall back on importing the actor from the compendium so long as the user has permission to do so. --- lang/en.json | 10 ++-- module/data/item/fields/summons-field.mjs | 64 +++++++++++++++++++++-- module/data/item/templates/action.mjs | 2 +- module/data/token/token-system-flags.mjs | 2 + module/documents/chat-message.mjs | 3 ++ module/documents/item.mjs | 14 ++--- 6 files changed, 77 insertions(+), 18 deletions(-) diff --git a/lang/en.json b/lang/en.json index 6279de38bc..1211e603d0 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1481,9 +1481,6 @@ "Hint": "Modify to saving throw DCs on the summoned creature's abilities to match that of the summoner." } }, - "Placement": { - "GenericError": "Failed to place summoned actor" - }, "Profile": { "Label": "Summons Profile", "LabelPl": "Summons Profiles", @@ -1492,6 +1489,13 @@ "Prompt": { "Label": "Summon Prompt", "Hint": "Disable the summoning prompt when item is used. Players will still be able to summon from the chat card." + }, + "Warning": { + "CreateActor": "You must have 'Create New Actors' permission in order to summon directly a compendium.", + "NoActor": "Actor cannot be found with UUID '{uuid}' to summon.", + "NoOwnership": "You do not have ownership of '{actor}' which you must have to summon it.", + "NoProfile": "Cannot find summoning profile {profileId} on '{item}'.", + "Wildcard": "You must have 'Use File Browser' permissions to summon creatures with wildcard artwork." } }, "DND5E.Supply": "Supply", diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 60f367f7de..8355c089ea 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -89,6 +89,20 @@ export class SummonsData extends foundry.abstract.DataModel { /* Properties */ /* -------------------------------------------- */ + /** + * Does the user have permissions to summon? + * @type {boolean} + */ + static get canSummon() { + return game.user.can("TOKEN_CREATE"); + } + + get canSummon() { + return this.constructor.canSummon; + } + + /* -------------------------------------------- */ + /** * Item to which this summoning information belongs. * @type {Item5e} @@ -106,8 +120,12 @@ export class SummonsData extends foundry.abstract.DataModel { * @param {string} profileId ID of the summoning profile to use. */ async summon(profileId) { + if ( !this.canSummon || !canvas.scene ) return; + const profile = this.profiles.find(p => p._id === profileId); - if ( !profile ) throw new Error(`Cannot find summoning profile ${profileId} on ${this.item.id}.`); + if ( !profile ) { + throw new Error(game.i18n.format("DND5E.Summoning.Warning.NoProfile", { profileId, item: this.item.name })); + } /** * A hook event that fires before summoning is performed. @@ -120,9 +138,12 @@ export class SummonsData extends foundry.abstract.DataModel { if ( Hooks.call("dnd5e.preSummon", this.item, profile) === false ) return; // Fetch the actor that will be summoned - let actor = await fromUuid(profile.uuid); + const actor = await this.fetchActor(profile.uuid); - // TODO: Import actor into world if inside compendium + // Verify ownership of actor + if ( !actor.isOwner ) { + throw new Error(game.i18n.format("DND5E.Summoning.Warning.NoOwnership", { actor: actor.name })); + } const tokensData = []; const minimized = !this.item.parent?.sheet._minimized; @@ -186,6 +207,33 @@ export class SummonsData extends foundry.abstract.DataModel { /* -------------------------------------------- */ + /** + * If actor to be summoned is in a compendium, create a local copy or use an already imported version if present. + * @param {string} uuid UUID of actor that will be summoned. + * @returns {Actor5e} Local copy of actor. + */ + async fetchActor(uuid) { + const actor = await fromUuid(uuid); + if ( !actor ) throw new Error(game.i18n.format("DND5E.Summoning.Warning.NoActor", { uuid })); + if ( !actor.pack ) return actor; + + // Search world actors to see if any have a matching summon ID flag + const localActor = game.actors.find(a => + a.getFlag("dnd5e", "summonedCopy") && (a.getFlag("core", "sourceId") === uuid) + ); + if ( localActor ) return localActor; + + // Check permissions to create actors before importing + if ( !game.user.can("ACTOR_CREATE") ) throw new Error(game.i18n.localize("DND5E.Summoning.Warning.CreateActor")); + + // Otherwise import the actor into the world and set the flag + return game.actors.importFromCompendium(game.packs.get(actor.pack), actor.id, { + "flags.dnd5e.summonedCopy": true + }); + } + + /* -------------------------------------------- */ + /** * Prepare the updates to apply to the summoned actor. * @param {Actor5e} actor Actor that will be modified. @@ -345,8 +393,18 @@ export class SummonsData extends foundry.abstract.DataModel { * @returns {object} */ async getTokenData({ actor, placement, tokenUpdates, actorUpdates }) { + if ( actor.prototypeToken.randomImg && !game.user.can("FILES_BROWSE") ) { + tokenUpdates.texture ??= {}; + tokenUpdates.texture.src ??= actor.img; + ui.notifications.warn("DND5E.Summoning.Warning.Wildcard", { localize: true }); + } + 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/item/templates/action.mjs b/module/data/item/templates/action.mjs index f4d06b8ff7..82a3ca8af2 100644 --- a/module/data/item/templates/action.mjs +++ b/module/data/item/templates/action.mjs @@ -246,7 +246,7 @@ export default class ActionTemplate extends ItemDataModel { * @type {boolean} */ get hasSummoning() { - return (this.actionType === "summ") && !!this.summons.profiles.length; + return (this.actionType === "summ") && !!this.summons?.profiles.length; } /* -------------------------------------------- */ diff --git a/module/data/token/token-system-flags.mjs b/module/data/token/token-system-flags.mjs index e85daf140d..fac4578088 100644 --- a/module/data/token/token-system-flags.mjs +++ b/module/data/token/token-system-flags.mjs @@ -22,6 +22,7 @@ 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 { @@ -33,6 +34,7 @@ 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({ diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs index 6a7b5fcff1..338ed2c220 100644 --- a/module/documents/chat-message.mjs +++ b/module/documents/chat-message.mjs @@ -1,3 +1,4 @@ +import { SummonsData } from "../data/item/fields/summons-field.mjs"; import simplifyRollFormula from "../dice/simplify-roll-formula.mjs"; import DamageRoll from "../dice/damage-roll.mjs"; @@ -99,6 +100,8 @@ 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"; return; diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 92fbe067eb..ad1746d856 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1056,11 +1056,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { try { summoned = await item.system.summons.summon(config.summonsProfile); } catch(err) { - Hooks.onError("Item5e#use", err, { - msg: game.i18n.localize("DND5E.Summoning.Placement.GenericError"), - log: "error", - notify: "error" - }); + Hooks.onError("Item5e#use", err, { log: "error", notify: "error" }); } } @@ -1116,7 +1112,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { if ( game.user.can("TEMPLATE_CREATE") && this.hasAreaTarget && canvas.scene ) { config.createMeasuredTemplate = target.prompt; } - if ( this.system.hasSummoning ) { + if ( this.system.hasSummoning && this.system.summons.canSummon && canvas.scene ) { config.createSummons = summons.prompt; config.summonsProfile = this.system.summons.profiles[0]._id; } @@ -2148,11 +2144,7 @@ export default class Item5e extends SystemDocumentMixin(Item) { try { await item.system.summons.summon(summonsProfile); } catch(err) { - Hooks.onError("Item5e#_onChatCardSummon", err, { - msg: game.i18n.localize("DND5E.Summoning.Placement.GenericError"), - log: "error", - notify: "error" - }); + Hooks.onError("Item5e#_onChatCardSummon", err, { log: "error", notify: "error" }); } } From e9858669d3304396fb60a6b9fdc817bbf7a0861a Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Mon, 18 Mar 2024 15:41:10 -0700 Subject: [PATCH 2/2] [#893] Add Allow Summoning system setting --- lang/en.json | 8 ++++++-- module/data/item/fields/summons-field.mjs | 2 +- module/settings.mjs | 10 ++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lang/en.json b/lang/en.json index 1211e603d0..85949d5f1d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1491,9 +1491,9 @@ "Hint": "Disable the summoning prompt when item is used. Players will still be able to summon from the chat card." }, "Warning": { - "CreateActor": "You must have 'Create New Actors' permission in order to summon directly a compendium.", + "CreateActor": "You must have 'Create New Actors' permission in order to summon directly from a compendium.", "NoActor": "Actor cannot be found with UUID '{uuid}' to summon.", - "NoOwnership": "You do not have ownership of '{actor}' which you must have to summon it.", + "NoOwnership": "You must have ownership of '{actor}' in order to summon it.", "NoProfile": "Cannot find summoning profile {profileId} on '{item}'.", "Wildcard": "You must have 'Use File Browser' permissions to summon creatures with wildcard artwork." } @@ -1845,6 +1845,10 @@ "Hint": "Disabling the token ring animations can improve performance." }, "SETTINGS.DND5E": { + "ALLOWSUMMONING": { + "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." + }, "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. Can be overridden on a sheet-by-sheet basis." diff --git a/module/data/item/fields/summons-field.mjs b/module/data/item/fields/summons-field.mjs index 8355c089ea..66e57198c8 100644 --- a/module/data/item/fields/summons-field.mjs +++ b/module/data/item/fields/summons-field.mjs @@ -94,7 +94,7 @@ export class SummonsData extends foundry.abstract.DataModel { * @type {boolean} */ static get canSummon() { - return game.user.can("TOKEN_CREATE"); + return game.user.can("TOKEN_CREATE") && (game.user.isGM || game.settings.get("dnd5e", "allowSummoning")); } get canSummon() { diff --git a/module/settings.mjs b/module/settings.mjs index 3e2eb8acf5..da6fdb9931 100644 --- a/module/settings.mjs +++ b/module/settings.mjs @@ -222,6 +222,16 @@ export function registerSystemSettings() { } }); + // Allow Summoning + game.settings.register("dnd5e", "allowSummoning", { + name: "SETTINGS.DND5E.ALLOWSUMMONING.Name", + hint: "SETTINGS.DND5E.ALLOWSUMMONING.Hint", + scope: "world", + config: true, + default: false, + type: Boolean + }); + // Metric Unit Weights game.settings.register("dnd5e", "metricWeightUnits", { name: "SETTINGS.5eMetricN",