Files

1109 lines
40 KiB
JavaScript
Raw Permalink Normal View History

2025-01-04 00:34:03 +01:00
import DiceTerm from "./terms/dice.mjs";
import PoolTerm from "./terms/pool.mjs";
import NumericTerm from "./terms/numeric.mjs";
import RollTerm from "./terms/term.mjs";
import OperatorTerm from "./terms/operator.mjs";
import StringTerm from "./terms/string.mjs";
import ParentheticalTerm from "./terms/parenthetical.mjs";
import FunctionTerm from "./terms/function.mjs";
/**
* @typedef {import("../_types.mjs").RollParseNode} RollParseNode
*/
/**
* An interface and API for constructing and evaluating dice rolls.
* The basic structure for a dice roll is a string formula and an object of data against which to parse it.
*
* @example Attack with advantage
* ```js
* // Construct the Roll instance
* let r = new Roll("2d20kh + @prof + @strMod", {prof: 2, strMod: 4});
*
* // The parsed terms of the roll formula
* console.log(r.terms); // [Die, OperatorTerm, NumericTerm, OperatorTerm, NumericTerm]
*
* // Execute the roll
* await r.evaluate();
*
* // The resulting equation after it was rolled
* console.log(r.result); // 16 + 2 + 4
*
* // The total resulting from the roll
* console.log(r.total); // 22
* ```
*/
export default class Roll {
/**
* @param {string} formula The string formula to parse
* @param {object} data The data object against which to parse attributes within the formula
* @param {object} [options] Options which modify or describe the Roll
*/
constructor(formula, data={}, options={}) {
this.data = this._prepareData(data);
this.options = options;
this.terms = this.constructor.parse(formula, this.data);
this._formula = this.resetFormula();
}
/**
* The original provided data object which substitutes into attributes of the roll formula.
* @type {object}
*/
data;
/**
* Options which modify or describe the Roll
* @type {object}
*/
options;
/**
* The identified terms of the Roll
* @type {RollTerm[]}
*/
terms;
/**
* An array of inner DiceTerms that were evaluated as part of the Roll evaluation
* @type {DiceTerm[]}
* @internal
*/
_dice = [];
/**
* Store the original cleaned formula for the Roll, prior to any internal evaluation or simplification
* @type {string}
* @internal
*/
_formula;
/**
* Track whether this Roll instance has been evaluated or not. Once evaluated the Roll is immutable.
* @type {boolean}
* @internal
*/
_evaluated = false;
/**
* Cache the numeric total generated through evaluation of the Roll.
* @type {number}
* @internal
*/
_total;
/**
* A reference to the Roll at the root of the evaluation tree.
* @type {Roll}
* @internal
*/
_root;
/**
* A reference to the RollResolver app being used to externally resolve this Roll.
* @type {RollResolver}
* @internal
*/
_resolver;
/**
* A Proxy environment for safely evaluating a string using only available Math functions
* @type {Math}
*/
static MATH_PROXY = new Proxy(Math, {
has: () => true, // Include everything
get: (t, k) => k === Symbol.unscopables ? undefined : t[k],
set: () => console.error("You may not set properties of the Roll.MATH_PROXY environment") // No-op
});
/**
* The HTML template path used to render a complete Roll object to the chat log
* @type {string}
*/
static CHAT_TEMPLATE = "templates/dice/roll.html";
/**
* The HTML template used to render an expanded Roll tooltip to the chat log
* @type {string}
*/
static TOOLTIP_TEMPLATE = "templates/dice/tooltip.html";
/**
* A mapping of Roll instances to currently-active resolvers.
* @type {Map<Roll, RollResolver>}
*/
static RESOLVERS = new Map();
/* -------------------------------------------- */
/**
* Prepare the data structure used for the Roll.
* This is factored out to allow for custom Roll classes to do special data preparation using provided input.
* @param {object} data Provided roll data
* @returns {object} The prepared data object
* @protected
*/
_prepareData(data) {
return data;
}
/* -------------------------------------------- */
/* Roll Attributes */
/* -------------------------------------------- */
/**
* Return an Array of the individual DiceTerm instances contained within this Roll.
* @type {DiceTerm[]}
*/
get dice() {
return this._dice.concat(this.terms.flatMap(t => {
const dice = [];
dice.push(...(t.dice ?? []));
if ( t instanceof DiceTerm ) dice.push(t);
return dice;
}));
}
/* -------------------------------------------- */
/**
* Return a standardized representation for the displayed formula associated with this Roll.
* @type {string}
*/
get formula() {
return this.constructor.getFormula(this.terms);
}
/* -------------------------------------------- */
/**
* The resulting arithmetic expression after rolls have been evaluated
* @type {string}
*/
get result() {
return this.terms.map(t => t.total).join("");
}
/* -------------------------------------------- */
/**
* Return the total result of the Roll expression if it has been evaluated.
* @type {number}
*/
get total() {
return Number(this._total) || 0;
}
/* -------------------------------------------- */
/**
* Return the arbitrary product of evaluating this Roll.
* @returns {any}
*/
get product() {
return this._total;
}
/* -------------------------------------------- */
/**
* Whether this Roll contains entirely deterministic terms or whether there is some randomness.
* @type {boolean}
*/
get isDeterministic() {
return this.terms.every(t => t.isDeterministic);
}
/* -------------------------------------------- */
/* Roll Instance Methods */
/* -------------------------------------------- */
/**
* Alter the Roll expression by adding or multiplying the number of dice which are rolled
* @param {number} multiply A factor to multiply. Dice are multiplied before any additions.
* @param {number} add A number of dice to add. Dice are added after multiplication.
* @param {boolean} [multiplyNumeric] Apply multiplication factor to numeric scalar terms
* @returns {Roll} The altered Roll expression
*/
alter(multiply, add, {multiplyNumeric=false}={}) {
if ( this._evaluated ) throw new Error("You may not alter a Roll which has already been evaluated");
// Alter dice and numeric terms
this.terms = this.terms.map(term => {
if ( term instanceof DiceTerm ) return term.alter(multiply, add);
else if ( (term instanceof NumericTerm) && multiplyNumeric ) term.number *= multiply;
return term;
});
// Update the altered formula and return the altered Roll
this.resetFormula();
return this;
}
/* -------------------------------------------- */
/**
* Clone the Roll instance, returning a new Roll instance that has not yet been evaluated.
* @returns {Roll}
*/
clone() {
return new this.constructor(this._formula, this.data, this.options);
}
/* -------------------------------------------- */
/**
* Execute the Roll asynchronously, replacing dice and evaluating the total result
* @param {object} [options={}] Options which inform how the Roll is evaluated
* @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value.
* @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value.
* @param {boolean} [options.allowStrings=false] If true, string terms will not cause an error to be thrown during
* evaluation.
* @param {boolean} [options.allowInteractive=true] If false, force the use of non-interactive rolls and do not
* prompt the user to make manual rolls.
* @returns {Promise<Roll>} The evaluated Roll instance
*
* @example Evaluate a Roll expression
* ```js
* let r = new Roll("2d6 + 4 + 1d4");
* await r.evaluate();
* console.log(r.result); // 5 + 4 + 2
* console.log(r.total); // 11
* ```
*/
async evaluate({minimize=false, maximize=false, allowStrings=false, allowInteractive=true, ...options}={}) {
if ( this._evaluated ) {
throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
}
this._evaluated = true;
if ( CONFIG.debug.dice ) console.debug(`Evaluating roll with formula "${this.formula}"`);
// Migration path for async rolls
if ( "async" in options ) {
foundry.utils.logCompatibilityWarning("The async option for Roll#evaluate has been removed. "
+ "Use Roll#evaluateSync for synchronous roll evaluation.");
}
return this._evaluate({minimize, maximize, allowStrings, allowInteractive});
}
/* -------------------------------------------- */
/**
* Execute the Roll synchronously, replacing dice and evaluating the total result.
* @param {object} [options={}]
* @param {boolean} [options.minimize=false] Minimize the result, obtaining the smallest possible value.
* @param {boolean} [options.maximize=false] Maximize the result, obtaining the largest possible value.
* @param {boolean} [options.strict=true] Throw an Error if the Roll contains non-deterministic terms that
* cannot be evaluated synchronously. If this is set to false,
* non-deterministic terms will be ignored.
* @param {boolean} [options.allowStrings=false] If true, string terms will not cause an error to be thrown during
* evaluation.
* @returns {Roll} The evaluated Roll instance.
*/
evaluateSync({minimize=false, maximize=false, allowStrings=false, strict=true}={}) {
if ( this._evaluated ) {
throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable.`);
}
this._evaluated = true;
if ( CONFIG.debug.dice ) console.debug(`Synchronously evaluating roll with formula "${this.formula}"`);
return this._evaluateSync({minimize, maximize, allowStrings, strict});
}
/* -------------------------------------------- */
/**
* Evaluate the roll asynchronously.
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @param {boolean} [options.allowStrings] If true, string terms will not cause an error to be thrown during
* evaluation.
* @param {boolean} [options.allowInteractive] If false, force the use of digital rolls and do not prompt the user to
* make manual rolls.
* @returns {Promise<Roll>}
* @protected
*/
async _evaluate(options={}) {
// If the user has configured alternative dice fulfillment methods, prompt for the first pass of fulfillment here.
let resolver;
const { allowInteractive, minimize, maximize } = options;
if ( !this._root && (allowInteractive !== false) && (maximize !== true) && (minimize !== true) ) {
resolver = new this.constructor.resolverImplementation(this);
this._resolver = resolver;
await resolver.awaitFulfillment();
}
const ast = CONFIG.Dice.parser.toAST(this.terms);
this._total = await this._evaluateASTAsync(ast, options);
resolver?.close();
return this;
}
/* -------------------------------------------- */
/**
* Evaluate an AST asynchronously.
* @param {RollParseNode|RollTerm} node The root node or term.
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @param {boolean} [options.allowStrings] If true, string terms will not cause an error to be thrown during
* evaluation.
* @returns {Promise<string|number>}
* @protected
*/
async _evaluateASTAsync(node, options={}) {
if ( node.class !== "Node" ) {
if ( !node._evaluated ) {
node._root = this._root ?? this;
await node.evaluate(options);
}
return node.total;
}
let [left, right] = node.operands;
[left, right] = [await this._evaluateASTAsync(left, options), await this._evaluateASTAsync(right, options)];
switch ( node.operator ) {
case "-": return left - right;
case "*": return left * right;
case "/": return left / right;
case "%": return left % right;
// Treat an unknown operator as addition.
default: return left + right;
}
}
/* -------------------------------------------- */
/**
* Evaluate the roll synchronously.
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @param {boolean} [options.strict] Throw an error if encountering a term that cannot be synchronously
* evaluated.
* @param {boolean} [options.allowStrings] If true, string terms will not cause an error to be thrown during
* evaluation.
* @returns {Roll}
* @protected
*/
_evaluateSync(options={}) {
const ast = CONFIG.Dice.parser.toAST(this.terms);
this._total = this._evaluateASTSync(ast, options);
return this;
}
/* -------------------------------------------- */
/**
* Evaluate an AST synchronously.
* @param {RollParseNode|RollTerm} node The root node or term.
* @param {object} [options] Options which inform how evaluation is performed
* @param {boolean} [options.minimize] Force the result to be minimized
* @param {boolean} [options.maximize] Force the result to be maximized
* @param {boolean} [options.strict] Throw an error if encountering a term that cannot be synchronously
* evaluated.
* @param {boolean} [options.allowStrings] If true, string terms will not cause an error to be thrown during
* evaluation.
* @returns {string|number}
* @protected
*/
_evaluateASTSync(node, options={}) {
const { maximize, minimize, strict } = options;
if ( node.class !== "Node" ) {
if ( node._evaluated ) return node.total;
if ( RollTerm.isDeterministic(node, { maximize, minimize }) ) {
node.evaluate(options);
return node.total;
}
if ( strict ) throw new Error("This Roll contains terms that cannot be synchronously evaluated.");
return 0;
}
let [left, right] = node.operands;
[left, right] = [this._evaluateASTSync(left, options), this._evaluateASTSync(right, options)];
switch ( node.operator ) {
case "-": return left - right;
case "*": return left * right;
case "/": return left / right;
case "%": return left % right;
// Treat an unknown operator as addition.
default: return left + right;
}
}
/* -------------------------------------------- */
/**
* Safely evaluate the final total result for the Roll using its component terms.
* @returns {number} The evaluated total
* @protected
*/
_evaluateTotal() {
const expression = this.terms.map(t => t.total).join(" ");
const total = this.constructor.safeEval(expression);
if ( !Number.isNumeric(total) ) {
throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula}));
}
return total;
}
/* -------------------------------------------- */
/**
* Alias for evaluate.
* @see {Roll#evaluate}
* @param {object} options Options passed to Roll#evaluate
* @returns {Promise<Roll>}
*/
async roll(options={}) {
return this.evaluate(options);
}
/* -------------------------------------------- */
/**
* Create a new Roll object using the original provided formula and data.
* Each roll is immutable, so this method returns a new Roll instance using the same data.
* @param {object} [options={}] Evaluation options passed to Roll#evaluate
* @returns {Promise<Roll>} A new Roll object, rolled using the same formula and data
*/
async reroll(options={}) {
const r = this.clone();
return r.evaluate(options);
}
/* -------------------------------------------- */
/**
* Recompile the formula string that represents this Roll instance from its component terms.
* @returns {string} The re-compiled formula
*/
resetFormula() {
return this._formula = this.constructor.getFormula(this.terms);
}
/* -------------------------------------------- */
/**
* Propagate flavor text across all terms that do not have any.
* @param {string} flavor The flavor text.
*/
propagateFlavor(flavor) {
if ( !flavor ) return;
this.terms.forEach(t => t.options.flavor ??= flavor);
}
/* -------------------------------------------- */
/** @override */
toString() {
return this._formula;
}
/* -------------------------------------------- */
/* Static Class Methods */
/* -------------------------------------------- */
/**
* A factory method which constructs a Roll instance using the default configured Roll class.
* @param {string} formula The formula used to create the Roll instance
* @param {object} [data={}] The data object which provides component data for the formula
* @param {object} [options={}] Additional options which modify or describe this Roll
* @returns {Roll} The constructed Roll instance
*/
static create(formula, data={}, options={}) {
const cls = CONFIG.Dice.rolls[0];
return new cls(formula, data, options);
}
/* -------------------------------------------- */
/**
* Get the default configured Roll class.
* @returns {typeof Roll}
*/
static get defaultImplementation() {
return CONFIG.Dice.rolls[0];
}
/* -------------------------------------------- */
/**
* Retrieve the appropriate resolver implementation based on the user's configuration.
* @returns {typeof RollResolver}
*/
static get resolverImplementation() {
const config = game.settings.get("core", "diceConfiguration");
const methods = new Set(Object.values(config).filter(method => {
if ( !method || (method === "manual") ) return false;
return CONFIG.Dice.fulfillment.methods[method]?.interactive;
}));
// If there is more than one interactive method configured, use the default resolver which has a combined, method-
// agnostic interface.
if ( methods.size !== 1 ) return foundry.applications.dice.RollResolver;
// Otherwise use the specific resolver configured for that method, if any.
const method = CONFIG.Dice.fulfillment.methods[methods.first()];
return method.resolver ?? foundry.applications.dice.RollResolver;
}
/* -------------------------------------------- */
/**
* Transform an array of RollTerm objects into a cleaned string formula representation.
* @param {RollTerm[]} terms An array of terms to represent as a formula
* @returns {string} The string representation of the formula
*/
static getFormula(terms) {
return terms.map(t => t.formula).join("");
}
/* -------------------------------------------- */
/**
* A sandbox-safe evaluation function to execute user-input code with access to scoped Math methods.
* @param {string} expression The input string expression
* @returns {number} The numeric evaluated result
*/
static safeEval(expression) {
let result;
try {
// eslint-disable-next-line no-new-func
const evl = new Function("sandbox", `with (sandbox) { return ${expression}}`);
result = evl(this.MATH_PROXY);
} catch(err) {
result = undefined;
}
if ( !Number.isNumeric(result) ) {
throw new Error(`Roll.safeEval produced a non-numeric result from expression "${expression}"`);
}
return result;
}
/* -------------------------------------------- */
/**
* After parenthetical and arithmetic terms have been resolved, we need to simplify the remaining expression.
* Any remaining string terms need to be combined with adjacent non-operators in order to construct parsable terms.
* @param {RollTerm[]} terms An array of terms which is eligible for simplification
* @returns {RollTerm[]} An array of simplified terms
*/
static simplifyTerms(terms) {
// Simplify terms by combining with pending strings
let simplified = terms.reduce((terms, term) => {
const prior = terms[terms.length - 1];
const isOperator = term instanceof OperatorTerm;
// Combine a non-operator term with prior StringTerm
if ( !isOperator && (prior instanceof StringTerm) ) {
prior.term += term.total;
foundry.utils.mergeObject(prior.options, term.options);
return terms;
}
// Combine StringTerm with a prior non-operator term
const priorOperator = prior instanceof OperatorTerm;
if ( prior && !priorOperator && (term instanceof StringTerm) ) {
term.term = String(prior.total) + term.term;
foundry.utils.mergeObject(term.options, prior.options);
terms[terms.length - 1] = term;
return terms;
}
// Otherwise continue
terms.push(term);
return terms;
}, []);
// Convert remaining String terms to a RollTerm which can be evaluated
simplified = simplified.map(term => {
if ( !(term instanceof StringTerm) ) return term;
const t = this._classifyStringTerm(term.formula, {intermediate: false});
t.options = foundry.utils.mergeObject(term.options, t.options, {inplace: false});
return t;
});
// Eliminate leading or trailing arithmetic
if ( (simplified[0] instanceof OperatorTerm) && (simplified[0].operator !== "-") ) simplified.shift();
if ( simplified.at(-1) instanceof OperatorTerm ) simplified.pop();
return simplified;
}
/* -------------------------------------------- */
/**
* Simulate a roll and evaluate the distribution of returned results
* @param {string} formula The Roll expression to simulate
* @param {number} n The number of simulations
* @returns {Promise<number[]>} The rolled totals
*/
static async simulate(formula, n=10000) {
const results = await Promise.all([...Array(n)].map(async () => {
const r = new this(formula);
return (await r.evaluate({allowInteractive: false})).total;
}, []));
const summary = results.reduce((sum, v) => {
sum.total = sum.total + v;
if ( (sum.min === null) || (v < sum.min) ) sum.min = v;
if ( (sum.max === null) || (v > sum.max) ) sum.max = v;
return sum;
}, {total: 0, min: null, max: null});
summary.mean = summary.total / n;
console.log(`Formula: ${formula} | Iterations: ${n} | Mean: ${summary.mean} | Min: ${summary.min} | Max: ${summary.max}`);
return results;
}
/* -------------------------------------------- */
/**
* Register an externally-fulfilled result with an active RollResolver.
* @param {string} method The fulfillment method.
* @param {string} denomination The die denomination being fulfilled.
* @param {number} result The obtained result.
* @returns {boolean|void} Whether the result was consumed. Returns undefined if no resolver was available.
*/
static registerResult(method, denomination, result) {
// TODO: Currently this only takes the first Resolver, but the logic for which Resolver to use could be improved.
for ( const app of foundry.applications.instances.values() ) {
if ( (app instanceof foundry.applications.dice.RollResolver) && app.rendered ) {
return app.registerResult(method, denomination, result);
}
}
}
/* -------------------------------------------- */
/* Roll Formula Parsing */
/* -------------------------------------------- */
/**
* Parse a formula expression using the compiled peggy grammar.
* @param {string} formula The original string expression to parse.
* @param {object} data A data object used to substitute for attributes in the formula.
* @returns {RollTerm[]}
*/
static parse(formula, data) {
if ( !formula ) return [];
// Step 1: Replace formula and remove all spaces.
const replaced = this.replaceFormulaData(formula, data, { missing: "0" });
// Step 2: Use configured RollParser to parse the formula into a parse tree.
const tree = foundry.dice.RollGrammar.parse(replaced);
// Step 3: Flatten the tree into infix notation and instantiate all the nodes as RollTerm instances.
return this.instantiateAST(tree);
}
/* -------------------------------------------- */
/**
* Instantiate the nodes in an AST sub-tree into RollTerm instances.
* @param {RollParseNode} ast The root of the AST sub-tree.
* @returns {RollTerm[]}
*/
static instantiateAST(ast) {
return CONFIG.Dice.parser.flattenTree(ast).map(node => {
const cls = foundry.dice.terms[node.class] ?? RollTerm;
return cls.fromParseNode(node);
});
}
/* -------------------------------------------- */
/**
* Replace referenced data attributes in the roll formula with values from the provided data.
* Data references in the formula use the @attr syntax and would reference the corresponding attr key.
*
* @param {string} formula The original formula within which to replace
* @param {object} data The data object which provides replacements
* @param {object} [options] Options which modify formula replacement
* @param {string} [options.missing] The value that should be assigned to any unmatched keys.
* If null, the unmatched key is left as-is.
* @param {boolean} [options.warn=false] Display a warning notification when encountering an un-matched key.
* @static
*/
static replaceFormulaData(formula, data, {missing, warn=false}={}) {
let dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
return formula.replace(dataRgx, (match, term) => {
let value = foundry.utils.getProperty(data, term);
if ( value == null ) {
if ( warn && ui.notifications ) ui.notifications.warn(game.i18n.format("DICE.WarnMissingData", {match}));
return (missing !== undefined) ? String(missing) : match;
}
switch ( foundry.utils.getType(value) ) {
case "string": return value.trim();
case "number": case "boolean": return String(value);
case "Object":
if ( value.constructor.name !== "Object" ) return value.toString();
break;
case "Set": value = Array.from(value); break;
case "Map": value = Object.fromEntries(Array.from(value)); break;
}
return `$${JSON.stringify(value)}$`;
});
}
/* -------------------------------------------- */
/**
* Validate that a provided roll formula can represent a valid
* @param {string} formula A candidate formula to validate
* @returns {boolean} Is the provided input a valid dice formula?
*/
static validate(formula) {
// Replace all data references with an arbitrary number
formula = formula.replace(/@([a-z.0-9_-]+)/gi, "1");
// Attempt to evaluate the roll
try {
const r = new this(formula);
r.evaluateSync({ strict: false });
return true;
}
// If we weren't able to evaluate, the formula is invalid
catch(err) {
return false;
}
}
/* -------------------------------------------- */
/**
* Determine which of the given terms require external fulfillment.
* @param {RollTerm[]} terms The terms.
* @returns {DiceTerm[]}
*/
static identifyFulfillableTerms(terms) {
const fulfillable = [];
const config = game.settings.get("core", "diceConfiguration");
const allowManual = game.user.hasPermission("MANUAL_ROLLS");
/**
* Determine if a given term should be externally fulfilled.
* @param {RollTerm} term The term.
*/
const identifyTerm = term => {
if ( !(term instanceof DiceTerm) || !term.number || !term.faces ) return;
const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
if ( (method === "manual") && !allowManual ) return;
if ( CONFIG.Dice.fulfillment.methods[method]?.interactive ) fulfillable.push(term);
};
/**
* Identify any DiceTerms in the provided list of terms.
* @param {RollTerm[]} terms The terms.
*/
const identifyDice = (terms=[]) => {
terms.forEach(term => {
identifyTerm(term);
if ( "dice" in term ) identifyDice(term.dice);
});
};
identifyDice(terms);
return fulfillable;
}
/* -------------------------------------------- */
/**
* Classify a remaining string term into a recognized RollTerm class
* @param {string} term A remaining un-classified string
* @param {object} [options={}] Options which customize classification
* @param {boolean} [options.intermediate=true] Allow intermediate terms
* @param {RollTerm|string} [options.prior] The prior classified term
* @param {RollTerm|string} [options.next] The next term to classify
* @returns {RollTerm} A classified RollTerm instance
* @internal
*/
static _classifyStringTerm(term, {intermediate=true, prior, next}={}) {
// Terms already classified
if ( term instanceof RollTerm ) return term;
// Numeric terms
const numericMatch = NumericTerm.matchTerm(term);
if ( numericMatch ) return NumericTerm.fromMatch(numericMatch);
// Dice terms
const diceMatch = DiceTerm.matchTerm(term, {imputeNumber: !intermediate});
if ( diceMatch ) {
if ( intermediate && (prior?.isIntermediate || next?.isIntermediate) ) return new StringTerm({term});
return DiceTerm.fromMatch(diceMatch);
}
// Remaining strings
return new StringTerm({term});
}
/* -------------------------------------------- */
/* Chat Messages */
/* -------------------------------------------- */
/**
* Render the tooltip HTML for a Roll instance
* @returns {Promise<string>} The rendered HTML tooltip as a string
*/
async getTooltip() {
const parts = this.dice.map(d => d.getTooltipData());
return renderTemplate(this.constructor.TOOLTIP_TEMPLATE, { parts });
}
/* -------------------------------------------- */
/**
* Render a Roll instance to HTML
* @param {object} [options={}] Options which affect how the Roll is rendered
* @param {string} [options.flavor] Flavor text to include
* @param {string} [options.template] A custom HTML template path
* @param {boolean} [options.isPrivate=false] Is the Roll displayed privately?
* @returns {Promise<string>} The rendered HTML template as a string
*/
async render({flavor, template=this.constructor.CHAT_TEMPLATE, isPrivate=false}={}) {
if ( !this._evaluated ) await this.evaluate({allowInteractive: !isPrivate});
const chatData = {
formula: isPrivate ? "???" : this._formula,
flavor: isPrivate ? null : flavor ?? this.options.flavor,
user: game.user.id,
tooltip: isPrivate ? "" : await this.getTooltip(),
total: isPrivate ? "?" : Math.round(this.total * 100) / 100
};
return renderTemplate(template, chatData);
}
/* -------------------------------------------- */
/**
* Transform a Roll instance into a ChatMessage, displaying the roll result.
* This function can either create the ChatMessage directly, or return the data object that will be used to create.
*
* @param {object} messageData The data object to use when creating the message
* @param {options} [options] Additional options which modify the created message.
* @param {string} [options.rollMode] The template roll mode to use for the message from CONFIG.Dice.rollModes
* @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the
* prepared chatData object.
* @returns {Promise<ChatMessage|object>} A promise which resolves to the created ChatMessage document if create is
* true, or the Object of prepared chatData otherwise.
*/
async toMessage(messageData={}, {rollMode, create=true}={}) {
if ( rollMode === "roll" ) rollMode = undefined;
rollMode ||= game.settings.get("core", "rollMode");
// Perform the roll, if it has not yet been rolled
if ( !this._evaluated ) await this.evaluate({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND});
// Prepare chat data
messageData = foundry.utils.mergeObject({
user: game.user.id,
content: String(this.total),
sound: CONFIG.sounds.dice
}, messageData);
messageData.rolls = [this];
// Either create the message or just return the chat data
const cls = getDocumentClass("ChatMessage");
const msg = new cls(messageData);
// Either create or return the data
if ( create ) return cls.create(msg.toObject(), { rollMode });
else {
msg.applyRollMode(rollMode);
return msg.toObject();
}
}
/* -------------------------------------------- */
/* Interface Helpers */
/* -------------------------------------------- */
/**
* Expand an inline roll element to display its contained dice result as a tooltip.
* @param {HTMLAnchorElement} a The inline-roll button
* @returns {Promise<void>}
*/
static async expandInlineResult(a) {
if ( !a.classList.contains("inline-roll") ) return;
if ( a.classList.contains("expanded") ) return;
// Create a new tooltip
const roll = this.fromJSON(unescape(a.dataset.roll));
const tip = document.createElement("div");
tip.innerHTML = await roll.getTooltip();
// Add the tooltip
const tooltip = tip.querySelector(".dice-tooltip");
if ( !tooltip ) return;
a.appendChild(tooltip);
a.classList.add("expanded");
// Set the position
const pa = a.getBoundingClientRect();
const pt = tooltip.getBoundingClientRect();
tooltip.style.left = `${Math.min(pa.x, window.innerWidth - (pt.width + 3))}px`;
tooltip.style.top = `${Math.min(pa.y + pa.height + 3, window.innerHeight - (pt.height + 3))}px`;
const zi = getComputedStyle(a).zIndex;
tooltip.style.zIndex = Number.isNumeric(zi) ? zi + 1 : 100;
// Disable tooltip while expanded
delete a.dataset.tooltip;
game.tooltip.deactivate();
}
/* -------------------------------------------- */
/**
* Collapse an expanded inline roll to conceal its tooltip.
* @param {HTMLAnchorElement} a The inline-roll button
*/
static collapseInlineResult(a) {
if ( !a.classList.contains("inline-roll") ) return;
if ( !a.classList.contains("expanded") ) return;
const tooltip = a.querySelector(".dice-tooltip");
if ( tooltip ) tooltip.remove();
const roll = this.fromJSON(unescape(a.dataset.roll));
a.dataset.tooltip = roll.formula;
return a.classList.remove("expanded");
}
/* -------------------------------------------- */
/**
* Construct an inline roll link for this Roll.
* @param {object} [options] Additional options to configure how the link is constructed.
* @param {string} [options.label] A custom label for the total.
* @param {Record<string, string>} [options.attrs] Attributes to set on the link.
* @param {Record<string, string>} [options.dataset] Custom data attributes to set on the link.
* @param {string[]} [options.classes] Additional classes to add to the link. The classes `inline-roll`
* and `inline-result` are added by default.
* @param {string} [options.icon] A font-awesome icon class to use as the icon instead of a d20.
* @returns {HTMLAnchorElement}
*/
toAnchor({attrs={}, dataset={}, classes=[], label, icon}={}) {
dataset = foundry.utils.mergeObject({roll: escape(JSON.stringify(this))}, dataset);
const a = document.createElement("a");
a.classList.add("inline-roll", "inline-result", ...classes);
a.dataset.tooltip = this.formula;
Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v));
Object.entries(dataset).forEach(([k, v]) => a.dataset[k] = v);
label = label ? `${label}: ${this.total}` : this.total;
a.innerHTML = `<i class="${icon ?? "fas fa-dice-d20"}"></i>${label}`;
return a;
}
/* -------------------------------------------- */
/* Serialization and Loading */
/* -------------------------------------------- */
/**
* Represent the data of the Roll as an object suitable for JSON serialization.
* @returns {object} Structured data which can be serialized into JSON
*/
toJSON() {
return {
class: this.constructor.name,
options: this.options,
dice: this._dice,
formula: this._formula,
terms: this.terms.map(t => t.toJSON()),
total: this._total,
evaluated: this._evaluated
};
}
/* -------------------------------------------- */
/**
* Recreate a Roll instance using a provided data object
* @param {object} data Unpacked data representing the Roll
* @returns {Roll} A reconstructed Roll instance
*/
static fromData(data) {
// Redirect to the proper Roll class definition
if ( data.class && (data.class !== this.name) ) {
const cls = CONFIG.Dice.rolls.find(cls => cls.name === data.class);
if ( !cls ) throw new Error(`Unable to recreate ${data.class} instance from provided data`);
return cls.fromData(data);
}
// Create the Roll instance
const roll = new this(data.formula, data.data, data.options);
// Expand terms
roll.terms = data.terms.map(t => {
if ( t.class ) {
if ( t.class === "DicePool" ) t.class = "PoolTerm"; // Backwards compatibility
if ( t.class === "MathTerm" ) t.class = "FunctionTerm";
return RollTerm.fromData(t);
}
return t;
});
// Repopulate evaluated state
if ( data.evaluated ?? true ) {
roll._total = data.total;
roll._dice = (data.dice || []).map(t => DiceTerm.fromData(t));
roll._evaluated = true;
}
return roll;
}
/* -------------------------------------------- */
/**
* Recreate a Roll instance using a provided JSON string
* @param {string} json Serialized JSON data representing the Roll
* @returns {Roll} A reconstructed Roll instance
*/
static fromJSON(json) {
return this.fromData(JSON.parse(json));
}
/* -------------------------------------------- */
/**
* Manually construct a Roll object by providing an explicit set of input terms
* @param {RollTerm[]} terms The array of terms to use as the basis for the Roll
* @param {object} [options={}] Additional options passed to the Roll constructor
* @returns {Roll} The constructed Roll instance
*
* @example Construct a Roll instance from an array of component terms
* ```js
* const t1 = new Die({number: 4, faces: 8};
* const plus = new OperatorTerm({operator: "+"});
* const t2 = new NumericTerm({number: 8});
* const roll = Roll.fromTerms([t1, plus, t2]);
* roll.formula; // 4d8 + 8
* ```
*/
static fromTerms(terms, options={}) {
// Validate provided terms
if ( !terms.every(t => t instanceof RollTerm ) ) {
throw new Error("All provided terms must be RollTerm instances");
}
const allEvaluated = terms.every(t => t._evaluated);
const noneEvaluated = !terms.some(t => t._evaluated);
if ( !(allEvaluated || noneEvaluated) ) {
throw new Error("You can only call Roll.fromTerms with an array of terms which are either all evaluated, or none evaluated");
}
// Construct the roll
const formula = this.getFormula(terms);
const roll = new this(formula, {}, options);
roll.terms = terms;
roll._evaluated = allEvaluated;
if ( roll._evaluated ) roll._total = roll._evaluateTotal();
return roll;
}
}