diff --git a/lang/en.json b/lang/en.json index 6b4fcca69f..0d35a2ce18 100644 --- a/lang/en.json +++ b/lang/en.json @@ -118,6 +118,7 @@ "DND5E.AdvancementItemGrantRecursiveWarning": "You cannot grant an item in its own advancement.", "DND5E.AdvancementItemGrantOptional": "Optional", "DND5E.AdvancementItemGrantOptionalHint": "If optional, players will be given the option to opt out of any of the following items, otherwise all of them are granted.", +"DND5E.AdvancementItemTypeInvalidWarning": "{type} items cannot be added with this advancement type.", "DND5E.AdvancementLevelHeader": "Level {level}", "DND5E.AdvancementLevelAnyHeader": "Any Level", "DND5E.AdvancementLevelNoneHeader": "No Level", diff --git a/module/advancement/advancement-config.mjs b/module/advancement/advancement-config.mjs index bdd95d7a25..ef71cdb020 100644 --- a/module/advancement/advancement-config.mjs +++ b/module/advancement/advancement-config.mjs @@ -2,8 +2,10 @@ * Base configuration application for advancements that can be extended by other types to implement custom * editing interfaces. * - * @param {Advancement} advancement The advancement item being edited. - * @param {object} [options={}] Additional options passed to FormApplication. + * @property {Advancement} advancement The advancement item being edited. + * @param {object} [options={}] Additional options passed to FormApplication. + * @param {string} [options.dropKeyPath=null] Path within advancement configuration where dropped items are stored. + * If populated, will enable default drop & delete behavior. */ export default class AdvancementConfig extends FormApplication { constructor(advancement, options={}) { @@ -32,7 +34,8 @@ export default class AdvancementConfig extends FormApplication { width: 400, height: "auto", submitOnChange: true, - closeOnSubmit: false + closeOnSubmit: false, + dropKeyPath: null }); } @@ -52,6 +55,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"); return { + CONFIG: CONFIG.DND5E, data: this.advancement.data, default: { title: this.advancement.constructor.metadata.title, @@ -76,6 +80,15 @@ export default class AdvancementConfig extends FormApplication { /* -------------------------------------------- */ + activateListeners(html) { + super.activateListeners(html); + + // Remove an item from the list + if ( this.options.dropKeyPath ) html.on("click", "[data-action='delete']", this._onItemDelete.bind(this)); + } + + /* -------------------------------------------- */ + /** @inheritdoc */ async _updateObject(event, formData) { let updates = foundry.utils.expandObject(formData).data; @@ -101,4 +114,87 @@ export default class AdvancementConfig extends FormApplication { }, {}); } + /* -------------------------------------------- */ + /* Drag & Drop for Item Pools */ + /* -------------------------------------------- */ + + /** + * Handle deleting an existing Item entry from the Advancement. + * @param {Event} event The originating click event. + * @returns {Promise} The updated parent Item after the application re-renders. + * @protected + */ + async _onItemDelete(event) { + event.preventDefault(); + const uuidToDelete = event.currentTarget.closest("[data-item-uuid]")?.dataset.itemUuid; + if ( !uuidToDelete ) return; + const items = foundry.utils.getProperty(this.advancement.data.configuration, this.options.dropKeyPath); + const updates = { configuration: await this.prepareConfigurationUpdate({ + [this.options.dropKeyPath]: items.filter(uuid => uuid !== uuidToDelete) + }) }; + await this.advancement.update(updates); + this.render(); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _canDragDrop() { + return this.isEditable; + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _onDrop(event) { + if ( !this.options.dropKeyPath ) throw new Error( + "AdvancementConfig#options.dropKeyPath must be configured or #_onDrop must be overridden to support" + + " drag and drop on advancement config items." + ); + + // Try to extract the data + let data; + try { + data = JSON.parse(event.dataTransfer.getData("text/plain")); + } catch(err) { + return false; + } + + if ( data.type !== "Item" ) return false; + const item = await Item.implementation.fromDropData(data); + + try { + this._validateDroppedItem(event, item); + } catch(err) { + return ui.notifications.error(err.message); + } + + const existingItems = foundry.utils.getProperty(this.advancement.data.configuration, this.options.dropKeyPath); + + // Abort if this uuid is the parent item + if ( item.uuid === this.item.uuid ) { + return ui.notifications.error(game.i18n.localize("DND5E.AdvancementItemGrantRecursiveWarning")); + } + + // Abort if this uuid exists already + if ( existingItems.includes(item.uuid) ) { + return ui.notifications.warn(game.i18n.localize("DND5E.AdvancementItemGrantDuplicateWarning")); + } + + await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [...existingItems, item.uuid]}); + this.render(); + } + + /* -------------------------------------------- */ + + /** + * Called when an item is dropped to validate the Item before it is saved. An error should be thrown + * if the item is invalid. + * @param {Event} event Triggering drop event. + * @param {Item5e} item The materialized Item that was dropped. + * @throws An error if the item is invalid. + * @protected + */ + _validateDroppedItem(event, item) {} + } diff --git a/module/advancement/advancement.mjs b/module/advancement/advancement.mjs index 35b78440a5..18f7be5b79 100644 --- a/module/advancement/advancement.mjs +++ b/module/advancement/advancement.mjs @@ -318,4 +318,31 @@ export default class Advancement { * @abstract */ async reverse(level) { } + + /* -------------------------------------------- */ + + /** + * Helper method to prepare spell customizations. + * @param {object} spell Spell configuration object. + * @returns {object} Object of updates to apply to item. + * @protected + */ + _prepareSpellChanges(spell) { + const updates = {}; + if ( spell.ability ) updates["system.ability"] = spell.ability; + if ( spell.preparation ) updates["system.preparation.mode"] = spell.preparation; + if ( spell.uses?.max ) { + updates["system.uses.max"] = spell.uses.max; + if ( Number.isNumeric(spell.uses.max) ) updates["system.uses.value"] = parseInt(spell.uses.max); + else { + try { + const rollData = this.actor.getRollData({ deterministic: true }); + const formula = Roll.replaceFormulaData(spell.uses.max, rollData, {missing: 0}); + updates["system.uses.value"] = Roll.safeEval(formula); + } catch(e) { } + } + } + if ( spell.uses?.per ) updates["system.uses.per"] = spell.uses.per; + return updates; + } } diff --git a/module/advancement/types/item-grant.mjs b/module/advancement/types/item-grant.mjs index 597d02acd4..367982740c 100644 --- a/module/advancement/types/item-grant.mjs +++ b/module/advancement/types/item-grant.mjs @@ -12,7 +12,11 @@ export class ItemGrantAdvancement extends Advancement { static get metadata() { return foundry.utils.mergeObject(super.metadata, { defaults: { - configuration: { items: [], optional: false } + configuration: { + items: [], + optional: false, + spell: null + } }, order: 40, icon: "systems/dnd5e/icons/svg/item-grant.svg", @@ -25,6 +29,14 @@ export class ItemGrantAdvancement extends Advancement { }); } + /* -------------------------------------------- */ + + /** + * The item types that are supported in Item Grant. + * @type {Set} + */ + static VALID_TYPES = new Set(["feat", "spell", "consumable", "backpack", "equipment", "loot", "tool", "weapon"]); + /* -------------------------------------------- */ /* Display Methods */ /* -------------------------------------------- */ @@ -66,6 +78,7 @@ export class ItemGrantAdvancement extends Advancement { async apply(level, data, retainedData={}) { const items = []; const updates = {}; + const spellChanges = this.data.configuration.spell ? this._prepareSpellChanges(this.data.configuration.spell) : {}; for ( const [uuid, selected] of Object.entries(data) ) { if ( !selected ) continue; @@ -79,6 +92,7 @@ export class ItemGrantAdvancement extends Advancement { "flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}` }, {keepId: true}).toObject(); } + if ( itemData.type === "spell" ) foundry.utils.mergeObject(itemData, spellChanges); items.push(itemData); // TODO: Trigger any additional advancement steps for added items @@ -129,72 +143,27 @@ export class ItemGrantConfig extends AdvancementConfig { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { dragDrop: [{ dropSelector: ".drop-target" }], + dropKeyPath: "items", template: "systems/dnd5e/templates/advancement/item-grant-config.hbs" }); } /* -------------------------------------------- */ - activateListeners(html) { - super.activateListeners(html); - - // Remove an item from the list - html.on("click", ".item-delete", this._onItemDelete.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * Handle deleting an existing Item entry from the Advancement. - * @param {Event} event The originating click event. - * @returns {Promise} The promise for the updated parent Item which resolves after the application re-renders - * @private - */ - async _onItemDelete(event) { - event.preventDefault(); - const uuidToDelete = event.currentTarget.closest("[data-item-uuid]")?.dataset.itemUuid; - if ( !uuidToDelete ) return; - const items = this.advancement.data.configuration.items.filter(uuid => uuid !== uuidToDelete); - const updates = { configuration: this.prepareConfigurationUpdate({ items }) }; - await this.advancement.update(updates); - this.render(); - } - - /* -------------------------------------------- */ - /** @inheritdoc */ - _canDragDrop() { - return this.isEditable; + getData() { + const context = super.getData(); + context.showSpellConfig = context.data.configuration.items.map(fromUuidSync).some(i => i.type === "spell"); + return context; } /* -------------------------------------------- */ /** @inheritdoc */ - async _onDrop(event) { - // Try to extract the data - let data; - try { - data = JSON.parse(event.dataTransfer.getData("text/plain")); - } catch(err) { - return false; - } - - if ( data.type !== "Item" ) return false; - const item = await Item.implementation.fromDropData(data); - const existingItems = this.advancement.data.configuration.items; - - // Abort if this uuid is the parent item - if ( item.uuid === this.item.uuid ) { - return ui.notifications.warn(game.i18n.localize("DND5E.AdvancementItemGrantRecursiveWarning")); - } - - // Abort if this uuid exists already - if ( existingItems.includes(item.uuid) ) { - return ui.notifications.warn(game.i18n.localize("DND5E.AdvancementItemGrantDuplicateWarning")); - } - - await this.advancement.update({"configuration.items": [...existingItems, item.uuid]}); - this.render(); + _validateDroppedItem(event, item) { + if ( this.advancement.constructor.VALID_TYPES.has(item.type) ) return true; + const type = game.i18n.localize(`ITEM.Type${item.type.capitalize()}`); + throw new Error(game.i18n.format("DND5E.AdvancementItemTypeInvalidWarning", { type })); } } diff --git a/module/utils.mjs b/module/utils.mjs index b56468e257..22480cefd1 100644 --- a/module/utils.mjs +++ b/module/utils.mjs @@ -110,7 +110,8 @@ export async function preloadHandlebarsTemplates() { "systems/dnd5e/templates/items/parts/item-spellcasting.hbs", // Advancement Partials - "systems/dnd5e/templates/advancement/parts/advancement-controls.hbs" + "systems/dnd5e/templates/advancement/parts/advancement-controls.hbs", + "systems/dnd5e/templates/advancement/parts/advancement-spell-config.hbs" ]; const paths = {}; diff --git a/templates/advancement/item-grant-config.hbs b/templates/advancement/item-grant-config.hbs index 8b78ca2b45..f520ade670 100644 --- a/templates/advancement/item-grant-config.hbs +++ b/templates/advancement/item-grant-config.hbs @@ -8,6 +8,10 @@

{{localize "DND5E.AdvancementItemGrantOptionalHint"}}

+ {{#if showSpellConfig}} + {{> "dnd5e.advancement-spell-config"}} + {{/if}} +
  1. {{localize "DOCUMENT.Items"}}

  2. @@ -16,7 +20,9 @@
  3. {{{dnd5e-linkForUuid this}}}
    - + + +
  4. {{/each}} diff --git a/templates/advancement/parts/advancement-spell-config.hbs b/templates/advancement/parts/advancement-spell-config.hbs new file mode 100644 index 0000000000..1abf2c2bfe --- /dev/null +++ b/templates/advancement/parts/advancement-spell-config.hbs @@ -0,0 +1,28 @@ +
    + {{log this}} + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + +
    +