From 136668810c581babb4968fa09350a4962a5686ab Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Wed, 18 Dec 2024 13:21:47 -0800 Subject: [PATCH] Allow natural weapons to make ranged attacks without "thrown" Reworks how activities fetch their list of available attack types so they can display "Default (melee or ranged)" in the attack activity dialog if relevant (such as thrown melee weapons). Adjust how that list of valid attack types is generated to that natural weapons can have ranged attacks so long as they have a range value. To ensure modifiers are properly supported this adds a new attack mode of "Ranged" for natural weapons that don't have the thrown property. Fixes a bug with extra melee critical damage dice being applied even when the weapon is thrown. Also fixes a bug causing the "reach" migration to be applied when switching weapon modes. --- lang/en.json | 1 + module/applications/activity/attack-sheet.mjs | 6 +++-- module/config.mjs | 3 +++ module/data/activity/attack-data.mjs | 19 ++++----------- module/data/activity/base-activity.mjs | 16 +++++++++++-- module/data/item/weapon.mjs | 23 ++++++++++++++++++- module/enrichers.mjs | 2 +- 7 files changed, 49 insertions(+), 21 deletions(-) diff --git a/lang/en.json b/lang/en.json index 1512f7d36d..5ea3e0cec1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -547,6 +547,7 @@ "Label": "Attack Mode", "Offhand": "Offhand", "OneHanded": "One-Handed", + "Ranged": "Ranged", "Thrown": "Thrown", "ThrownOffhand": "Offhand Throw", "TwoHanded": "Two-Handed" diff --git a/module/applications/activity/attack-sheet.mjs b/module/applications/activity/attack-sheet.mjs index a7bc046429..99eb73f241 100644 --- a/module/applications/activity/attack-sheet.mjs +++ b/module/applications/activity/attack-sheet.mjs @@ -77,10 +77,12 @@ export default class AttackSheet extends ActivitySheet { context.attackTypeOptions = Object.entries(CONFIG.DND5E.attackTypes) .map(([value, config]) => ({ value, label: config.label })); - if ( this.item.system.attackType ) context.attackTypeOptions.unshift({ + if ( this.item.system.validAttackTypes?.size ) context.attackTypeOptions.unshift({ value: "", label: game.i18n.format("DND5E.DefaultSpecific", { - default: CONFIG.DND5E.attackTypes[this.item.system.attackType].label.toLowerCase() + default: game.i18n.getListFormatter({ type: "disjunction" }).format( + Array.from(this.item.system.validAttackTypes).map(t => CONFIG.DND5E.attackTypes[t].label.toLowerCase()) + ) }) }); diff --git a/module/config.mjs b/module/config.mjs index 2087869374..2160e3e0c9 100644 --- a/module/config.mjs +++ b/module/config.mjs @@ -2833,6 +2833,9 @@ DND5E.attackModes = Object.seal({ offhand: { label: "DND5E.ATTACK.Mode.Offhand" }, + ranged: { + label: "DND5E.ATTACK.Mode.Ranged" + }, thrown: { label: "DND5E.ATTACK.Mode.Thrown" }, diff --git a/module/data/activity/attack-data.mjs b/module/data/activity/attack-data.mjs index e2830cd728..f62f89f5e8 100644 --- a/module/data/activity/attack-data.mjs +++ b/module/data/activity/attack-data.mjs @@ -139,14 +139,7 @@ export default class AttackActivityData extends BaseActivityData { get validAttackTypes() { const sourceType = this._source.attack.type.value; if ( sourceType ) return new Set([sourceType]); - if ( this.item.type !== "weapon" ) return new Set(); - - const types = new Set(); - const attackType = this.attack.type.value || this.item.system.attackType; - if ( attackType === "melee" ) types.add("melee"); - if ( (attackType === "ranged") || ((this.item.system.attackType === "melee") - && this.item.system.properties.has("thr")) ) types.add("ranged"); - return types; + return this.item.system.validAttackTypes ?? new Set(); } /* -------------------------------------------- */ @@ -236,8 +229,7 @@ export default class AttackActivityData extends BaseActivityData { const key = attackMode.split("-").map(s => s.capitalize()).join(""); attackModeLabel = game.i18n.localize(`DND5E.ATTACK.Mode.${key}`); } - let actionType = this.actionType; - if ( (actionType === "mwak") && (attackMode?.startsWith("thrown")) ) actionType = "rwak"; + const actionType = this.getActionType(attackMode); let actionTypeLabel = game.i18n.localize(`DND5E.Action${actionType.toUpperCase()}`); const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy"; const isUnarmed = this.attack.type.classification === "unarmed"; @@ -267,9 +259,6 @@ export default class AttackActivityData extends BaseActivityData { const rollData = this.getRollData(); if ( this.attack.flat ) return CONFIG.Dice.BasicRoll.constructParts({ toHit: this.attack.bonus }, rollData); - let actionType = this.actionType; - if ( (actionType === "mwak") && attackMode?.startsWith("thrown") ) actionType = "rwak"; - const weapon = this.item.system; const ammo = this.actor?.items.get(ammunition)?.system; const { parts, data } = CONFIG.Dice.BasicRoll.constructParts({ @@ -278,7 +267,7 @@ export default class AttackActivityData extends BaseActivityData { bonus: this.attack.bonus, weaponMagic: weapon.magicAvailable ? weapon.magicalBonus : null, ammoMagic: ammo?.magicAvailable ? ammo.magicalBonus : null, - actorBonus: this.actor?.system.bonuses?.[actionType]?.attack, + actorBonus: this.actor?.system.bonuses?.[this.getActionType(attackMode)]?.attack, situational }, rollData); @@ -420,7 +409,7 @@ export default class AttackActivityData extends BaseActivityData { } const criticalBonusDice = this.actor?.getFlag("dnd5e", "meleeCriticalDamageDice") ?? 0; - if ( (this.actionType === "mwak") && (parseInt(criticalBonusDice) !== 0) ) { + if ( (this.getActionType(rollConfig.attackMode) === "mwak") && (parseInt(criticalBonusDice) !== 0) ) { foundry.utils.setProperty(roll, "options.critical.bonusDice", criticalBonusDice); } diff --git a/module/data/activity/base-activity.mjs b/module/data/activity/base-activity.mjs index 1e6675c6be..b122d55ac0 100644 --- a/module/data/activity/base-activity.mjs +++ b/module/data/activity/base-activity.mjs @@ -648,6 +648,19 @@ export default class BaseActivityData extends foundry.abstract.DataModel { /* Helpers */ /* -------------------------------------------- */ + /** + * Retrieve the action type reflecting changes based on the provided attack mode. + * @param {string} [attackMode=""] + * @returns {string} + */ + getActionType(attackMode="") { + let actionType = this.actionType; + if ( (actionType === "mwak") && (attackMode?.startsWith("thrown") || (attackMode === "ranged")) ) return "rwak"; + return actionType; + } + + /* -------------------------------------------- */ + /** * Get the roll parts used to create the damage rolls. * @param {Partial} [config={}] @@ -683,8 +696,7 @@ export default class BaseActivityData extends foundry.abstract.DataModel { const data = { ...rollData }; if ( index === 0 ) { - let actionType = this.actionType; - if ( (actionType === "mwak") && rollConfig.attackMode?.startsWith("thrown") ) actionType = "rwak"; + const actionType = this.getActionType(rollConfig.attackMode); const bonus = foundry.utils.getProperty(this.actor ?? {}, `system.bonuses.${actionType}.damage`); if ( bonus && (parseInt(bonus) !== 0) ) parts.push(bonus); } diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 599241ec5f..b4a8f3b96a 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -175,7 +175,8 @@ export default class WeaponData extends ItemDataModel.mixin( * @param {object} source The candidate source data from which the model will be constructed. */ static #migrateReach(source) { - if ( !source.properties || !source.range?.value || !source.type?.value || source.range?.reach ) return; + if ( !source.properties || !source.range?.value || !source.type?.value + || (source.range?.reach !== undefined) ) return; if ( (CONFIG.DND5E.weaponTypeMap[source.type.value] !== "melee") || source.properties.includes("thr") ) return; // Range of `0` or greater than `10` is always included, and so is range longer than `5` without reach property if ( (source.range.value === 0) || (source.range.value > 10) @@ -351,6 +352,11 @@ export default class WeaponData extends ItemDataModel.mixin( }); } + else if ( !this.attackType && this.range.value ) { + if ( modes.length ) modes.push({ rule: true }); + modes.push({ value: "ranged", label: CONFIG.DND5E.attackModes.ranged.label }); + } + return modes; } @@ -510,6 +516,21 @@ export default class WeaponData extends ItemDataModel.mixin( return Number(isProficient); } + /* -------------------------------------------- */ + + /** + * Attack types that can be used with this item by default. + * @type {Set} + */ + get validAttackTypes() { + const types = new Set(); + const attackType = this.attackType; + if ( (attackType === "melee") || (attackType === null) ) types.add("melee"); + if ( (attackType === "ranged") || this.properties.has("thr") + || ((attackType === null) && this.range.value) ) types.add("ranged"); + return types; + } + /* -------------------------------------------- */ /* Socket Event Handlers */ /* -------------------------------------------- */ diff --git a/module/enrichers.mjs b/module/enrichers.mjs index 31e9832925..aee563d174 100644 --- a/module/enrichers.mjs +++ b/module/enrichers.mjs @@ -188,7 +188,7 @@ async function enrichAttack(config, label, options) { config.type = "attack"; if ( label ) return createRollLink(label, config); - let displayFormula = simplifyRollFormula(config.formula); + let displayFormula = simplifyRollFormula(config.formula) || "+0"; if ( !displayFormula.startsWith("+") && !displayFormula.startsWith("-") ) displayFormula = `+${displayFormula}`; const span = document.createElement("span");