Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#1620, #1626, #1689] Add spell config & type restriction to ItemGrant advancement #1690

Merged
merged 4 commits into from
Sep 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 99 additions & 3 deletions module/advancement/advancement-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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={}) {
Expand Down Expand Up @@ -32,7 +34,8 @@ export default class AdvancementConfig extends FormApplication {
width: 400,
height: "auto",
submitOnChange: true,
closeOnSubmit: false
closeOnSubmit: false,
dropKeyPath: null
});
}

Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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<Item5e>} 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;
arbron marked this conversation as resolved.
Show resolved Hide resolved
}

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

/** @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"));
arbron marked this conversation as resolved.
Show resolved Hide resolved
}

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.
arbron marked this conversation as resolved.
Show resolved Hide resolved
* @throws An error if the item is invalid.
* @protected
*/
_validateDroppedItem(event, item) {}

}
27 changes: 27 additions & 0 deletions module/advancement/advancement.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
79 changes: 24 additions & 55 deletions module/advancement/types/item-grant.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,6 +29,14 @@ export class ItemGrantAdvancement extends Advancement {
});
}

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

/**
* The item types that are supported in Item Grant.
* @type {Set<string>}
*/
static VALID_TYPES = new Set(["feat", "spell", "consumable", "backpack", "equipment", "loot", "tool", "weapon"]);

/* -------------------------------------------- */
/* Display Methods */
/* -------------------------------------------- */
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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<Item5e>} 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 }));
}
}

Expand Down
3 changes: 2 additions & 1 deletion module/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
8 changes: 7 additions & 1 deletion templates/advancement/item-grant-config.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<p class="hint">{{localize "DND5E.AdvancementItemGrantOptionalHint"}}</p>
</div>

{{#if showSpellConfig}}
{{> "dnd5e.advancement-spell-config"}}
{{/if}}

<div class="drop-target">
<ol class="items-list">
<li class="items-header flexrow"><h3 class="item-name">{{localize "DOCUMENT.Items"}}</h3></li>
Expand All @@ -16,7 +20,9 @@
<li class="item flexrow" data-item-uuid="{{this}}">
<div class="item-name">{{{dnd5e-linkForUuid this}}}</div>
<div class="item-controls flexrow">
<a class="item-control item-delete" title="Delete Item"><i class="fas fa-trash"></i></a>
<a class="item-control item-action" data-action="delete" title="{{localize 'DND5E.ItemDelete'}}">
<i class="fas fa-trash"></i>
</a>
</div>
</li>
{{/each}}
Expand Down
28 changes: 28 additions & 0 deletions templates/advancement/parts/advancement-spell-config.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<div class="form-group">
{{log this}}
<label>{{localize "DND5E.AbilityModifier"}}</label>
<div class="form-fields">
<select name="data.configuration.spell.ability">
{{selectOptions CONFIG.abilities selected=data.configuration.spell.ability blank="&mdash;"}}
</select>
</div>
</div>

<div class="form-group">
<label>{{localize "DND5E.SpellPreparationMode"}}</label>
<div class="form-fields">
<select name="data.configuration.spell.preparation">
{{selectOptions CONFIG.spellPreparationModes selected=data.configuration.spell.preparation blank="&mdash;"}}
</select>
</div>
</div>

<div class="form-group">
<label>{{localize "DND5E.LimitedUses"}}</label>
<div class="form-fields">
<input type="text" name="data.configuration.spell.uses.max" value="{{data.configuration.spell.uses.max}}">
<select name="data.configuration.spell.uses.per">
{{selectOptions CONFIG.limitedUsePeriods selected=data.configuration.spell.uses.per blank="&mdash;"}}
</select>
</div>
</div>