From f070d2725c8e3ad0c7c9c8e7e3805271d22badaa Mon Sep 17 00:00:00 2001 From: TJ Date: Mon, 18 Jan 2021 16:13:32 -0600 Subject: [PATCH] Added new handling for resources/rolling --- module/actor/entity.js | 66 ++-- module/config.js | 11 +- module/dice.js | 77 ++++- module/item/entity.js | 665 +++++++++++++++++++++++------------------ 4 files changed, 494 insertions(+), 325 deletions(-) diff --git a/module/actor/entity.js b/module/actor/entity.js index bdbd385e..c608ea12 100644 --- a/module/actor/entity.js +++ b/module/actor/entity.js @@ -92,8 +92,14 @@ export default class Actor5e extends Actor { init.total = init.mod + init.prof + init.bonus; // Prepare power-casting data - this._computePowercastingDC(this.data); + data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10; this._computePowercastingProgression(this.data); + + // Compute owned item attributes which depend on prepared Actor data + this.items.forEach(item => { + item.getSaveDC(); + item.getAttackToHit(); + }); } /* -------------------------------------------- */ @@ -168,7 +174,10 @@ export default class Actor5e extends Actor { } // Load item data for all identified features - const features = await Promise.all(ids.map(id => fromUuid(id))); + const features = []; + for ( let id of ids ) { + features.push(await fromUuid(id)); + } // Class powers should always be prepared for ( const feature of features ) { @@ -312,19 +321,22 @@ export default class Actor5e extends Actor { const joat = flags.jackOfAllTrades; const observant = flags.observantFeat; const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0; - let round = Math.floor; for (let [id, skl] of Object.entries(data.skills)) { - skl.value = parseFloat(skl.value || 0); + skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0; + let round = Math.floor; - // Apply Remarkable Athlete or Jack of all Trades - let multi = skl.value; - if ( athlete && (skl.value === 0) && feats.remarkableAthlete.abilities.includes(skl.ability) ) { - multi = 0.5; + // Remarkable + if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) { + skl.value = 0.5; round = Math.ceil; } - if ( joat && (skl.value === 0 ) ) multi = 0.5; - // Retain the maximum skill proficiency when skill proficiencies are merged + // Jack of All Trades + if ( joat && (skl.value < 0.5) ) { + skl.value = 0.5; + } + + // Polymorph Skill Proficiencies if ( originalSkills ) { skl.value = Math.max(skl.value, originalSkills[id].value); } @@ -332,7 +344,7 @@ export default class Actor5e extends Actor { // Compute modifier skl.bonus = checkBonus + skillBonus; skl.mod = data.abilities[skl.ability].mod; - skl.prof = round(multi * data.attributes.prof); + skl.prof = round(skl.value * data.attributes.prof); skl.total = skl.mod + skl.prof + skl.bonus; // Compute passive bonus @@ -343,23 +355,6 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ - /** - * Compute the powercasting DC for all item abilities which use power DC scaling - * @param {object} actorData The actor data being prepared - * @private - */ - _computePowercastingDC(actorData) { - - // Compute the powercasting DC - const data = actorData.data; - data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10; - - // Compute ability save DCs that depend on the calling actor - this.items.forEach(i => i.getSaveDC()); - } - - /* -------------------------------------------- */ - /** * Prepare data related to the power-casting capabilities of the Actor * @private @@ -408,7 +403,7 @@ export default class Actor5e extends Actor { progression.slot = Math.ceil(caster.data.levels / denom); } - // EXCEPTION: NPC with an explicit powercaster level + // EXCEPTION: NPC with an explicit power-caster level if (isNPC && actorData.data.details.powerLevel) { progression.slot = actorData.data.details.powerLevel; } @@ -419,9 +414,9 @@ export default class Actor5e extends Actor { for ( let [n, lvl] of Object.entries(powers) ) { let i = parseInt(n.slice(-1)); if ( Number.isNaN(i) ) continue; - if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 1); + if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 0); else lvl.max = slots[i-1] || 0; - lvl.value = Math.min(parseInt(lvl.value), lvl.max); + lvl.value = parseInt(lvl.value); } // Determine the Actor's pact magic level (if any) @@ -1108,8 +1103,7 @@ export default class Actor5e extends Actor { // Recover power slots for ( let [k, v] of Object.entries(data.powers) ) { - if ( !v.max && !v.override ) continue; - updateData[`data.powers.${k}.value`] = v.override || v.max; + updateData[`data.powers.${k}.value`] = !Number.isNaN(v.override) ? v.override : (v.max ?? 0); } // Recover pact slots. @@ -1186,7 +1180,6 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ - /** * Transform this Actor into another one. * @@ -1216,10 +1209,10 @@ export default class Actor5e extends Actor { } // Get the original Actor data and the new source data - const o = duplicate(this.data); + const o = this.toJSON(); o.flags.sw5e = o.flags.sw5e || {}; o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; - const source = duplicate(target.data); + const source = target.toJSON(); // Prepare new data to merge from the source const d = { @@ -1227,6 +1220,7 @@ export default class Actor5e extends Actor { name: `${o.name} (${source.name})`, // Append the new shape to your old name data: source.data, // Get the data model of your new form items: source.items, // Get the items of your new form + effects: o.effects.concat(source.effects), // Combine active effects from both forms token: source.token, // New token configuration img: source.img, // New appearance permission: o.permission, // Use the original actor permissions diff --git a/module/config.js b/module/config.js index 3cf516e6..14ca3ee2 100644 --- a/module/config.js +++ b/module/config.js @@ -56,6 +56,16 @@ SW5E.alignments = { /* -------------------------------------------- */ +/** + * An enumeration of item attunement types + * @enum {number} + */ +SW5E.attunementTypes = { + NONE: 0, + REQUIRED: 1, + ATTUNED: 2, +} + /** * An enumeration of item attunement states * @type {{"0": string, "1": string, "2": string}} @@ -457,7 +467,6 @@ SW5E.senses = { "truesight": "SW5E.SenseTruesight" }; - /* -------------------------------------------- */ /** diff --git a/module/dice.js b/module/dice.js index 3a28b827..c05dd3df 100644 --- a/module/dice.js +++ b/module/dice.js @@ -1,3 +1,69 @@ +/** + * A standardized helper function for simplifying the constant parts of a multipart roll formula + * + * @param {string} formula The original Roll formula + * @param {Object} data Actor or item data against which to parse the roll + * @param {Object} options Formatting options + * @param {boolean} options.constantFirst Puts the constants before the dice terms in the resulting formula + * + * @return {string} The resulting simplified formula + */ +export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) { + const roll = new Roll(formula, data); // Parses the formula and replaces any @properties + const terms = roll.terms; + + // Some terms are "too complicated" for this algorithm to simplify + // In this case, the original formula is returned. + if (terms.some(_isUnsupportedTerm)) return roll.formula; + + const rollableTerms = []; // Terms that are non-constant, and their associated operators + const constantTerms = []; // Terms that are constant, and their associated operators + let operators = []; // Temporary storage for operators before they are moved to one of the above + + for (let term of terms) { // For each term + if (["+", "-"].includes(term)) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array + else { // Otherwise the term is not an operator + if (term instanceof DiceTerm) { // If the term is something rollable + rollableTerms.push(...operators); // Place all the operators into the rollableTerms array + rollableTerms.push(term); // Then place this rollable term into it as well + } // + else { // Otherwise, this must be a constant + constantTerms.push(...operators); // Place the operators into the constantTerms array + constantTerms.push(term); // Then also add this constant term to that array. + } // + operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration. + } + } + + const constantFormula = Roll.cleanFormula(constantTerms); // Cleans up the constant terms and produces a new formula string + const rollableFormula = Roll.cleanFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string + + const constantPart = roll._safeEval(constantFormula); // Mathematically evaluate the constant formula to produce a single constant term + + const parts = constantFirst ? // Order the rollable and constant terms, either constant first or second depending on the optional argumen + [constantPart, rollableFormula] : [rollableFormula, constantPart]; + + // Join the parts with a + sign, pass them to `Roll` once again to clean up the formula + return new Roll(parts.filterJoin(" + ")).formula; +} + +/* -------------------------------------------- */ + +/** + * Only some terms are supported by simplifyRollFormula, this method returns true when the term is not supported. + * @param {*} term - A single Dice term to check support on + * @return {Boolean} True when unsupported, false if supported + */ +function _isUnsupportedTerm(term) { + const diceTerm = term instanceof DiceTerm; + const operator = ["+", "-"].includes(term); + const number = !isNaN(Number(term)); + + return !(diceTerm || operator || number); +} + +/* -------------------------------------------- */ + /** * A standardized helper function for managing core 5e "d20 rolls" * @@ -53,7 +119,7 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ // Determine the d20 roll and modifiers let nd = 1; - let mods = halflingLucky ? "r=1" : ""; + let mods = halflingLucky ? "r1=1" : ""; // Handle advantage if (adv === 1) { @@ -109,6 +175,8 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ if (d.faces === 20) { d.options.critical = critical; d.options.fumble = fumble; + if ( adv === 1 ) d.options.advantage = true; + else if ( adv === -1 ) d.options.disadvantage = true; if (targetValue) d.options.target = targetValue; } } @@ -131,7 +199,6 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ /* -------------------------------------------- */ - /** * Present a Dialog form which creates a d20 roll once submitted * @return {Promise} @@ -175,7 +242,6 @@ async function _d20RollDialog({template, title, parts, data, rollMode, dialogOpt }); } - /* -------------------------------------------- */ /** @@ -235,14 +301,15 @@ export async function damageRoll({parts, actor, data, event={}, rollMode=null, t roll.terms[0].alter(1, criticalBonusDice); roll._formula = roll.formula; } - roll.dice.forEach(d => d.options.critical = true); messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`; if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true; } // Execute the roll try { - return roll.roll(); + roll.evaluate() + if ( crit ) roll.dice.forEach(d => d.options.critical = true); // TODO workaround core bug which wipes Roll#options on roll + return roll; } catch(err) { console.error(err); ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); diff --git a/module/item/entity.js b/module/item/entity.js index 7b539a8d..fc76affe 100644 --- a/module/item/entity.js +++ b/module/item/entity.js @@ -1,4 +1,4 @@ -import {d20Roll, damageRoll} from "../dice.js"; +import {simplifyRollFormula, d20Roll, damageRoll} from "../dice.js"; import AbilityUseDialog from "../apps/ability-use-dialog.js"; import AbilityTemplate from "../pixi/ability-template.js"; @@ -101,7 +101,8 @@ export default class Item5e extends Item { * @type {boolean} */ get hasSave() { - return !!(this.data.data.save && this.data.data.save.ability); + const save = this.data.data?.save || {}; + return !!(save.ability && save.scaling); } /* -------------------------------------------- */ @@ -255,29 +256,41 @@ export default class Item5e extends Item { // Saving throws this.getSaveDC(); + // To Hit + this.getAttackToHit(); + // Damage let dam = data.damage || {}; if ( dam.parts ) { labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- "); labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", "); } - } - // Assign labels - this.labels = labels; + // Limited Uses + if ( this.isOwned && !!data.uses?.max ) { + let max = data.uses.max; + if ( !Number.isNumeric(max) ) { + max = Roll.replaceFormulaData(max, this.actor.getRollData()); + if ( Roll.MATH_PROXY.safeEval ) max = Roll.MATH_PROXY.safeEval(max); + } + data.uses.max = Number(max); + } + } } + /* -------------------------------------------- */ + /** - * Update the derived spell DC for an item that requires a saving throw + * Update the derived power DC for an item that requires a saving throw * @returns {number|null} */ getSaveDC() { if ( !this.hasSave ) return; const save = this.data.data?.save; - // Actor spell-DC based scaling - if ( save.scaling === "spell" ) { - save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.spelldc") : null; + // Actor power-DC based scaling + if ( save.scaling === "power" ) { + save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerdc") : null; } // Ability-score based scaling @@ -286,22 +299,332 @@ export default class Item5e extends Item { } // Update labels - const abl = CONFIG.DND5E.abilities[save.ability]; - this.labels.save = game.i18n.format("DND5E.SaveDC", {dc: save.dc || "", ability: abl}); + const abl = CONFIG.SW5E.abilities[save.ability]; + this.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: abl}); return save.dc; } /* -------------------------------------------- */ + /** + * Update a label to the Item detailing its total to hit bonus. + * Sources: + * - item entity's innate attack bonus + * - item's actor's proficiency bonus if applicable + * - item's actor's global bonuses to the given item type + * - item's ammunition if applicable + * + * @returns {Object} returns `rollData` and `parts` to be used in the item's Attack roll + */ + getAttackToHit() { + const itemData = this.data.data; + if ( !this.hasAttack || !itemData ) return; + const rollData = this.getRollData(); + + // Define Roll bonuses + const parts = []; + + // Include the item's innate attack bonus as the initial value and label + if ( itemData.attackBonus ) { + parts.push(itemData.attackBonus) + this.labels.toHit = itemData.attackBonus; + } + + // Take no further action for un-owned items + if ( !this.isOwned ) return {rollData, parts}; + + // Ability score modifier + parts.push(`@mod`); + + // Add proficiency bonus if an explicit proficiency flag is present or for non-item features + if ( !["weapon", "consumable"].includes(this.data.type) || itemData.proficient ) { + parts.push("@prof"); + } + + // Actor-level global bonus to attack rolls + const actorBonus = this.actor.data.data.bonuses?.[itemData.actionType] || {}; + if ( actorBonus.attack ) parts.push(actorBonus.attack); + + // One-time bonus provided by consumed ammunition + if ( (itemData.consume?.type === 'ammo') && !!this.actor.items ) { + const ammoItemData = this.actor.items.get(itemData.consume.target)?.data; + + if (ammoItemData) { + const ammoItemQuantity = ammoItemData.data.quantity; + const ammoCanBeConsumed = ammoItemQuantity && (ammoItemQuantity - (itemData.consume.amount ?? 0) >= 0); + const ammoItemAttackBonus = ammoItemData.data.attackBonus; + const ammoIsTypeConsumable = (ammoItemData.type === "consumable") && (ammoItemData.data.consumableType === "ammo") + if ( ammoCanBeConsumed && ammoItemAttackBonus && ammoIsTypeConsumable ) { + parts.push("@ammo"); + rollData["ammo"] = ammoItemAttackBonus; + } + } + } + + // Condense the resulting attack bonus formula into a simplified label + let toHitLabel = simplifyRollFormula(parts.join('+'), rollData).trim(); + if (toHitLabel.charAt(0) !== '-') { + toHitLabel = '+ ' + toHitLabel + } + this.labels.toHit = toHitLabel; + + // Update labels and return the prepared roll data + return {rollData, parts}; + } + + /* -------------------------------------------- */ + /** * Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options * @param {boolean} [configureDialog] Display a configuration dialog for the item roll, if applicable? * @param {string} [rollMode] The roll display mode with which to display (or not) the card * @param {boolean} [createMessage] Whether to automatically create a chat message (if true) or simply return * the prepared chat message data (if false). - * @return {Promise} + * @return {Promise} */ - async roll({configureDialog=true, rollMode=null, createMessage=true}={}) { + async roll({configureDialog=true, rollMode, createMessage=true}={}) { + let item = this; + const actor = this.actor; + + // Reference aspects of the item data necessary for usage + const id = this.data.data; // Item data + const hasArea = this.hasAreaTarget; // Is the ability usage an AoE? + const resource = id.consume || {}; // Resource consumption + const recharge = id.recharge || {}; // Recharge mechanic + const uses = id?.uses ?? {}; // Limited uses + const isPower = this.type === "power"; // Does the item require a power slot? + const requirePowerSlot = isPower && (id.level > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode); + + // Define follow-up actions resulting from the item usage + let createMeasuredTemplate = hasArea; // Trigger a template creation + let consumeRecharge = !!recharge.value; // Consume recharge + let consumeResource = !!resource.target && (resource.type !== "ammo") // Consume a linked (non-ammo) resource + let consumePowerSlot = requirePowerSlot; // Consume a power slot + let consumeUsage = !!uses.per; // Consume limited uses + let consumeQuantity = uses.autoDestroy; // Consume quantity of the item in lieu of uses + + // Display a configuration dialog to customize the usage + const needsConfiguration = createMeasuredTemplate || consumeRecharge || consumeResource || consumePowerSlot || consumeUsage; + if (configureDialog && needsConfiguration) { + const configuration = await AbilityUseDialog.create(this); + if (!configuration) return; + + // Determine consumption preferences + createMeasuredTemplate = Boolean(configuration.placeTemplate); + consumeUsage = Boolean(configuration.consumeUse); + consumeRecharge = Boolean(configuration.consumeRecharge); + consumeResource = Boolean(configuration.consumeResource); + consumePowerSlot = Boolean(configuration.consumeSlot); + + // Handle power upcasting + if ( requirePowerSlot ) { + const slotLevel = configuration.level; + const powerLevel = slotLevel === "pact" ? actor.data.data.powerss.pact.level : parseInt(slotLevel); + if (powerLevel !== id.level) { + const upcastData = mergeObject(this.data, {"data.level": powerLevel}, {inplace: false}); + item = this.constructor.createOwned(upcastData, actor); // Replace the item with an upcast version + } + if ( consumePowerSlot ) consumePowerSlot = slotLevel === "pact" ? "pact" : `power${powerLevel}`; + } + } + + // Determine whether the item can be used by testing for resource consumption + const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerSlot, consumeUsage, consumeQuantity}); + if ( !usage ) return; + const {actorUpdates, itemUpdates, resourceUpdates} = usage; + + // Commit pending data updates + if ( !isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); + if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete(); + if ( !isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); + if ( !isObjectEmpty(resourceUpdates) ) { + const resource = actor.items.get(id.consume?.target); + if ( resource ) await resource.update(resourceUpdates); + } + + // Initiate measured template creation + if ( createMeasuredTemplate ) { + const template = game.sw5e.canvas.AbilityTemplate.fromItem(item); + if ( template ) template.drawPreview(); + } + + // Create or return the Chat Message data + return item.displayCard({rollMode, createMessage}); + } + + /* -------------------------------------------- */ + + /** + * Verify that the consumed resources used by an Item are available. + * Otherwise display an error and return false. + * @param {boolean} consumeQuantity Consume quantity of the item if other consumption modes are not available? + * @param {boolean} consumeRecharge Whether the item consumes the recharge mechanic + * @param {boolean} consumeResource Whether the item consumes a limited resource + * @param {string|boolean} consumePowerSlot A level of power slot consumed, or false + * @param {boolean} consumeUsage Whether the item consumes a limited usage + * @returns {object|boolean} A set of data changes to apply when the item is used, or false + * @private + */ + _getUsageUpdates({consumeQuantity=false, consumeRecharge=false, consumeResource=false, consumePowerSlot=false, consumeUsage=false}) { + + // Reference item data + const id = this.data.data; + const actorUpdates = {}; + const itemUpdates = {}; + const resourceUpdates = {}; + + // Consume Recharge + if ( consumeRecharge ) { + const recharge = id.recharge || {}; + if ( recharge.charged === false ) { + ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name})); + return false; + } + itemUpdates["data.recharge.charged"] = false; + } + + // Consume Limited Resource + if ( consumeResource ) { + const canConsume = this._handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates); + if ( canConsume === false ) return false; + } + + // Consume Power Slots + if ( consumePowerSlot ) { + const level = this.actor?.data.data.powers[consumePowerSlot]; + const powers = Number(level?.value ?? 0); + if ( powers === 0 ) { + const label = game.i18n.localize(consumePowerSlot === "pact" ? "SW5E.PowerProgPact" : `SW5E.PowerLevel${id.level}`); + ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label})); + return false; + } + actorUpdates[`data.powers.${consumePowerSlot}.value`] = Math.max(powers - 1, 0); + } + + // Consume Limited Usage + if ( consumeUsage ) { + const uses = id.uses || {}; + const available = Number(uses.value ?? 0); + let used = false; + + // Reduce usages + const remaining = Math.max(available - 1, 0); + if ( available >= 1 ) { + used = true; + itemUpdates["data.uses.value"] = remaining; + } + + // Reduce quantity if not reducing usages or if usages hit 0 and we are set to consumeQuantity + if ( consumeQuantity && (!used || (remaining === 0)) ) { + const q = Number(id.quantity ?? 1); + if ( q >= 1 ) { + used = true; + itemUpdates["data.quantity"] = Math.max(q - 1, 0); + itemUpdates["data.uses.value"] = uses.max ?? 1; + } + } + + // If the item was not used, return a warning + if ( !used ) { + ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name})); + return false; + } + } + + // Return the configured usage + return {itemUpdates, actorUpdates, resourceUpdates}; + } + + /* -------------------------------------------- */ + + /** + * Handle update actions required when consuming an external resource + * @param {object} itemUpdates An object of data updates applied to this item + * @param {object} actorUpdates An object of data updates applied to the item owner (Actor) + * @param {object} resourceUpdates An object of data updates applied to a different resource item (Item) + * @return {boolean|void} Return false to block further progress, or return nothing to continue + * @private + */ + _handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates) { + const actor = this.actor; + const itemData = this.data.data; + const consume = itemData.consume || {}; + if ( !consume.type ) return; + + // No consumed target + const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type]; + if ( !consume.target ) { + ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel})); + return false; + } + + // Identify the consumed resource and its current quantity + let resource = null; + let amount = Number(consume.amount ?? 1); + let quantity = 0; + switch ( consume.type ) { + case "attribute": + resource = getProperty(actor.data.data, consume.target); + quantity = resource || 0; + break; + case "ammo": + case "material": + resource = actor.items.get(consume.target); + quantity = resource ? resource.data.data.quantity : 0; + break; + case "charges": + resource = actor.items.get(consume.target); + if ( !resource ) break; + const uses = resource.data.data.uses; + if ( uses.per && uses.max ) quantity = uses.value; + else if ( resource.data.data.recharge?.value ) { + quantity = resource.data.data.recharge.charged ? 1 : 0; + amount = 1; + } + break; + } + + // Verify that a consumed resource is available + if ( !resource ) { + ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel})); + return false; + } + + // Verify that the required quantity is available + let remaining = quantity - amount; + if ( remaining < 0 ) { + ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel})); + return false; + } + + // Define updates to provided data objects + switch ( consume.type ) { + case "attribute": + actorUpdates[`data.${consume.target}`] = remaining; + break; + case "ammo": + case "material": + resourceUpdates["data.quantity"] = remaining; + break; + case "charges": + const uses = resource.data.data.uses || {}; + const recharge = resource.data.data.recharge || {}; + if ( uses.per && uses.max ) resourceUpdates["data.uses.value"] = remaining; + else if ( recharge.value ) resourceUpdates["data.recharge.charged"] = false; + break; + } + } + + /* -------------------------------------------- */ + + /** + * Display the chat card for an Item as a Chat Message + * @param {object} options Options which configure the display of the item chat card + * @param {string} rollMode The message visibility mode to apply to the created card + * @param {boolean} createMessage Whether to automatically create a ChatMessage entity (if true), or only return + * the prepared message data (if false) + */ + async displayCard({rollMode, createMessage=true}={}) { // Basic template rendering data const token = this.actor.token; @@ -320,190 +643,31 @@ export default class Item5e extends Item { hasAreaTarget: this.hasAreaTarget }; - // For feature items, optionally show an ability usage dialog - if (this.data.type === "feat") { - let configured = await this._rollFeat(configureDialog); - if ( configured === false ) return; - } else if ( this.data.type === "consumable" ) { - let configured = await this._rollConsumable(configureDialog); - if ( configured === false ) return; - } - - // For items which consume a resource, handle that here - const allowed = await this._handleResourceConsumption({isCard: true, isAttack: false}); - if ( allowed === false ) return; - // Render the chat card template const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item"; const template = `systems/sw5e/templates/chat/${templateType}-card.html`; const html = await renderTemplate(template, templateData); - // Basic chat message data + // Create the ChatMessage data object const chatData = { user: game.user._id, type: CONST.CHAT_MESSAGE_TYPES.OTHER, content: html, flavor: this.data.data.chatFlavor || this.name, - speaker: { - actor: this.actor._id, - token: this.actor.token, - alias: this.actor.name - }, + speaker: ChatMessage.getSpeaker({actor: this.actor, token}), flags: {"core.canPopout": true} }; - // If the consumable was destroyed in the process - embed the item data in the surviving message + // If the Item was destroyed in the process of displaying its card - embed the item data in the chat message if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) { chatData.flags["sw5e.itemData"] = this.data; } - // Toggle default roll mode - rollMode = rollMode || game.settings.get("core", "rollMode"); - if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM"); - if ( rollMode === "blindroll" ) chatData["blind"] = true; + // Apply the roll mode to adjust message visibility + ChatMessage.applyRollMode(chatData, rollMode || game.settings.get("core", "rollMode")); - // Create the chat message - if ( createMessage ) return ChatMessage.create(chatData); - else return chatData; - } - - /* -------------------------------------------- */ - - /** - * For items which consume a resource, handle the consumption of that resource when the item is used. - * There are four types of ability consumptions which are handled: - * 1. Ammunition (on attack rolls) - * 2. Attributes (on card usage) - * 3. Materials (on card usage) - * 4. Item Charges (on card usage) - * - * @param {boolean} isCard Is the item card being played? - * @param {boolean} isAttack Is an attack roll being made? - * @return {Promise} Can the item card or attack roll be allowed to proceed? - * @private - */ - async _handleResourceConsumption({isCard=false, isAttack=false}={}) { - const itemData = this.data.data; - const consume = itemData.consume || {}; - if ( !consume.type ) return true; - const actor = this.actor; - const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type]; - - // Only handle certain types for certain actions - if ( ((consume.type === "ammo") && !isAttack ) || ((consume.type !== "ammo") && !isCard) ) return true; - - // No consumed target set - if ( !consume.target ) { - ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel})); - return false; - } - - // Identify the consumed resource and it's quantity - let consumed = null; - let amount = parseInt(consume.amount || 1); - let quantity = 0; - switch ( consume.type ) { - case "attribute": - consumed = getProperty(actor.data.data, consume.target); - quantity = consumed || 0; - break; - case "ammo": - case "material": - consumed = actor.items.get(consume.target); - quantity = consumed ? consumed.data.data.quantity : 0; - break; - case "charges": - consumed = actor.items.get(consume.target); - if ( !consumed ) break; - const uses = consumed.data.data.uses; - if ( uses.per && uses.max ) quantity = uses.value; - else if ( consumed.data.data.recharge?.value ) { - quantity = consumed.data.data.recharge.charged ? 1 : 0; - amount = 1; - } - break; - } - - // Verify that the consumed resource is available - if ( [null, undefined].includes(consumed) ) { - ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel})); - return false; - } - let remaining = quantity - amount; - if ( remaining < 0) { - ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel})); - return false; - } - - // Update the consumed resource - switch ( consume.type ) { - case "attribute": - await this.actor.update({[`data.${consume.target}`]: remaining}); - break; - case "ammo": - case "material": - await consumed.update({"data.quantity": remaining}); - break; - case "charges": - const uses = consumed.data.data.uses || {}; - const recharge = consumed.data.data.recharge || {}; - if ( uses.per && uses.max ) await consumed.update({"data.uses.value": remaining}); - else if ( recharge.value ) await consumed.update({"data.recharge.charged": false}); - break; - } - return true; - } - - /* -------------------------------------------- */ - - /** - * Additional rolling steps when rolling a feat-type item - * @private - * @return {boolean} whether the roll should be prevented - */ - async _rollFeat(configureDialog) { - if ( this.data.type !== "feat" ) throw new Error("Wrong Item type"); - - // Configure whether to consume a limited use or to place a template - const charge = this.data.data.recharge; - const uses = this.data.data.uses; - let usesCharges = !!uses.per && !!uses.max; - let placeTemplate = false; - let consume = charge.value || usesCharges; - - // Determine whether the feat uses charges - configureDialog = configureDialog && (consume || this.hasAreaTarget); - if ( configureDialog ) { - const usage = await AbilityUseDialog.create(this); - if ( usage === null ) return false; - consume = Boolean(usage.get("consumeUse")); - placeTemplate = Boolean(usage.get("placeTemplate")); - } - - // Update Item data - const current = getProperty(this.data, "data.uses.value") || 0; - if ( consume && charge.value ) { - if ( !charge.charged ) { - ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name})); - return false; - } - else await this.update({"data.recharge.charged": false}); - } - else if ( consume && usesCharges ) { - if ( uses.value <= 0 ) { - ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name})); - return false; - } - await this.update({"data.uses.value": Math.max(current - 1, 0)}); - } - - // Maybe initiate template placement workflow - if ( this.hasAreaTarget && placeTemplate ) { - const template = AbilityTemplate.fromItem(this); - if ( template ) template.drawPreview(); - if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize(); - } - return true; + // Create the Chat Message or return its data + return createMessage ? ChatMessage.create(chatData) : chatData; } /* -------------------------------------------- */ @@ -527,8 +691,9 @@ export default class Item5e extends Item { const fn = this[`_${this.data.type}ChatData`]; if ( fn ) fn.bind(this)(data, labels, props); - // General equipment properties + // Equipment properties if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) { + if ( data.attunement === CONFIG.SW5E.attunementTypes.REQUIRED ) props.push(game.i18n.localize(CONFIG.SW5E.attunements[CONFIG.SW5E.attunementTypes.REQUIRED])); props.push( game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"), game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"), @@ -653,43 +818,35 @@ export default class Item5e extends Item { */ async rollAttack(options={}) { const itemData = this.data.data; - const actorData = this.actor.data.data; const flags = this.actor.data.flags.sw5e || {}; if ( !this.hasAttack ) { throw new Error("You may not place an Attack Roll with this Item."); } let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`; - const rollData = this.getRollData(); - // Define Roll bonuses - const parts = [`@mod`]; - if ( (this.data.type !== "weapon") || itemData.proficient ) { - parts.push("@prof"); - } + // get the parts and rollData for this item's attack + const {parts, rollData} = this.getAttackToHit(); - // Attack Bonus - if ( itemData.attackBonus ) parts.push(itemData.attackBonus); - const actorBonus = actorData?.bonuses?.[itemData.actionType] || {}; - if ( actorBonus.attack ) parts.push(actorBonus.attack); - - // Ammunition Bonus + // Handle ammunition consumption delete this._ammo; + let ammo = null; + let ammoUpdate = null; const consume = itemData.consume; if ( consume?.type === "ammo" ) { - const ammo = this.actor.items.get(consume.target); - if(ammo?.data){ + ammo = this.actor.items.get(consume.target); + if (ammo?.data) { const q = ammo.data.data.quantity; const consumeAmount = consume.amount ?? 0; if ( q && (q - consumeAmount >= 0) ) { this._ammo = ammo; - let ammoBonus = ammo.data.data.attackBonus; - if ( ammoBonus ) { - parts.push("@ammo"); - rollData["ammo"] = ammoBonus; - title += ` [${ammo.name}]`; - } + title += ` [${ammo.name}]`; } } + + // Get pending ammunition update + const usage = this._getUsageUpdates({consumeResource: true}); + if ( usage === false ) return null; + ammoUpdate = usage.resourceUpdates || {}; } // Compose roll options @@ -730,9 +887,8 @@ export default class Item5e extends Item { const roll = await d20Roll(rollConfig); if ( roll === false ) return null; - // Handle resource consumption if the attack roll was made - const allowed = await this._handleResourceConsumption({isCard: false, isAttack: true}); - if ( allowed === false ) return null; + // Commit ammunition consumption on attack rolls resource consumption if the attack roll was made + if ( ammo && !isObjectEmpty(ammoUpdate) ) await ammo.update(ammoUpdate); return roll; } @@ -747,7 +903,7 @@ export default class Item5e extends Item { * @param {object} [options] Additional options passed to the damageRoll function * @return {Promise} A Promise which resolves to the created Roll instance */ - rollDamage({event, powerLevel=null, versatile=false, options={}}={}) { + rollDamage({critical=false, event=null, powerLevel=null, versatile=false, options={}}={}) { if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item."); const itemData = this.data.data; const actorData = this.actor.data.data; @@ -761,10 +917,12 @@ export default class Item5e extends Item { // Configure the damage roll const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`; const rollConfig = { - event: event, - parts: parts, actor: this.actor, + critical: critical ?? event?.altKey ?? false, data: rollData, + event: event, + fastForward: event ? event.shiftKey || event.altKey || event.ctrlKey || event.metaKey : false, + parts: parts, title: title, flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title, speaker: ChatMessage.getSpeaker({actor: this.actor}), @@ -784,9 +942,9 @@ export default class Item5e extends Item { // Scale damage from up-casting powers if ( (this.data.type === "power") ) { - if ( (itemData.scaling.mode === "atwill") ) { + if ( (itemData.scaling.mode === "cantrip") ) { const level = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel; - this._scaleAtWillDamage(parts, itemData.scaling.formula, level, rollData); + this._scaleCantripDamage(parts, itemData.scaling.formula, level, rollData); } else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) { const scaling = itemData.scaling.formula; @@ -800,10 +958,13 @@ export default class Item5e extends Item { parts.push(actorBonus.damage); } - // Add ammunition damage - if ( this._ammo ) { + // Handle ammunition damage + const ammoData = this._ammo?.data; + + // only add the ammunition damage if the ammution is a consumable with type 'ammo' + if ( this._ammo && (ammoData.type === "consumable") && (ammoData.data.consumableType === "ammo") ) { parts.push("@ammo"); - rollData["ammo"] = this._ammo.data.data.damage.parts.map(p => p[0]).join("+"); + rollData["ammo"] = ammoData.data.damage.parts.map(p => p[0]).join("+"); rollConfig.flavor += ` [${this._ammo.name}]`; delete this._ammo; } @@ -913,74 +1074,6 @@ export default class Item5e extends Item { /* -------------------------------------------- */ - /** - * Use a consumable item, deducting from the quantity or charges of the item. - * @param {boolean} configureDialog Whether to show a configuration dialog - * @return {boolean} Whether further execution should be prevented - * @private - */ - async _rollConsumable(configureDialog) { - if ( this.data.type !== "consumable" ) throw new Error("Wrong Item type"); - const itemData = this.data.data; - - // Determine whether to deduct uses of the item - const uses = itemData.uses || {}; - const autoDestroy = uses.autoDestroy; - let usesCharges = !!uses.per && (uses.max > 0); - const recharge = itemData.recharge || {}; - const usesRecharge = !!recharge.value; - - // Display a configuration dialog to confirm the usage - let placeTemplate = false; - let consume = uses.autoUse || true; - if ( configureDialog ) { - const usage = await AbilityUseDialog.create(this); - if ( usage === null ) return false; - consume = Boolean(usage.get("consumeUse")); - placeTemplate = Boolean(usage.get("placeTemplate")); - } - - // Update Item data - if ( consume ) { - const current = uses.value || 0; - const remaining = usesCharges ? Math.max(current - 1, 0) : current; - if ( usesRecharge ) await this.update({"data.recharge.charged": false}); - else { - const q = itemData.quantity; - // Case 1, reduce charges - if ( remaining ) { - await this.update({"data.uses.value": remaining}); - } - // Case 2, reduce quantity - else if ( q > 1 ) { - await this.update({"data.quantity": q - 1, "data.uses.value": uses.max || 0}); - } - // Case 3, destroy the item - else if ( (q <= 1) && autoDestroy ) { - await this.actor.deleteOwnedItem(this.id); - } - // Case 4, reduce item to 0 quantity and 0 charges - else if ( (q === 1) ) { - await this.update({"data.quantity": q - 1, "data.uses.value": 0}); - } - // Case 5, item unusable, display warning and do nothing - else { - ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name})); - } - } - } - - // Maybe initiate template placement workflow - if ( this.hasAreaTarget && placeTemplate ) { - const template = AbilityTemplate.fromItem(this); - if ( template ) template.drawPreview(); - if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize(); - } - return true; - } - - /* -------------------------------------------- */ - /** * Perform an ability recharge test for an item which uses the d6 recharge mechanic * @return {Promise} A Promise which resolves to the created Roll instance @@ -1033,6 +1126,7 @@ export default class Item5e extends Item { left: window.innerWidth - 710, }, halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false, + reliableTalent: (this.data.data.proficient >= 1) && this.actor.getFlag("sw5e", "reliableTalent"), messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }} }, options); rollConfig.event = options.event; @@ -1114,9 +1208,14 @@ export default class Item5e extends Item { case "attack": await item.rollAttack({event}); break; case "damage": - await item.rollDamage({event, powerLevel}); break; case "versatile": - await item.rollDamage({event, powerLevel, versatile: true}); break; + await item.rollDamage({ + critical: event.altKey, + event: event, + powerLevel: powerLevel, + versatile: action === "versatile" + }); + break; case "formula": await item.rollFormula({event, powerLevel}); break; case "save": @@ -1129,7 +1228,7 @@ export default class Item5e extends Item { case "toolCheck": await item.rollToolCheck({event}); break; case "placeTemplate": - const template = AbilityTemplate.fromItem(item); + const template = game.sw5e.canvas.AbilityTemplate.fromItem(item); if ( template ) template.drawPreview(); break; }