From d327b8b9a29bc03f6988a620f4cee188ed5062ce Mon Sep 17 00:00:00 2001 From: Michael Roth Date: Sat, 18 May 2024 20:52:16 -0400 Subject: [PATCH] added DC scaling, group attack checkbox, tidy5e compatibility --- languages/en.json | 9 +++ scripts/constants.js | 13 ++++- scripts/interface.js | 5 +- scripts/module.js | 2 + scripts/plugins/midiqol.js | 37 +++++++++++- scripts/plugins/vanilla.js | 110 +++++++++++++++++++++++++++--------- scripts/sheet-overrides.js | 112 +++++++++++++++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 scripts/sheet-overrides.js diff --git a/languages/en.json b/languages/en.json index 52bef2e..145a23f 100644 --- a/languages/en.json +++ b/languages/en.json @@ -21,6 +21,11 @@ "TurnIntoMinionFeature": "Set as minion group attack", "RevertFromMinionFeature": "Set as regular attack" }, + "ItemOverrides": { + "ScaleDC": "DC scales with minions", + "Enabled": "Enabled", + "GroupAttack": "Is a minion group attack" + }, "Errors": { "MidiActive": "MidiQOL is required for Minion Manager to function! Please install & activate the module." }, @@ -57,6 +62,10 @@ "Title": "Enable Minion Group Attack Bonus", "Hint": "When enabled, minions get a bonus to their group attack rolls equal to the number of attacking minions." }, + "EnableGroupDCBonus": { + "Title": "Enable Minion Group DC Bonus", + "Hint": "When enabled, minions get a bonus to the DC of any of their group attack rolls equal to the number of attacking minions." + }, "EnableMinionSuperSave": { "Title": "Enable Minion Super Saves", "Hint": "When enabled, minions that are forced to make a saving throw, they instead take no damage if they would take half when they succeed on their saving throw." diff --git a/scripts/constants.js b/scripts/constants.js index 1ecca43..e635be1 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -20,7 +20,8 @@ const CONSTANTS = { GROUP_NUMBER: `${FLAG}.groupNumber`, DELETE_GROUP_NUMBER: `${FLAG}.-=groupNumber`, MIDI_GROUP_ATTACK: "flags.midiProperties.grpact", - MINION_FEATURE: `${FLAG}.minionfeature.` + MINION_FEATURE: `${FLAG}.minionfeature.`, + DC_SCALING_ENABLED: `${FLAG}.dcScaling` }, MODULES: { MIDI: false @@ -35,6 +36,7 @@ CONSTANTS["SETTING_KEYS"] = { ENABLE_OVERKILL_MESSAGE: "enableOverkillMessage", ENABLE_GROUP_ATTACKS: "enableGroupAttacks", ENABLE_GROUP_ATTACK_BONUS: "enableGroupAttackBonus", + ENABLE_GROUP_DC_BONUS: "enableGroupDCBonus", ENABLE_MINION_SUPER_SAVE: "enableMinionSuperSave", MINION_FEATURE_NAME: "minionFeatureName", MINION_FEATURE_DESCRIPTION: "minionFeatureDescription", @@ -114,6 +116,15 @@ CONSTANTS["SETTINGS"] = () => { type: Boolean }, + [CONSTANTS.SETTING_KEYS.ENABLE_GROUP_DC_BONUS]: { + name: "MINIONMANAGER.Settings.EnableGroupDCBonus.Title", + hint: "MINIONMANAGER.Settings.EnableGroupDCBonus.Hint", + scope: "world", + config: true, + default: true, + type: Boolean + }, + [CONSTANTS.SETTING_KEYS.ENABLE_MINION_SUPER_SAVE]: { name: "MINIONMANAGER.Settings.EnableMinionSuperSave.Title", hint: "MINIONMANAGER.Settings.EnableMinionSuperSave.Hint", diff --git a/scripts/interface.js b/scripts/interface.js index eb5e820..dcbe398 100644 --- a/scripts/interface.js +++ b/scripts/interface.js @@ -194,6 +194,7 @@ export function initializeInterface() { Hooks.on("dnd5e.getItemContextOptions", (item, menuItems) => { const actor = item.parent; + const isGroupAttackable = ["feat", "weapon", "spell"].includes(item.type); menuItems.push({ name: game.i18n.localize("MINIONMANAGER.ItemContextMenu.TurnIntoMinionFeature"), icon: ``, @@ -201,7 +202,7 @@ export function initializeInterface() { return api.setActorItemToGroupAttack(item, true); }, condition: () => { - return game.user.isGM && !api.isItemGroupAttack(item) && api.isMinion(actor); + return game.user.isGM && !api.isItemGroupAttack(item) && api.isMinion(actor) && isGroupAttackable; } }, { name: game.i18n.localize("MINIONMANAGER.ItemContextMenu.RevertFromMinionFeature"), @@ -210,7 +211,7 @@ export function initializeInterface() { return api.setActorItemToGroupAttack(item, false); }, condition: () => { - return game.user.isGM && api.isItemGroupAttack(item) && api.isMinion(actor); + return game.user.isGM && api.isItemGroupAttack(item) && api.isMinion(actor) && isGroupAttackable; } }); }); diff --git a/scripts/module.js b/scripts/module.js index 66d1f37..3b647d5 100644 --- a/scripts/module.js +++ b/scripts/module.js @@ -2,6 +2,7 @@ import CONSTANTS from "./constants.js"; import { initializeMinions } from "./minion.js"; import { initializeInterface } from "./interface.js"; import { initializeInitiative } from "./initiative.js"; +import { registerSheetOverrides } from "./sheet-overrides.js"; import * as API from "./api.js"; Hooks.on("init", () => { @@ -10,6 +11,7 @@ Hooks.on("init", () => { initializeMinions(); initializeInterface(); initializeInitiative(); + registerSheetOverrides(); }); Hooks.once("ready", () => { diff --git a/scripts/plugins/midiqol.js b/scripts/plugins/midiqol.js index b5d5feb..25ddb6e 100644 --- a/scripts/plugins/midiqol.js +++ b/scripts/plugins/midiqol.js @@ -83,12 +83,47 @@ export default { return true; }); - delete minionAttacks[workflow.id]; + if (!workflow.hasSave) delete minionAttacks[workflow.id]; return true; }); + Hooks.on("midi-qol.preCheckSaves", async (workflow) => { + if (!lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACKS)) return true; + + const isGroupAttack = getProperty(workflow.item, CONSTANTS.FLAGS.MIDI_GROUP_ATTACK) ?? false; + + const isScaleDC = getProperty(workflow.item, CONSTANTS.FLAGS.DC_SCALING_ENABLED) ?? false; + + if (!workflow.actor || !api.isMinion(workflow?.actor) || !isGroupAttack || !isScaleDC) return true; + + if (!minionAttacks?.[workflow.id]) { + const result = await Dialog.confirm({ + title: game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Title"), + content: ` +

${game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Label")}

+

+ `, + yes: (html) => { + return html.find('input[name="numberOfAttacks"]').val() + }, + options: { height: "100%" } + }) + + minionAttacks[workflow.id] = Math.max(1, Number(result) || 1);; + } + + const numberOfMinions = minionAttacks[workflow.id]; + + if (!lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_DC_BONUS)) return true; + + workflow.item = workflow.item.clone({"system.save": {"dc": workflow.item.system.save.dc + (numberOfMinions > 1 ? numberOfMinions : 0), "scaling": "flat"}}, {"keepId": true}); + workflow.item.prepareData(); + workflow.item.prepareFinalAttributes(); + delete minionAttacks[workflow.id]; + }); + Hooks.on("midi-qol.postCheckSaves", async (workflow) => { if (!lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_MINION_SUPER_SAVE)) return; for (const savedToken of workflow.saves) { diff --git a/scripts/plugins/vanilla.js b/scripts/plugins/vanilla.js index 3a2b9e4..c91b5ea 100644 --- a/scripts/plugins/vanilla.js +++ b/scripts/plugins/vanilla.js @@ -16,41 +16,52 @@ export default { if (!api.isMinion(item.parent) || !isGroupAttack) return true; - // If we've already prompted the user, and the attack hasn't gone through, then we continue the original attack - if (minionAttacks[item.parent.uuid] && !minionAttacks[item.parent.uuid].attacked) { + // If we've already prompted the user, and the attack hasn't gone through (and we didn't prompt for a save DC), then we continue the original attack + if (minionAttacks[item.parent.uuid] && !minionAttacks[item.parent.uuid].attacked && !minionAttacks[item.parent.uuid].oldDC) { minionAttacks[item.parent.uuid].attacked = true; return true; } - Dialog.confirm({ - title: game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Title"), - content: ` -

${game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Label")}

-

- `, - yes: (html) => { - return html.find('input[name="numberOfAttacks"]').val() - }, - options: { height: "100%" } - }).then(result => { - - const numberOfMinions = Math.max(1, Number(result) || 1); - - minionAttacks[item.parent.uuid] = { - numberOfMinions, - attacked: false - }; - + if (!minionAttacks[item.parent.uuid]) { + Dialog.confirm({ + title: game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Title"), + content: ` +

${game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Label")}

+

+ `, + yes: (html) => { + return html.find('input[name="numberOfAttacks"]').val() + }, + options: { height: "100%" } + }).then(result => { + + const numberOfMinions = Math.max(1, Number(result) || 1); + + minionAttacks[item.parent.uuid] = { + numberOfMinions, + attacked: false + }; + + rollConfig.data.numberOfMinions = numberOfMinions; + if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACK_BONUS)) { + const containsNumberOfMinions = rollConfig.parts.includes(CONSTANTS.NUMBER_MINIONS_BONUS); + rollConfig.parts = []; + if (!containsNumberOfMinions) rollConfig.parts.push(numberOfMinions > 1 ? CONSTANTS.NUMBER_MINIONS_BONUS : ''); + } + + item.rollAttack(rollConfig); + + }); + } else { + const numberOfMinions = minionAttacks[item.parent.uuid].numberOfMinions; rollConfig.data.numberOfMinions = numberOfMinions; if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACK_BONUS)) { const containsNumberOfMinions = rollConfig.parts.includes(CONSTANTS.NUMBER_MINIONS_BONUS); - rollConfig.parts = []; if (!containsNumberOfMinions) rollConfig.parts.push(numberOfMinions > 1 ? CONSTANTS.NUMBER_MINIONS_BONUS : ''); } - - item.rollAttack(rollConfig); - - }); + minionAttacks[item.parent.uuid].attacked = true; + return true; + } return false; @@ -102,6 +113,53 @@ export default { }); + Hooks.on("dnd5e.preUseItem", (item, config, options) => { + if (!lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACKS)) return true; + + const isGroupAttack = getProperty(item, CONSTANTS.FLAGS.MIDI_GROUP_ATTACK) ?? false; + + const isScaleDC = getProperty(item, CONSTANTS.FLAGS.DC_SCALING_ENABLED) ?? false; + + if (!api.isMinion(item.parent) || !isGroupAttack || !isScaleDC) return true; + + if (!lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_DC_BONUS)) return true; + + if (!item.system.save || item.system.save?.ability == "" || !item.system.save?.dc) return true; + + if (minionAttacks?.[item.parent.uuid]?.oldDC) return true; + Dialog.confirm({ + title: game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Title"), + content: ` +

${game.i18n.localize("MINIONMANAGER.Dialogs.MinionAttack.Label")}

+

+ `, + yes: (html) => { + return html.find('input[name="numberOfAttacks"]').val() + }, + options: { height: "100%" } + }).then(result => { + const numberOfMinions = Math.max(1, Number(result) || 1) + + minionAttacks[item.parent.uuid] = { + numberOfMinions, + attacked: false, + oldDC: item.system.save?.dc + }; + + item.system.save.dc = item.system.save.dc + (numberOfMinions > 1 ? numberOfMinions : 0); + item.use(config, options); + }); + return false; + }); + + Hooks.on("dnd5e.useItem", async (item) => { + let minionAttackData = minionAttacks?.[item.parent.uuid]; + if (minionAttackData?.oldDC) { + item.system.save.dc = minionAttackData.oldDC; + } + if (!item.hasAttack) delete minionAttacks?.[item.parent.uuid]; + return true; + }); Hooks.on("dnd5e.rollDamage", async (item, damageRoll) => { diff --git a/scripts/sheet-overrides.js b/scripts/sheet-overrides.js new file mode 100644 index 0000000..f6bc050 --- /dev/null +++ b/scripts/sheet-overrides.js @@ -0,0 +1,112 @@ +import CONSTANTS from "./constants.js"; +import * as lib from "./lib.js"; +import * as api from "./api.js"; + +export function registerSheetOverrides() { + Hooks.on("renderItemSheet5e", patchItemSheet); + Hooks.on("tidy5e-sheet.renderItemSheet", patchTidyItemSheet); +} + +function patchItemSheet(app, html, { item } = {}) { + if (!app.options.classes.includes("tidy5e-sheet")) { + patchGroupAttack(html, item); + patchDCScaling(html, item); + } +} + +function patchTidyItemSheet(app, element, { item }, forced) { + patchTidyGroupAttack(element, item); + patchTidyDCScaling(element, item); +} + +function patchGroupAttack(html, item) { + if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACKS) && api.isMinion(item.parent)) { + if (["feat", "weapon", "spell"].includes(item.type)) { + const featureHeader = game.i18n.localize("DND5E.FeatureUsage"); + const weaponHeader = game.i18n.localize("DND5E.ItemWeaponUsage"); + const spellHeader = game.i18n.localize("DND5E.SpellCastingHeader"); + let targetElem = html.find(` + .form-header:contains(${featureHeader}), + .form-header:contains(${weaponHeader}), + .form-header:contains(${spellHeader})`)[0]; + if (!targetElem) return; + $(getGroupAttackHtml(item)).insertBefore(targetElem); + } + } +} + +function patchTidyGroupAttack(element, item) { + if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_ATTACKS) && api.isMinion(item.parent)) { + const html = $(element); + const markupToInject = ` +
+ ${getGroupAttackHtml(item)} +
+ `; + if (["feat", "weapon", "spell"].includes(item.type)) { + const featureHeader = game.i18n.localize("DND5E.FeatureUsage"); + const weaponHeader = game.i18n.localize("DND5E.ItemWeaponUsage"); + const spellHeader = game.i18n.localize("DND5E.SpellCastingHeader"); + let targetElem = html.find(` + .form-header:contains(${featureHeader}), + .form-header:contains(${weaponHeader}), + .form-header:contains(${spellHeader})`)[0]; + if (!targetElem) return; + $(markupToInject).insertBefore(targetElem); + } + } +} + +function patchDCScaling(html, item) { + if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_DC_BONUS) && getProperty(item, CONSTANTS.FLAGS.MIDI_GROUP_ATTACK)) { + let targetElem = html.find('[name="system.save.ability"]')?.parent()?.parent()?.[0]; + if (!targetElem) return; + $(getDCScalingHtml(item)).insertAfter(targetElem); + } +} + +function patchTidyDCScaling(element, item) { + if (lib.getSetting(CONSTANTS.SETTING_KEYS.ENABLE_GROUP_DC_BONUS) && getProperty(item, CONSTANTS.FLAGS.MIDI_GROUP_ATTACK)) { + const html = $(element); + const markupToInject = ` +
+ ${getDCScalingHtml(item)} +
+ `; + let targetElem = html.find('[data-tidy-field="system.save.ability"]')?.parent()?.parent()?.[0]; + if (!targetElem) return; + $(markupToInject).insertAfter(targetElem); + } +} + +function getGroupAttackHtml(item) { + const groupAttackEnabled = foundry.utils.getProperty(item, CONSTANTS.FLAGS.MIDI_GROUP_ATTACK) ?? false; + let idealWidth = Math.ceil((1.2 * game.i18n.localize("MINIONMANAGER.ItemOverrides.Enabled").length) + 3); + return ` +
+ +
+ +
+
+ ` +} + +function getDCScalingHtml(item) { + const dcScalingEnabled = foundry.utils.getProperty(item, `${CONSTANTS.FLAGS.DC_SCALING_ENABLED}`) ?? false; + let idealWidth = Math.ceil((1.2 * game.i18n.localize("MINIONMANAGER.ItemOverrides.Enabled").length) + 3); + return ` +
+ +
+ +
+
+ `; +} \ No newline at end of file