/** * 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" * * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward". * This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively * * @param {Array} parts The dice roll component parts, excluding the initial d20 * @param {Object} data Actor or item data against which to parse the roll * @param {Event|object} event The triggering event which initiated the roll * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll * @param {string|null} template The HTML template used to render the roll dialog * @param {string|null} title The dice roll UI window title * @param {Object} speaker The ChatMessage speaker to pass when creating the chat * @param {string|null} flavor Flavor text to use in the posted chat message * @param {Boolean} fastForward Allow fast-forward advantage selection * @param {Function} onClose Callback for actions to take when the dialog form is closed * @param {Object} dialogOptions Modal dialog options * @param {boolean} advantage Apply advantage to the roll (unless otherwise specified) * @param {boolean} disadvantage Apply disadvantage to the roll (unless otherwise specified) * @param {number} critical The value of d20 result which represents a critical success * @param {number} fumble The value of d20 result which represents a critical failure * @param {number} targetValue Assign a target value against which the result of this roll should be compared * @param {boolean} elvenAccuracy Allow Elven Accuracy to modify this roll? * @param {boolean} halflingLucky Allow Halfling Luck to modify this roll? * @param {boolean} reliableTalent Allow Reliable Talent to modify this roll? * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll * @param {object} messageData Additional data which is applied to the created Chat Message, if any * * @return {Promise} A Promise which resolves once the roll workflow has completed */ export async function d20Roll({ parts = [], data = {}, event = {}, rollMode = null, template = null, title = null, speaker = null, flavor = null, fastForward = null, dialogOptions, advantage = null, disadvantage = null, critical = 20, fumble = 1, targetValue = null, elvenAccuracy = false, halflingLucky = false, reliableTalent = false, chatMessage = true, messageData = {} } = {}) { // Prepare Message Data messageData.flavor = flavor || title; messageData.speaker = speaker || ChatMessage.getSpeaker(); const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; parts = parts.concat(["@bonus"]); // Handle fast-forward events let adv = 0; fastForward = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); if (fastForward) { if (advantage ?? event.altKey) adv = 1; else if (disadvantage ?? (event.ctrlKey || event.metaKey)) adv = -1; } // Define the inner roll function const _roll = (parts, adv, form) => { // Determine the d20 roll and modifiers let nd = 1; let mods = halflingLucky ? "r1=1" : ""; // Handle advantage if (adv === 1) { nd = elvenAccuracy ? 3 : 2; messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; if ("flags.sw5e.roll" in messageData) messageData["flags.sw5e.roll"].advantage = true; mods += "kh"; } // Handle disadvantage else if (adv === -1) { nd = 2; messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; if ("flags.sw5e.roll" in messageData) messageData["flags.sw5e.roll"].disadvantage = true; mods += "kl"; } // Prepend the d20 roll let formula = `${nd}d20${mods}`; if (reliableTalent) formula = `{${nd}d20${mods},10}kh`; parts.unshift(formula); // Optionally include a situational bonus if (form) { data["bonus"] = form.bonus.value; messageOptions.rollMode = form.rollMode.value; } if (!data["bonus"]) parts.pop(); // Optionally include an ability score selection (used for tool checks) const ability = form ? form.ability : null; if (ability && ability.value) { data.ability = ability.value; const abl = data.abilities[data.ability]; if (abl) { data.mod = abl.mod; messageData.flavor += ` (${CONFIG.SW5E.abilities[data.ability]})`; } } // Execute the roll let roll = new Roll(parts.join(" + "), data); try { roll.roll(); } catch (err) { console.error(err); ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); return null; } // Flag d20 options for any 20-sided dice in the roll for (let d of roll.dice) { 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; } } // If reliable talent was applied, add it to the flavor text if (reliableTalent && roll.dice[0].total < 10) { messageData.flavor += ` (${game.i18n.localize("SW5E.FlagsReliableTalent")})`; } return roll; }; // Create the Roll instance const roll = fastForward ? _roll(parts, adv) : await _d20RollDialog({ template, title, parts, data, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll }); // Create a Chat Message if (roll && chatMessage) roll.toMessage(messageData, messageOptions); return roll; } /* -------------------------------------------- */ /** * Present a Dialog form which creates a d20 roll once submitted * @return {Promise} * @private */ async function _d20RollDialog({template, title, parts, data, rollMode, dialogOptions, roll} = {}) { // Render modal dialog template = template || "systems/sw5e/templates/chat/roll-dialog.html"; let dialogData = { formula: parts.join(" + "), data: data, rollMode: rollMode, rollModes: CONFIG.Dice.rollModes, config: CONFIG.SW5E }; const html = await renderTemplate(template, dialogData); // Create the Dialog window return new Promise(resolve => { new Dialog( { title: title, content: html, buttons: { advantage: { label: game.i18n.localize("SW5E.Advantage"), callback: html => resolve(roll(parts, 1, html[0].querySelector("form"))) }, normal: { label: game.i18n.localize("SW5E.Normal"), callback: html => resolve(roll(parts, 0, html[0].querySelector("form"))) }, disadvantage: { label: game.i18n.localize("SW5E.Disadvantage"), callback: html => resolve(roll(parts, -1, html[0].querySelector("form"))) } }, default: "normal", close: () => resolve(null) }, dialogOptions ).render(true); }); } /* -------------------------------------------- */ /** * A standardized helper function for managing core 5e "d20 rolls" * * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward". * This chooses the default options of a normal attack with no bonus, Critical, or no bonus respectively * * @param {Array} parts The dice roll component parts, excluding the initial d20 * @param {Actor} actor The Actor making the damage roll * @param {Object} data Actor or item data against which to parse the roll * @param {Event|object}[event The triggering event which initiated the roll * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll * @param {String} template The HTML template used to render the roll dialog * @param {String} title The dice roll UI window title * @param {Object} speaker The ChatMessage speaker to pass when creating the chat * @param {string} flavor Flavor text to use in the posted chat message * @param {boolean} allowCritical Allow the opportunity for a critical hit to be rolled * @param {Boolean} critical Flag this roll as a critical hit for the purposes of fast-forward rolls * @param {number} criticalBonusDice A number of bonus damage dice that are added for critical hits * @param {number} criticalMultiplier A critical hit multiplier which is applied to critical hits * @param {Boolean} fastForward Allow fast-forward advantage selection * @param {Function} onClose Callback for actions to take when the dialog form is closed * @param {Object} dialogOptions Modal dialog options * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll * @param {object} messageData Additional data which is applied to the created Chat Message, if any * * @return {Promise} A Promise which resolves once the roll workflow has completed */ export async function damageRoll({ parts, actor, data, event = {}, rollMode = null, template, title, speaker, flavor, allowCritical = true, critical = false, criticalBonusDice = 0, criticalMultiplier = 2, fastForward = null, dialogOptions = {}, chatMessage = true, messageData = {} } = {}) { // Prepare Message Data messageData.flavor = flavor || title; messageData.speaker = speaker || ChatMessage.getSpeaker(); const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; parts = parts.concat(["@bonus"]); // Define inner roll function const _roll = function (parts, crit, form) { // Optionally include a situational bonus if (form) { data["bonus"] = form.bonus.value; messageOptions.rollMode = form.rollMode.value; } if (!data["bonus"]) parts.pop(); // Create the damage roll let roll = new Roll(parts.join("+"), data); // Modify the damage formula for critical hits if (crit === true) { roll.alter(criticalMultiplier, 0); // Multiply all dice if (roll.terms[0] instanceof Die) { // Add bonus dice for only the main dice term roll.terms[0].alter(1, criticalBonusDice); roll._formula = roll.formula; } messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`; if ("flags.sw5e.roll" in messageData) messageData["flags.sw5e.roll"].critical = true; } // Execute the roll try { 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}`); return null; } }; // Create the Roll instance const roll = fastForward ? _roll(parts, critical) : await _damageRollDialog({ template, title, parts, data, allowCritical, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll }); // Create a Chat Message if (roll && chatMessage) roll.toMessage(messageData, messageOptions); return roll; } /* -------------------------------------------- */ /** * Present a Dialog form which creates a damage roll once submitted * @return {Promise} * @private */ async function _damageRollDialog({template, title, parts, data, allowCritical, rollMode, dialogOptions, roll} = {}) { // Render modal dialog template = template || "systems/sw5e/templates/chat/roll-dialog.html"; let dialogData = { formula: parts.join(" + "), data: data, rollMode: rollMode, rollModes: CONFIG.Dice.rollModes }; const html = await renderTemplate(template, dialogData); // Create the Dialog window return new Promise(resolve => { new Dialog( { title: title, content: html, buttons: { critical: { condition: allowCritical, label: game.i18n.localize("SW5E.CriticalHit"), callback: html => resolve(roll(parts, true, html[0].querySelector("form"))) }, normal: { label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), callback: html => resolve(roll(parts, false, html[0].querySelector("form"))) } }, default: "normal", close: () => resolve(null) }, dialogOptions ).render(true); }); }