This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
/** @module dice */
export * as types from "./_types.mjs";
export * as terms from "./terms/_module.mjs";
export {default as Roll} from "./roll.mjs";
export {default as RollGrammar} from "./grammar.pegjs";
export {default as RollParser} from "./parser.mjs";
export {default as MersenneTwister} from "./twister.mjs";

View File

@@ -0,0 +1,75 @@
/**
* @typedef {Object} DiceTermResult
* @property {number} result The numeric result
* @property {boolean} [active] Is this result active, contributing to the total?
* @property {number} [count] A value that the result counts as, otherwise the result is not used directly as
* @property {boolean} [success] Does this result denote a success?
* @property {boolean} [failure] Does this result denote a failure?
* @property {boolean} [discarded] Was this result discarded?
* @property {boolean} [rerolled] Was this result rerolled?
* @property {boolean} [exploded] Was this result exploded?
*/
/* -------------------------------------------- */
/* Roll Parsing Types */
/* -------------------------------------------- */
/**
* @typedef {object} RollParseNode
* @property {string} class The class name for this node.
* @property {string} formula The original matched text for this node.
*/
/**
* @typedef {RollParseNode} RollParseTreeNode
* @property {string} operator The binary operator.
* @property {[RollParseNode, RollParseNode]} operands The two operands.
*/
/**
* @typedef {RollParseNode} FlavorRollParseNode
* @property {object} options
* @property {string} options.flavor Flavor text associated with the node.
*/
/**
* @typedef {FlavorRollParseNode} ModifiersRollParseNode
* @property {string} modifiers The matched modifiers string.
*/
/**
* @typedef {FlavorRollParseNode} NumericRollParseNode
* @property {number} number The number.
*/
/**
* @typedef {FlavorRollParseNode} FunctionRollParseNode
* @property {string} fn The function name.
* @property {RollParseNode[]} terms The arguments to the function.
*/
/**
* @typedef {ModifiersRollParseNode} PoolRollParseNode
* @property {RollParseNode[]} terms The pool terms.
*/
/**
* @typedef {FlavorRollParseNode} ParentheticalRollParseNode
* @property {string} term The inner parenthetical term.
*/
/**
* @typedef {FlavorRollParseNode} StringParseNode
* @property {string} term The unclassified string term.
*/
/**
* @typedef {ModifiersRollParseNode} DiceRollParseNode
* @property {number|ParentheticalRollParseNode} number The number of dice.
* @property {string|number|ParentheticalRollParseNode} faces The number of faces or a string denomination like "c" or
* "f".
*/
/**
* @typedef {null|number|string|RollParseNode|RollParseArg[]} RollParseArg
*/

View File

@@ -0,0 +1,91 @@
{
/*
This is a per-parser initialization block. It runs whenever the parser is invoked. Any variables declared here are
available in any javascript blocks in the rest of the grammar.
In addition to the parser generated by peggy, we allow for certain parts of the process to be delegated to client
code. A class implementing this 'parser' API may be passed-in here as an option when the peggy parser is invoked,
otherwise we use the one configured at CONFIG.Dice.parser.
*/
const Parser = options.parser ?? CONFIG.Dice.parser;
const parser = new Parser(input);
}
Expression = _ leading:(_ @Additive)* _ head:Term tail:(_ @Operators _ @Term)* _ {
/*
The grammar rules are matched in order of precedence starting at the top of the file, so the rules that match the
largest portions of a string should generally go at the top, with matches for smaller substrings going at the bottom.
Here we have a rule that matches the overall roll formula. If a given formula does not match this rule, it means that
it is an invalid formula and will throw a parsing error.
Prefixing a pattern with 'foo:' is a way to give a name to the sub-match in the associated javascript code. We use
this fairly heavily since we want to forward these sub-matches onto the 'parser'. We can think of them like named
capture groups.
Prefixing a pattern with '@' is called 'plucking', and is used to identify sub-matches that should be assigned to the
overall capture name (like 'foo:'), ignoring any that are not 'plucked'.
For example 'tail:(_ @Operators _ @Term)*' matches operators followed by terms, with any amount of whitespace
in-between, however only the operator and term matches are assigned to the 'tail' variable, the whitespace is ignored.
The 'head:A tail:(Delimiter B)*' pattern is a way of representing a string of things separated by a delimiter,
like 'A + B + C' for formulas, or 'A, B, C' for Pool and Math terms.
In each of these cases we follow the same pattern: We match a term, then we call 'parser.on*', and that method is
responsible for taking the raw matched sub-strings and returning a 'parse node', i.e. a plain javascript object that
contains all the information we need to instantiate a real `RollTerm`.
*/
return parser._onExpression(head, tail, leading, text(), error);
}
Term = FunctionTerm / DiceTerm / NumericTerm / PoolTerm / Parenthetical / StringTerm
FunctionTerm = fn:FunctionName "(" _ head:Expression? _ tail:(_ "," _ @Expression)* _ ")" flavor:Flavor? {
return parser._onFunctionTerm(fn, head, tail, flavor, text());
}
DiceTerm = number:(Parenthetical / Constant)? [dD] faces:([a-z]i / Parenthetical / Constant) modifiers:Modifiers? flavor:Flavor? {
return parser._onDiceTerm(number, faces, modifiers, flavor, text());
}
NumericTerm = number:Constant flavor:Flavor? !StringTerm { return parser._onNumericTerm(number, flavor); }
PoolTerm = "{" _ head:Expression tail:("," _ @Expression)* "}" modifiers:Modifiers? flavor:Flavor? {
return parser._onPoolTerm(head, tail, modifiers, flavor, text());
}
Parenthetical = "(" _ term:Expression _ ")" flavor:Flavor? { return parser._onParenthetical(term, flavor, text()); }
StringTerm = term:(ReplacedData / PlainString) flavor:Flavor? { return parser._onStringTerm(term, flavor); }
ReplacedData = $("$" $[^$]+ "$")
PlainString = $[^ (){}[\]$,+\-*%/]+
FunctionName = $([a-z$_]i [a-z$_0-9]i*)
Modifiers = $([^ (){}[\]$+\-*/,]+)
Constant = _ [0-9]+ ("." [0-9]+)? { return Number(text()); }
Operators = MultiplicativeFirst / AdditiveOnly
MultiplicativeFirst = head:Multiplicative tail:(_ @Additive)* { return [head, ...tail]; }
AdditiveOnly = head:Additive tail:(_ @Additive)* { return [null, head, ...tail]; }
Multiplicative = "*" / "/" / "%"
Additive = "+" / "-"
Flavor = "[" @$[^[\]]+ "]"
_ "whitespace" = [ ]*

View File

@@ -0,0 +1,370 @@
import { getType } from "../../common/utils/helpers.mjs";
import OperatorTerm from "./terms/operator.mjs";
/**
* @typedef {import("../_types.mjs").RollParseNode} RollParseNode
* @typedef {import("../_types.mjs").RollParseTreeNode} RollParseTreeNode
* @typedef {import("../_types.mjs").NumericRollParseNode} NumericRollParseNode
* @typedef {import("../_types.mjs").FunctionRollParseNode} FunctionRollParseNode
* @typedef {import("../_types.mjs").PoolRollParseNode} PoolRollParseNode
* @typedef {import("../_types.mjs").ParentheticalRollParseNode} ParentheticalRollParseNode
* @typedef {import("../_types.mjs").DiceRollParseNode} DiceRollParseNode
* @typedef {import("../_types.mjs").RollParseArg} RollParseArg
*/
/**
* A class for transforming events from the Peggy grammar lexer into various formats.
*/
export default class RollParser {
/**
* @param {string} formula The full formula.
*/
constructor(formula) {
this.formula = formula;
}
/**
* The full formula.
* @type {string}
*/
formula;
/* -------------------------------------------- */
/* Parse Events */
/* -------------------------------------------- */
/**
* Handle a base roll expression.
* @param {RollParseNode} head The first operand.
* @param {[string[], RollParseNode][]} tail Zero or more subsequent (operators, operand) tuples.
* @param {string} [leading] A leading operator.
* @param {string} formula The original matched text.
* @param {function} error The peggy error callback to invoke on a parse error.
* @returns {RollParseTreeNode}
* @internal
* @protected
*/
_onExpression(head, tail, leading, formula, error) {
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onExpression", head, tail));
if ( leading.length ) leading = this._collapseOperators(leading);
if ( leading === "-" ) head = this._wrapNegativeTerm(head);
// We take the list of (operator, operand) tuples and arrange them into a left-skewed binary tree.
return tail.reduce((acc, [operators, operand]) => {
let operator;
let [multiplicative, ...additive] = operators;
if ( additive.length ) additive = this._collapseOperators(additive);
if ( multiplicative ) {
operator = multiplicative;
if ( additive === "-" ) operand = this._wrapNegativeTerm(operand);
}
else operator = additive;
if ( typeof operator !== "string" ) error(`Failed to parse ${formula}. Unexpected operator.`);
const operands = [acc, operand];
return { class: "Node", formula: `${acc.formula} ${operator} ${operand.formula}`, operands, operator };
}, head);
}
/* -------------------------------------------- */
/**
* Handle a dice term.
* @param {NumericRollParseNode|ParentheticalRollParseNode|null} number The number of dice.
* @param {string|NumericRollParseNode|ParentheticalRollParseNode|null} faces The number of die faces or a string
* denomination like "c" or "f".
* @param {string|null} modifiers The matched modifiers string.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {DiceRollParseNode}
* @internal
* @protected
*/
_onDiceTerm(number, faces, modifiers, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onDiceTerm", number, faces, modifiers, flavor, formula));
}
return { class: "DiceTerm", formula, modifiers, number, faces, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a numeric term.
* @param {number} number The number.
* @param {string} flavor Associated flavor text.
* @returns {NumericRollParseNode}
* @internal
* @protected
*/
_onNumericTerm(number, flavor) {
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onNumericTerm", number, flavor));
return {
class: "NumericTerm", number,
formula: `${number}${flavor ? `[${flavor}]` : ""}`,
evaluated: false,
options: { flavor }
};
}
/* -------------------------------------------- */
/**
* Handle a math term.
* @param {string} fn The Math function.
* @param {RollParseNode} head The first term.
* @param {RollParseNode[]} tail Zero or more additional terms.
* @param {string} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {FunctionRollParseNode}
* @internal
* @protected
*/
_onFunctionTerm(fn, head, tail, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onFunctionTerm", fn, head, tail, flavor, formula));
}
const terms = [];
if ( head ) terms.push(head, ...tail);
return { class: "FunctionTerm", fn, terms, formula, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a pool term.
* @param {RollParseNode} head The first term.
* @param {RollParseNode[]} tail Zero or more additional terms.
* @param {string|null} modifiers The matched modifiers string.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {PoolRollParseNode}
* @internal
* @protected
*/
_onPoolTerm(head, tail, modifiers, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onPoolTerm", head, tail, modifiers, flavor, formula));
}
const terms = [];
if ( head ) terms.push(head, ...tail);
return { class: "PoolTerm", terms, formula, modifiers, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle a parenthetical.
* @param {RollParseNode} term The inner term.
* @param {string|null} flavor Associated flavor text.
* @param {string} formula The original matched text.
* @returns {ParentheticalRollParseNode}
* @internal
* @protected
*/
_onParenthetical(term, flavor, formula) {
if ( CONFIG.debug.rollParsing ) {
console.debug(this.constructor.formatDebug("onParenthetical", term, flavor, formula));
}
return { class: "ParentheticalTerm", term, formula, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Handle some string that failed to be classified.
* @param {string} term The term.
* @param {string|null} [flavor] Associated flavor text.
* @returns {StringParseNode}
* @protected
*/
_onStringTerm(term, flavor) {
return { class: "StringTerm", term, evaluated: false, options: { flavor } };
}
/* -------------------------------------------- */
/**
* Collapse multiple additive operators into a single one.
* @param {string[]} operators A sequence of additive operators.
* @returns {string}
* @protected
*/
_collapseOperators(operators) {
let head = operators.pop();
for ( const operator of operators ) {
if ( operator === "-" ) head = head === "+" ? "-" : "+";
}
return head;
}
/* -------------------------------------------- */
/**
* Wrap a term with a leading minus.
* @param {RollParseNode} term The term to wrap.
* @returns {RollParseNode}
* @protected
*/
_wrapNegativeTerm(term) {
// Special case when we have a numeric term, otherwise we wrap it in a parenthetical.
if ( term.class === "NumericTerm" ) {
term.number *= -1;
term.formula = `-${term.formula}`;
return term;
}
return foundry.dice.RollGrammar.parse(`(${term.formula} * -1)`, { parser: this.constructor });
}
/* -------------------------------------------- */
/* Tree Manipulation */
/* -------------------------------------------- */
/**
* Flatten a tree structure (either a parse tree or AST) into an array with operators in infix notation.
* @param {RollParseNode} root The root of the tree.
* @returns {RollParseNode[]}
*/
static flattenTree(root) {
const list = [];
/**
* Flatten the given node.
* @param {RollParseNode} node The node.
*/
function flattenNode(node) {
if ( node.class !== "Node" ) {
list.push(node);
return;
}
const [left, right] = node.operands;
flattenNode(left);
list.push({ class: "OperatorTerm", operator: node.operator, evaluated: false });
flattenNode(right);
}
flattenNode(root);
return list;
}
/* -------------------------------------------- */
/**
* Use the Shunting Yard algorithm to convert a parse tree or list of terms into an AST with correct operator
* precedence.
* See https://en.wikipedia.org/wiki/Shunting_yard_algorithm for a description of the algorithm in detail.
* @param {RollParseNode|RollTerm[]} root The root of the parse tree or a list of terms.
* @returns {RollParseNode} The root of the AST.
*/
static toAST(root) {
// Flatten the parse tree to an array representing the original formula in infix notation.
const list = Array.isArray(root) ? root : this.flattenTree(root);
const operators = [];
const output = [];
/**
* Pop operators from the operator stack and push them onto the output stack until we reach an operator with lower
* or equal precedence and left-associativity.
* @param {RollParseNode} op The target operator to push.
*/
function pushOperator(op) {
let peek = operators.at(-1);
// We assume all our operators are left-associative, so we only check if the precedence is lower or equal here.
while ( peek && ((OperatorTerm.PRECEDENCE[peek.operator] ?? 0) >= (OperatorTerm.PRECEDENCE[op.operator] ?? 0)) ) {
output.push(operators.pop());
peek = operators.at(-1);
}
operators.push(op);
}
for ( const node of list ) {
// If this is an operator, push it onto the operators stack.
if ( this.isOperatorTerm(node) ) {
pushOperator(node);
continue;
}
// Recursively reorganize inner terms to AST sub-trees.
if ( node.class === "ParentheticalTerm" ) node.term = this.toAST(node.term);
else if ( (node.class === "FunctionTerm") || (node.class === "PoolTerm") ) {
node.terms = node.terms.map(term => this.toAST(term));
}
// Push the node onto the output stack.
output.push(node);
}
// Pop remaining operators off the operator stack and onto the output stack.
while ( operators.length ) output.push(operators.pop());
// The output now contains the formula in postfix notation, with correct operator precedence applied. We recombine
// it into a tree by matching each postfix operator with two operands.
const ast = [];
for ( const node of output ) {
if ( !this.isOperatorTerm(node) ) {
ast.push(node);
continue;
}
const right = ast.pop();
const left = ast.pop();
ast.push({ class: "Node", operator: node.operator, operands: [left, right] });
}
// The postfix array has been recombined into an array of one element, which is the root of the new AST.
return ast.pop();
}
/* -------------------------------------------- */
/**
* Determine if a given node is an operator term.
* @param {RollParseNode|RollTerm} node
*/
static isOperatorTerm(node) {
return (node instanceof OperatorTerm) || (node.class === "OperatorTerm");
}
/* -------------------------------------------- */
/* Debug Formatting */
/* -------------------------------------------- */
/**
* Format a list argument.
* @param {RollParseArg[]} list The list to format.
* @returns {string}
*/
static formatList(list) {
if ( !list ) return "[]";
return `[${list.map(RollParser.formatArg).join(", ")}]`;
}
/* -------------------------------------------- */
/**
* Format a parser argument.
* @param {RollParseArg} arg The argument.
* @returns {string}
*/
static formatArg(arg) {
switch ( getType(arg) ) {
case "null": return "null";
case "number": return `${arg}`;
case "string": return `"${arg}"`;
case "Object": return arg.class;
case "Array": return RollParser.formatList(arg);
}
}
/* -------------------------------------------- */
/**
* Format arguments for debugging.
* @param {string} method The method name.
* @param {...RollParseArg} args The arguments.
* @returns {string}
*/
static formatDebug(method, ...args) {
return `${method}(${args.map(RollParser.formatArg).join(", ")})`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
export {default as Coin} from "./coin.mjs";
export {default as DiceTerm} from "./dice.mjs";
export {default as Die} from "./die.mjs"
export {default as FateDie} from "./fate.mjs";
export {default as FunctionTerm} from "./function.mjs";
export {default as NumericTerm} from "./numeric.mjs";
export {default as OperatorTerm} from "./operator.mjs";
export {default as ParentheticalTerm} from "./parenthetical.mjs";
export {default as PoolTerm} from "./pool.mjs";
export {default as RollTerm} from "./term.mjs";
export {default as StringTerm} from "./string.mjs";

View File

@@ -0,0 +1,89 @@
import DiceTerm from "./dice.mjs";
/**
* A type of DiceTerm used to represent flipping a two-sided coin.
* @implements {DiceTerm}
*/
export default class Coin extends DiceTerm {
constructor(termData) {
termData.faces = 2;
super(termData);
}
/** @inheritdoc */
static DENOMINATION = "c";
/** @inheritdoc */
static MODIFIERS = {
"c": "call"
};
/* -------------------------------------------- */
/** @inheritdoc */
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = 0;
else if ( maximize ) roll.result = 1;
else roll.result = await this._roll(options);
if ( roll.result === undefined ) roll.result = this.randomFace();
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultLabel(result) {
return {
"0": "T",
"1": "H"
}[result.result];
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultCSS(result) {
return [
this.constructor.name.toLowerCase(),
result.result === 1 ? "heads" : "tails",
result.success ? "success" : null,
result.failure ? "failure" : null
]
}
/* -------------------------------------------- */
/** @override */
mapRandomFace(randomUniform) {
return Math.round(randomUniform);
}
/* -------------------------------------------- */
/* Term Modifiers */
/* -------------------------------------------- */
/**
* Call the result of the coin flip, marking any coins that matched the called target as a success
* 3dcc1 Flip 3 coins and treat "heads" as successes
* 2dcc0 Flip 2 coins and treat "tails" as successes
* @param {string} modifier The matched modifier query
*/
call(modifier) {
// Match the modifier
const rgx = /c([01])/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
// Treat each result which matched the call as a success
for ( let r of this.results ) {
const match = r.result === target;
r.count = match ? 1 : 0;
r.success = match;
}
}
}

View File

@@ -0,0 +1,705 @@
import RollTerm from "./term.mjs";
/**
* @typedef {import("../_types.mjs").DiceTermResult} DiceTermResult
*/
/**
* An abstract base class for any type of RollTerm which involves randomized input from dice, coins, or other devices.
* @extends RollTerm
*/
export default class DiceTerm extends RollTerm {
/**
* @param {object} termData Data used to create the Dice Term, including the following:
* @param {number|Roll} [termData.number=1] The number of dice of this term to roll, before modifiers are applied, or
* a Roll instance that will be evaluated to a number.
* @param {number|Roll} termData.faces The number of faces on each die of this type, or a Roll instance that
* will be evaluated to a number.
* @param {string[]} [termData.modifiers] An array of modifiers applied to the results
* @param {object[]} [termData.results] An optional array of pre-cast results for the term
* @param {object} [termData.options] Additional options that modify the term
*/
constructor({number=1, faces=6, method, modifiers=[], results=[], options={}}) {
super({options});
this._number = number;
this._faces = faces;
this.method = method;
this.modifiers = modifiers;
this.results = results;
// If results were explicitly passed, the term has already been evaluated
if ( results.length ) this._evaluated = true;
}
/* -------------------------------------------- */
/**
* The resolution method used to resolve this DiceTerm.
* @type {string}
*/
get method() {
return this.#method;
}
set method(method) {
if ( this.#method || !(method in CONFIG.Dice.fulfillment.methods) ) return;
this.#method = method;
}
#method;
/**
* An Array of dice term modifiers which are applied
* @type {string[]}
*/
modifiers;
/**
* The array of dice term results which have been rolled
* @type {DiceTermResult[]}
*/
results;
/**
* Define the denomination string used to register this DiceTerm type in CONFIG.Dice.terms
* @type {string}
*/
static DENOMINATION = "";
/**
* Define the named modifiers that can be applied for this particular DiceTerm type.
* @type {Record<string, string|Function>}
*/
static MODIFIERS = {};
/**
* A regular expression pattern which captures the full set of term modifiers
* Anything until a space, group symbol, or arithmetic operator
* @type {string}
*/
static MODIFIERS_REGEXP_STRING = "([^ (){}[\\]+\\-*/]+)";
/**
* A regular expression used to separate individual modifiers
* @type {RegExp}
*/
static MODIFIER_REGEXP = /([A-z]+)([^A-z\s()+\-*\/]+)?/g
/** @inheritdoc */
static REGEXP = new RegExp(`^([0-9]+)?[dD]([A-z]|[0-9]+)${this.MODIFIERS_REGEXP_STRING}?${this.FLAVOR_REGEXP_STRING}?$`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["number", "faces", "modifiers", "results", "method"];
/* -------------------------------------------- */
/* Dice Term Attributes */
/* -------------------------------------------- */
/**
* The number of dice of this term to roll. Returns undefined if the number is a complex term that has not yet been
* evaluated.
* @type {number|void}
*/
get number() {
if ( typeof this._number === "number" ) return this._number;
else if ( this._number?._evaluated ) return this._number.total;
}
/**
* The number of dice of this term to roll, before modifiers are applied, or a Roll instance that will be evaluated to
* a number.
* @type {number|Roll}
* @protected
*/
_number;
set number(value) {
this._number = value;
}
/* -------------------------------------------- */
/**
* The number of faces on the die. Returns undefined if the faces are represented as a complex term that has not yet
* been evaluated.
* @type {number|void}
*/
get faces() {
if ( typeof this._faces === "number" ) return this._faces;
else if ( this._faces?._evaluated ) return this._faces.total;
}
/**
* The number of faces on the die, or a Roll instance that will be evaluated to a number.
* @type {number|Roll}
* @protected
*/
_faces;
set faces(value) {
this._faces = value;
}
/* -------------------------------------------- */
/** @inheritdoc */
get expression() {
const x = this.constructor.DENOMINATION === "d" ? this._faces : this.constructor.DENOMINATION;
return `${this._number}d${x}${this.modifiers.join("")}`;
}
/* -------------------------------------------- */
/**
* The denomination of this DiceTerm instance.
* @type {string}
*/
get denomination() {
return this.constructor.DENOMINATION;
}
/* -------------------------------------------- */
/**
* An array of additional DiceTerm instances involved in resolving this DiceTerm.
* @type {DiceTerm[]}
*/
get dice() {
const dice = [];
if ( this._number instanceof Roll ) dice.push(...this._number.dice);
if ( this._faces instanceof Roll ) dice.push(...this._faces.dice);
return dice;
}
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
if ( !this._evaluated ) return undefined;
let total = this.results.reduce((t, r) => {
if ( !r.active ) return t;
if ( r.count !== undefined ) return t + r.count;
else return t + r.result;
}, 0);
if ( this.number < 0 ) total *= -1;
return total;
}
/* -------------------------------------------- */
/**
* Return an array of rolled values which are still active within this term
* @type {number[]}
*/
get values() {
return this.results.reduce((arr, r) => {
if ( !r.active ) return arr;
arr.push(r.result);
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
get isDeterministic() {
return false;
}
/* -------------------------------------------- */
/* Dice Term Methods */
/* -------------------------------------------- */
/**
* Alter the DiceTerm 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.
* @returns {DiceTerm} The altered term
*/
alter(multiply, add) {
if ( this._evaluated ) throw new Error(`You may not alter a DiceTerm after it has already been evaluated`);
multiply = Number.isFinite(multiply) && (multiply >= 0) ? multiply : 1;
add = Number.isInteger(add) ? add : 0;
if ( multiply >= 0 ) {
if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} * ${multiply})`);
else this._number = Math.round(this.number * multiply);
}
if ( add ) {
if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} + ${add})`);
else this._number += add;
}
return this;
}
/* -------------------------------------------- */
/** @inheritDoc */
_evaluate(options={}) {
if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
return this._evaluateAsync(options);
}
/* -------------------------------------------- */
/**
* Evaluate this dice term asynchronously.
* @param {object} [options] Options forwarded to inner Roll evaluation.
* @returns {Promise<DiceTerm>}
* @protected
*/
async _evaluateAsync(options={}) {
for ( const roll of [this._faces, this._number] ) {
if ( !(roll instanceof Roll) ) continue;
if ( this._root ) roll._root = this._root;
await roll.evaluate(options);
}
if ( Math.abs(this.number) > 999 ) {
throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
}
// If this term was an intermediate term, it has not yet been added to the resolver, so we add it here.
if ( this.resolver && !this._id ) await this.resolver.addTerm(this);
for ( let n = this.results.length; n < Math.abs(this.number); n++ ) await this.roll(options);
await this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/**
* Evaluate deterministic values of this term synchronously.
* @param {object} [options]
* @param {boolean} [options.maximize] Force the result to be maximized.
* @param {boolean} [options.minimize] Force the result to be minimized.
* @param {boolean} [options.strict] Throw an error if attempting to evaluate a die term in a way that cannot be
* done synchronously.
* @returns {DiceTerm}
* @protected
*/
_evaluateSync(options={}) {
if ( this._faces instanceof Roll ) this._faces.evaluateSync(options);
if ( this._number instanceof Roll ) this._number.evaluateSync(options);
if ( Math.abs(this.number) > 999 ) {
throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
}
for ( let n = this.results.length; n < Math.abs(this.number); n++ ) {
const roll = { active: true };
if ( options.minimize ) roll.result = Math.min(1, this.faces);
else if ( options.maximize ) roll.result = this.faces;
else if ( options.strict ) throw new Error("Cannot synchronously evaluate a non-deterministic term.");
else continue;
this.results.push(roll);
}
return this;
}
/* -------------------------------------------- */
/**
* Roll the DiceTerm by mapping a random uniform draw against the faces of the dice term.
* @param {object} [options={}] Options which modify how a random result is produced
* @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.
* @returns {Promise<DiceTermResult>} The produced result
*/
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
roll.result = await this._roll(options);
if ( minimize ) roll.result = Math.min(1, this.faces);
else if ( maximize ) roll.result = this.faces;
else if ( roll.result === undefined ) roll.result = this.randomFace();
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/**
* Generate a roll result value for this DiceTerm based on its fulfillment method.
* @param {object} [options] Options forwarded to the fulfillment method handler.
* @returns {Promise<number|void>} Returns a Promise that resolves to the fulfilled number, or undefined if it could
* not be fulfilled.
* @protected
*/
async _roll(options={}) {
return this.#invokeFulfillmentHandler(options);
}
/* -------------------------------------------- */
/**
* Invoke the configured fulfillment handler for this term to produce a result value.
* @param {object} [options] Options forwarded to the fulfillment method handler.
* @returns {Promise<number|void>} Returns a Promise that resolves to the fulfilled number, or undefined if it could
* not be fulfilled.
*/
async #invokeFulfillmentHandler(options={}) {
const config = game.settings.get("core", "diceConfiguration");
const method = config[this.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
if ( (method === "manual") && !game.user.hasPermission("MANUAL_ROLLS") ) return;
const { handler, interactive } = CONFIG.Dice.fulfillment.methods[method] ?? {};
if ( interactive && this.resolver ) return this.resolver.resolveResult(this, method, options);
return handler?.(this, options);
}
/* -------------------------------------------- */
/**
* Maps a randomly-generated value in the interval [0, 1) to a face value on the die.
* @param {number} randomUniform A value to map. Must be in the interval [0, 1).
* @returns {number} The face value.
*/
mapRandomFace(randomUniform) {
return Math.ceil((1 - randomUniform) * this.faces);
}
/* -------------------------------------------- */
/**
* Generate a random face value for this die using the configured PRNG.
* @returns {number}
*/
randomFace() {
return this.mapRandomFace(CONFIG.Dice.randomUniform());
}
/* -------------------------------------------- */
/**
* Return a string used as the label for each rolled result
* @param {DiceTermResult} result The rolled result
* @returns {string} The result label
*/
getResultLabel(result) {
return String(result.result);
}
/* -------------------------------------------- */
/**
* Get the CSS classes that should be used to display each rolled result
* @param {DiceTermResult} result The rolled result
* @returns {string[]} The desired classes
*/
getResultCSS(result) {
const hasSuccess = result.success !== undefined;
const hasFailure = result.failure !== undefined;
const isMax = result.result === this.faces;
const isMin = result.result === 1;
return [
this.constructor.name.toLowerCase(),
"d" + this.faces,
result.success ? "success" : null,
result.failure ? "failure" : null,
result.rerolled ? "rerolled" : null,
result.exploded ? "exploded" : null,
result.discarded ? "discarded" : null,
!(hasSuccess || hasFailure) && isMin ? "min" : null,
!(hasSuccess || hasFailure) && isMax ? "max" : null
]
}
/* -------------------------------------------- */
/**
* Render the tooltip HTML for a Roll instance
* @returns {object} The data object used to render the default tooltip template for this DiceTerm
*/
getTooltipData() {
const { total, faces, flavor } = this;
const method = CONFIG.Dice.fulfillment.methods[this.method];
const icon = method?.interactive ? (method.icon ?? '<i class="fas fa-bluetooth"></i>') : null;
return {
total, faces, flavor, icon,
method: method?.label,
formula: this.expression,
rolls: this.results.map(r => {
return {
result: this.getResultLabel(r),
classes: this.getResultCSS(r).filterJoin(" ")
};
})
};
}
/* -------------------------------------------- */
/* Modifier Methods */
/* -------------------------------------------- */
/**
* Sequentially evaluate each dice roll modifier by passing the term to its evaluation function
* Augment or modify the results array.
* @internal
*/
async _evaluateModifiers() {
const cls = this.constructor;
const requested = foundry.utils.deepClone(this.modifiers);
this.modifiers = [];
// Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest
// prefixes first.
const allModifiers = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length);
// Iterate over requested modifiers
for ( const m of requested ) {
let command = m.match(/[A-z]+/)[0].toLowerCase();
// Matched command
if ( command in cls.MODIFIERS ) {
await this._evaluateModifier(command, m);
continue;
}
// Unmatched compound command
while ( command ) {
let matched = false;
for ( const modifier of allModifiers ) {
if ( command.startsWith(modifier) ) {
matched = true;
await this._evaluateModifier(modifier, modifier);
command = command.replace(modifier, "");
break;
}
}
if ( !matched ) command = "";
}
}
}
/* -------------------------------------------- */
/**
* Asynchronously evaluate a single modifier command, recording it in the array of evaluated modifiers
* @param {string} command The parsed modifier command
* @param {string} modifier The full modifier request
* @internal
*/
async _evaluateModifier(command, modifier) {
let fn = this.constructor.MODIFIERS[command];
if ( typeof fn === "string" ) fn = this[fn];
if ( fn instanceof Function ) {
const result = await fn.call(this, modifier);
const earlyReturn = (result === false) || (result === this); // handling this is backwards compatibility
if ( !earlyReturn ) this.modifiers.push(modifier.toLowerCase());
}
}
/* -------------------------------------------- */
/**
* A helper comparison function.
* Returns a boolean depending on whether the result compares favorably against the target.
* @param {number} result The result being compared
* @param {string} comparison The comparison operator in [=,&lt;,&lt;=,>,>=]
* @param {number} target The target value
* @returns {boolean} Is the comparison true?
*/
static compareResult(result, comparison, target) {
switch ( comparison ) {
case "=":
return result === target;
case "<":
return result < target;
case "<=":
return result <= target;
case ">":
return result > target;
case ">=":
return result >= target;
}
}
/* -------------------------------------------- */
/**
* A helper method to modify the results array of a dice term by flagging certain results are kept or dropped.
* @param {object[]} results The results array
* @param {number} number The number to keep or drop
* @param {boolean} [keep] Keep results?
* @param {boolean} [highest] Keep the highest?
* @returns {object[]} The modified results array
*/
static _keepOrDrop(results, number, {keep=true, highest=true}={}) {
// Sort remaining active results in ascending (keep) or descending (drop) order
const ascending = keep === highest;
const values = results.reduce((arr, r) => {
if ( r.active ) arr.push(r.result);
return arr;
}, []).sort((a, b) => ascending ? a - b : b - a);
// Determine the cut point, beyond which to discard
number = Math.clamp(keep ? values.length - number : number, 0, values.length);
const cut = values[number];
// Track progress
let discarded = 0;
const ties = [];
let comp = ascending ? "<" : ">";
// First mark results on the wrong side of the cut as discarded
results.forEach(r => {
if ( !r.active ) return; // Skip results which have already been discarded
let discard = this.compareResult(r.result, comp, cut);
if ( discard ) {
r.discarded = true;
r.active = false;
discarded++;
}
else if ( r.result === cut ) ties.push(r);
});
// Next discard ties until we have reached the target
ties.forEach(r => {
if ( discarded < number ) {
r.discarded = true;
r.active = false;
discarded++;
}
});
return results;
}
/* -------------------------------------------- */
/**
* A reusable helper function to handle the identification and deduction of failures
*/
static _applyCount(results, comparison, target, {flagSuccess=false, flagFailure=false}={}) {
for ( let r of results ) {
let success = this.compareResult(r.result, comparison, target);
if (flagSuccess) {
r.success = success;
if (success) delete r.failure;
}
else if (flagFailure ) {
r.failure = success;
if (success) delete r.success;
}
r.count = success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* A reusable helper function to handle the identification and deduction of failures
*/
static _applyDeduct(results, comparison, target, {deductFailure=false, invertFailure=false}={}) {
for ( let r of results ) {
// Flag failures if a comparison was provided
if (comparison) {
const fail = this.compareResult(r.result, comparison, target);
if ( fail ) {
r.failure = true;
delete r.success;
}
}
// Otherwise treat successes as failures
else {
if ( r.success === false ) {
r.failure = true;
delete r.success;
}
}
// Deduct failures
if ( deductFailure ) {
if ( r.failure ) r.count = -1;
}
else if ( invertFailure ) {
if ( r.failure ) r.count = -1 * r.result;
}
}
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Determine whether a string expression matches this type of term
* @param {string} expression The expression to parse
* @param {object} [options={}] Additional options which customize the match
* @param {boolean} [options.imputeNumber=true] Allow the number of dice to be optional, i.e. "d6"
* @returns {RegExpMatchArray|null}
*/
static matchTerm(expression, {imputeNumber=true}={}) {
const match = expression.match(this.REGEXP);
if ( !match ) return null;
if ( (match[1] === undefined) && !imputeNumber ) return null;
return match;
}
/* -------------------------------------------- */
/**
* Construct a term of this type given a matched regular expression array.
* @param {RegExpMatchArray} match The matched regular expression array
* @returns {DiceTerm} The constructed term
*/
static fromMatch(match) {
let [number, denomination, modifiers, flavor] = match.slice(1);
// Get the denomination of DiceTerm
denomination = denomination.toLowerCase();
const cls = denomination in CONFIG.Dice.terms ? CONFIG.Dice.terms[denomination] : CONFIG.Dice.terms.d;
if ( !foundry.utils.isSubclass(cls, foundry.dice.terms.DiceTerm) ) {
throw new Error(`DiceTerm denomination ${denomination} not registered to CONFIG.Dice.terms as a valid DiceTerm class`);
}
// Get the term arguments
number = Number.isNumeric(number) ? parseInt(number) : 1;
const faces = Number.isNumeric(denomination) ? parseInt(denomination) : null;
// Match modifiers
modifiers = Array.from((modifiers || "").matchAll(this.MODIFIER_REGEXP)).map(m => m[0]);
// Construct a term of the appropriate denomination
return new cls({number, faces, modifiers, options: {flavor}});
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
let { number, faces } = node;
let denomination = "d";
if ( number === null ) number = 1;
if ( number.class ) {
number = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(number));
}
if ( typeof faces === "string" ) denomination = faces.toLowerCase();
else if ( faces.class ) {
faces = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(faces));
}
const modifiers = Array.from((node.modifiers || "").matchAll(this.MODIFIER_REGEXP)).map(([m]) => m);
const cls = CONFIG.Dice.terms[denomination];
const data = { ...node, number, modifiers, class: cls.name };
if ( denomination === "d" ) data.faces = faces;
return this.fromData(data);
}
/* -------------------------------------------- */
/* Serialization & Loading */
/* -------------------------------------------- */
/** @inheritDoc */
toJSON() {
const data = super.toJSON();
if ( this._number instanceof Roll ) data._number = this._number.toJSON();
if ( this._faces instanceof Roll ) data._faces = this._faces.toJSON();
return data;
}
/* -------------------------------------------- */
/** @inheritDoc */
static _fromData(data) {
if ( data._number ) data.number = Roll.fromData(data._number);
if ( data._faces ) data.faces = Roll.fromData(data._faces);
return super._fromData(data);
}
}

View File

@@ -0,0 +1,421 @@
import DiceTerm from "./dice.mjs";
/**
* A type of DiceTerm used to represent rolling a fair n-sided die.
* @implements {DiceTerm}
*
* @example Roll four six-sided dice
* ```js
* let die = new Die({faces: 6, number: 4}).evaluate();
* ```
*/
export default class Die extends DiceTerm {
/** @inheritdoc */
static DENOMINATION = "d";
/** @inheritdoc */
static MODIFIERS = {
r: "reroll",
rr: "rerollRecursive",
x: "explode",
xo: "explodeOnce",
k: "keep",
kh: "keep",
kl: "keep",
d: "drop",
dh: "drop",
dl: "drop",
min: "minimum",
max: "maximum",
even: "countEven",
odd: "countOdd",
cs: "countSuccess",
cf: "countFailures",
df: "deductFailures",
sf: "subtractFailures",
ms: "marginSuccess"
};
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
const total = super.total;
if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess);
else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total;
else return total;
}
/* -------------------------------------------- */
/** @inheritDoc */
get denomination() {
return `d${this.faces}`;
}
/* -------------------------------------------- */
/* Term Modifiers */
/* -------------------------------------------- */
/**
* Re-roll the Die, rolling additional results for any values which fall within a target set.
* If no target number is specified, re-roll the lowest possible result.
*
* 20d20r reroll all 1s
* 20d20r1 reroll all 1s
* 20d20r=1 reroll all 1s
* 20d20r1=1 reroll a single 1
*
* @param {string} modifier The matched modifier query
* @param {boolean} recursive Reroll recursively, continuing to reroll until the condition is no longer met
* @returns {Promise<false|void>} False if the modifier was unmatched
*/
async reroll(modifier, {recursive=false}={}) {
// Match the re-roll modifier
const rgx = /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [max, comparison, target] = match.slice(1);
// If no comparison or target are provided, treat the max as the target
if ( max && !(target || comparison) ) {
target = max;
max = null;
}
// Determine target values
max = Number.isNumeric(max) ? parseInt(max) : null;
target = Number.isNumeric(target) ? parseInt(target) : 1;
comparison = comparison || "=";
// Recursively reroll until there are no remaining results to reroll
let checked = 0;
const initial = this.results.length;
while ( checked < this.results.length ) {
const r = this.results[checked];
checked++;
if ( !r.active ) continue;
// Maybe we have run out of rerolls
if ( (max !== null) && (max <= 0) ) break;
// Determine whether to re-roll the result
if ( DiceTerm.compareResult(r.result, comparison, target) ) {
r.rerolled = true;
r.active = false;
await this.roll({ reroll: true });
if ( max !== null ) max -= 1;
}
// Limit recursion
if ( !recursive && (checked >= initial) ) checked = this.results.length;
if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
}
}
/**
* @see {@link Die#reroll}
*/
async rerollRecursive(modifier) {
return this.reroll(modifier, {recursive: true});
}
/* -------------------------------------------- */
/**
* Explode the Die, rolling additional results for any values which match the target set.
* If no target number is specified, explode the highest possible result.
* Explosion can be a "small explode" using a lower-case x or a "big explode" using an upper-case "X"
*
* @param {string} modifier The matched modifier query
* @param {boolean} recursive Explode recursively, such that new rolls can also explode?
* @returns {Promise<false|void>} False if the modifier was unmatched.
*/
async explode(modifier, {recursive=true}={}) {
// Match the "explode" or "explode once" modifier
const rgx = /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [max, comparison, target] = match.slice(1);
// If no comparison or target are provided, treat the max as the target value
if ( max && !(target || comparison) ) {
target = max;
max = null;
}
// Determine target values
target = Number.isNumeric(target) ? parseInt(target) : this.faces;
comparison = comparison || "=";
// Determine the number of allowed explosions
max = Number.isNumeric(max) ? parseInt(max) : null;
// Recursively explode until there are no remaining results to explode
let checked = 0;
const initial = this.results.length;
while ( checked < this.results.length ) {
const r = this.results[checked];
checked++;
if ( !r.active ) continue;
// Maybe we have run out of explosions
if ( (max !== null) && (max <= 0) ) break;
// Determine whether to explode the result and roll again!
if ( DiceTerm.compareResult(r.result, comparison, target) ) {
r.exploded = true;
await this.roll({ explode: true });
if ( max !== null ) max -= 1;
}
// Limit recursion
if ( !recursive && (checked === initial) ) break;
if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
}
}
/**
* @see {@link Die#explode}
*/
async explodeOnce(modifier) {
return this.explode(modifier, {recursive: false});
}
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* 20d20k Keep the 1 highest die
* 20d20kh Keep the 1 highest die
* 20d20kh10 Keep the 10 highest die
* 20d20kl Keep the 1 lowest die
* 20d20kl10 Keep the 10 lowest die
*
* @param {string} modifier The matched modifier query
*/
keep(modifier) {
const rgx = /k([hl])?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [direction, number] = match.slice(1);
direction = direction ? direction.toLowerCase() : "h";
number = parseInt(number) || 1;
DiceTerm._keepOrDrop(this.results, number, {keep: true, highest: direction === "h"});
}
/* -------------------------------------------- */
/**
* Drop a certain number of highest or lowest dice rolls from the result set.
*
* 20d20d Drop the 1 lowest die
* 20d20dh Drop the 1 highest die
* 20d20dl Drop the 1 lowest die
* 20d20dh10 Drop the 10 highest die
* 20d20dl10 Drop the 10 lowest die
*
* @param {string} modifier The matched modifier query
*/
drop(modifier) {
const rgx = /d([hl])?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [direction, number] = match.slice(1);
direction = direction ? direction.toLowerCase() : "l";
number = parseInt(number) || 1;
DiceTerm._keepOrDrop(this.results, number, {keep: false, highest: direction !== "l"});
}
/* -------------------------------------------- */
/**
* Count the number of successful results which occurred in a given result set.
* Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
* Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
*
* 20d20cs Count the number of dice which rolled a 20
* 20d20cs>10 Count the number of dice which rolled higher than 10
* 20d20cs<10 Count the number of dice which rolled less than 10
*
* @param {string} modifier The matched modifier query
*/
countSuccess(modifier) {
const rgx = /(?:cs)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
comparison = comparison || "=";
target = parseInt(target) ?? this.faces;
DiceTerm._applyCount(this.results, comparison, target, {flagSuccess: true});
}
/* -------------------------------------------- */
/**
* Count the number of failed results which occurred in a given result set.
* Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
* Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
*
* 6d6cf Count the number of dice which rolled a 1 as failures
* 6d6cf<=3 Count the number of dice which rolled less than 3 as failures
* 6d6cf>4 Count the number of dice which rolled greater than 4 as failures
*
* @param {string} modifier The matched modifier query
*/
countFailures(modifier) {
const rgx = /(?:cf)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
comparison = comparison || "=";
target = parseInt(target) ?? 1;
DiceTerm._applyCount(this.results, comparison, target, {flagFailure: true});
}
/* -------------------------------------------- */
/**
* Count the number of even results which occurred in a given result set.
* Even numbers are marked as a success and counted as 1
* Odd numbers are marked as a non-success and counted as 0.
*
* 6d6even Count the number of even numbers rolled
*
* @param {string} modifier The matched modifier query
*/
countEven(modifier) {
for ( let r of this.results ) {
r.success = ( (r.result % 2) === 0 );
r.count = r.success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* Count the number of odd results which occurred in a given result set.
* Odd numbers are marked as a success and counted as 1
* Even numbers are marked as a non-success and counted as 0.
*
* 6d6odd Count the number of odd numbers rolled
*
* @param {string} modifier The matched modifier query
*/
countOdd(modifier) {
for ( let r of this.results ) {
r.success = ( (r.result % 2) !== 0 );
r.count = r.success ? 1 : 0;
}
}
/* -------------------------------------------- */
/**
* Deduct the number of failures from the dice result, counting each failure as -1
* Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
* Applying a deduct-failures modifier to the results counts all failed results as -1.
*
* 6d6df Subtract the number of dice which rolled a 1 from the non-failed total.
* 6d6cs>3df Subtract the number of dice which rolled a 3 or less from the non-failed count.
* 6d6cf<3df Subtract the number of dice which rolled less than 3 from the non-failed count.
*
* @param {string} modifier The matched modifier query
*/
deductFailures(modifier) {
const rgx = /(?:df)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
if ( comparison || target ) {
comparison = comparison || "=";
target = parseInt(target) ?? 1;
}
DiceTerm._applyDeduct(this.results, comparison, target, {deductFailure: true});
}
/* -------------------------------------------- */
/**
* Subtract the value of failed dice from the non-failed total, where each failure counts as its negative value.
* Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
* Applying a deduct-failures modifier to the results counts all failed results as -1.
*
* 6d6df<3 Subtract the value of results which rolled less than 3 from the non-failed total.
*
* @param {string} modifier The matched modifier query
*/
subtractFailures(modifier) {
const rgx = /(?:sf)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
if ( comparison || target ) {
comparison = comparison || "=";
target = parseInt(target) ?? 1;
}
DiceTerm._applyDeduct(this.results, comparison, target, {invertFailure: true});
}
/* -------------------------------------------- */
/**
* Subtract the total value of the DiceTerm from a target value, treating the difference as the final total.
* Example: 6d6ms>12 Roll 6d6 and subtract 12 from the resulting total.
* @param {string} modifier The matched modifier query
*/
marginSuccess(modifier) {
const rgx = /(?:ms)([<>=]+)?([0-9]+)?/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [comparison, target] = match.slice(1);
target = parseInt(target);
if ( [">", ">=", "=", undefined].includes(comparison) ) this.options.marginSuccess = target;
else if ( ["<", "<="].includes(comparison) ) this.options.marginFailure = target;
}
/* -------------------------------------------- */
/**
* Constrain each rolled result to be at least some minimum value.
* Example: 6d6min2 Roll 6d6, each result must be at least 2
* @param {string} modifier The matched modifier query
*/
minimum(modifier) {
const rgx = /(?:min)([0-9]+)/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
for ( let r of this.results ) {
if ( r.result < target ) {
r.count = target;
r.rerolled = true;
}
}
}
/* -------------------------------------------- */
/**
* Constrain each rolled result to be at most some maximum value.
* Example: 6d6max5 Roll 6d6, each result must be at most 5
* @param {string} modifier The matched modifier query
*/
maximum(modifier) {
const rgx = /(?:max)([0-9]+)/i;
const match = modifier.match(rgx);
if ( !match ) return false;
let [target] = match.slice(1);
target = parseInt(target);
for ( let r of this.results ) {
if ( r.result > target ) {
r.count = target;
r.rerolled = true;
}
}
}
}

View File

@@ -0,0 +1,62 @@
import DiceTerm from "./dice.mjs";
import Die from "./die.mjs";
/**
* A type of DiceTerm used to represent a three-sided Fate/Fudge die.
* Mathematically behaves like 1d3-2
* @extends {DiceTerm}
*/
export default class FateDie extends DiceTerm {
constructor(termData) {
termData.faces = 3;
super(termData);
}
/** @inheritdoc */
static DENOMINATION = "f";
/** @inheritdoc */
static MODIFIERS = {
"r": Die.prototype.reroll,
"rr": Die.prototype.rerollRecursive,
"k": Die.prototype.keep,
"kh": Die.prototype.keep,
"kl": Die.prototype.keep,
"d": Die.prototype.drop,
"dh": Die.prototype.drop,
"dl": Die.prototype.drop
}
/* -------------------------------------------- */
/** @inheritdoc */
async roll({minimize=false, maximize=false, ...options}={}) {
const roll = {result: undefined, active: true};
if ( minimize ) roll.result = -1;
else if ( maximize ) roll.result = 1;
else roll.result = await this._roll(options);
if ( roll.result === undefined ) roll.result = this.randomFace();
if ( roll.result === -1 ) roll.failure = true;
if ( roll.result === 1 ) roll.success = true;
this.results.push(roll);
return roll;
}
/* -------------------------------------------- */
/** @override */
mapRandomFace(randomUniform) {
return Math.ceil((randomUniform * this.faces) - 2);
}
/* -------------------------------------------- */
/** @inheritdoc */
getResultLabel(result) {
return {
"-1": "-",
"0": "&nbsp;",
"1": "+"
}[result.result];
}
}

View File

@@ -0,0 +1,177 @@
import RollTerm from "./term.mjs";
import DiceTerm from "./dice.mjs";
/**
* A type of RollTerm used to apply a function.
* @extends {RollTerm}
*/
export default class FunctionTerm extends RollTerm {
constructor({fn, terms=[], rolls=[], result, options}={}) {
super({options});
this.fn = fn;
this.terms = terms;
this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
this.result = result;
if ( result !== undefined ) this._evaluated = true;
}
/**
* The name of the configured function, or one in the Math environment, which should be applied to the term
* @type {string}
*/
fn;
/**
* An array of string argument terms for the function
* @type {string[]}
*/
terms;
/**
* The cached Roll instances for each function argument
* @type {Roll[]}
*/
rolls = [];
/**
* The cached result of evaluating the method arguments
* @type {string|number}
*/
result;
/** @inheritdoc */
isIntermediate = true;
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["fn", "terms", "rolls", "result"];
/* -------------------------------------------- */
/* Function Term Attributes */
/* -------------------------------------------- */
/**
* An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
* @type {DiceTerm[]}
*/
get dice() {
return this.rolls.flatMap(r => r.dice);
}
/** @inheritdoc */
get total() {
return this.result;
}
/** @inheritdoc */
get expression() {
return `${this.fn}(${this.terms.join(",")})`;
}
/**
* The function this term represents.
* @returns {RollFunction}
*/
get function() {
return CONFIG.Dice.functions[this.fn] ?? Math[this.fn];
}
/** @inheritdoc */
get isDeterministic() {
if ( this.function?.constructor.name === "AsyncFunction" ) return false;
return this.terms.every(t => Roll.create(t).isDeterministic);
}
/* -------------------------------------------- */
/* Math Term Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_evaluate(options={}) {
if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
return this._evaluateAsync(options);
}
/* -------------------------------------------- */
/**
* Evaluate this function when it contains any non-deterministic sub-terms.
* @param {object} [options]
* @returns {Promise<RollTerm>}
* @protected
*/
async _evaluateAsync(options={}) {
const args = await Promise.all(this.rolls.map(async roll => {
if ( this._root ) roll._root = this._root;
await roll.evaluate({ ...options, allowStrings: true });
roll.propagateFlavor(this.flavor);
return this.#parseArgument(roll);
}));
this.result = await this.function(...args);
if ( !options.allowStrings ) this.result = Number(this.result);
return this;
}
/* -------------------------------------------- */
/**
* Evaluate this function when it contains only deterministic sub-terms.
* @param {object} [options]
* @returns {RollTerm}
* @protected
*/
_evaluateSync(options={}) {
const args = [];
for ( const roll of this.rolls ) {
roll.evaluateSync({ ...options, allowStrings: true });
roll.propagateFlavor(this.flavor);
args.push(this.#parseArgument(roll));
}
this.result = this.function(...args);
if ( !options.allowStrings ) this.result = Number(this.result);
return this;
}
/* -------------------------------------------- */
/**
* Parse a function argument from its evaluated Roll instance.
* @param {Roll} roll The evaluated Roll instance that wraps the argument.
* @returns {string|number}
*/
#parseArgument(roll) {
const { product } = roll;
if ( typeof product !== "string" ) return product;
const [, value] = product.match(/^\$([^$]+)\$$/) || [];
return value ? JSON.parse(value) : product;
}
/* -------------------------------------------- */
/* Saving and Loading */
/* -------------------------------------------- */
/** @inheritDoc */
static _fromData(data) {
data.rolls = (data.rolls || []).map(r => r instanceof Roll ? r : Roll.fromData(r));
return super._fromData(data);
}
/* -------------------------------------------- */
/** @inheritDoc */
toJSON() {
const data = super.toJSON();
data.rolls = data.rolls.map(r => r.toJSON());
return data;
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
const rolls = node.terms.map(t => {
return Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(t));
});
const modifiers = Array.from((node.modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(([m]) => m);
return this.fromData({ ...node, rolls, modifiers, terms: rolls.map(r => r.formula) });
}
}

View File

@@ -0,0 +1,59 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to represent static numbers.
* @extends {RollTerm}
*/
export default class NumericTerm extends RollTerm {
constructor({number, options}={}) {
super({options});
this.number = Number(number);
}
/**
* The term's numeric value.
* @type {number}
*/
number;
/** @inheritdoc */
static REGEXP = new RegExp(`^([0-9]+(?:\\.[0-9]+)?)${RollTerm.FLAVOR_REGEXP_STRING}?$`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["number"];
/** @inheritdoc */
get expression() {
return String(this.number);
}
/** @inheritdoc */
get total() {
return this.number;
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Determine whether a string expression matches a NumericTerm
* @param {string} expression The expression to parse
* @returns {RegExpMatchArray|null}
*/
static matchTerm(expression) {
return expression.match(this.REGEXP) || null;
}
/* -------------------------------------------- */
/**
* Construct a term of this type given a matched regular expression array.
* @param {RegExpMatchArray} match The matched regular expression array
* @returns {NumericTerm} The constructed term
*/
static fromMatch(match) {
const [number, flavor] = match.slice(1);
return new this({number, options: {flavor}});
}
}

View File

@@ -0,0 +1,58 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to denote and perform an arithmetic operation.
* @extends {RollTerm}
*/
export default class OperatorTerm extends RollTerm {
constructor({operator, options}={}) {
super({options});
this.operator = operator;
}
/**
* The term's operator value.
* @type {string}
*/
operator;
/**
* An object of operators with their precedence values.
* @type {Readonly<Record<string, number>>}
*/
static PRECEDENCE = Object.freeze({
"+": 10,
"-": 10,
"*": 20,
"/": 20,
"%": 20
});
/**
* An array of operators which represent arithmetic operations
* @type {string[]}
*/
static OPERATORS = Object.keys(this.PRECEDENCE);
/** @inheritdoc */
static REGEXP = new RegExp(this.OPERATORS.map(o => "\\"+o).join("|"), "g");
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["operator"];
/** @inheritdoc */
get flavor() {
return ""; // Operator terms cannot have flavor text
}
/** @inheritdoc */
get expression() {
return ` ${this.operator} `;
}
/** @inheritdoc */
get total() {
return ` ${this.operator} `;
}
}

View File

@@ -0,0 +1,149 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to enclose a parenthetical expression to be recursively evaluated.
* @extends {RollTerm}
*/
export default class ParentheticalTerm extends RollTerm {
constructor({term, roll, options}) {
super({options});
this.term = term;
this.roll = roll;
// If a roll was explicitly passed in, the parenthetical may have already been evaluated
if ( this.roll ) {
this.term = roll.formula;
this._evaluated = this.roll._evaluated;
}
}
/**
* The original provided string term used to construct the parenthetical
* @type {string}
*/
term;
/**
* An already-evaluated Roll instance used instead of the string term.
* @type {Roll}
*/
roll;
/** @inheritdoc */
isIntermediate = true;
/**
* The regular expression pattern used to identify the opening of a parenthetical expression.
* This could also identify the opening of a math function.
* @type {RegExp}
*/
static OPEN_REGEXP = /([A-z][A-z0-9]+)?\(/g;
/**
* A regular expression pattern used to identify the closing of a parenthetical expression.
* @type {RegExp}
*/
static CLOSE_REGEXP = new RegExp("\\)(?:\\$\\$F[0-9]+\\$\\$)?", "g");
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["term", "roll"];
/* -------------------------------------------- */
/* Parenthetical Term Attributes */
/* -------------------------------------------- */
/**
* An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
* @type {DiceTerm[]}
*/
get dice() {
return this.roll?.dice;
}
/** @inheritdoc */
get total() {
return this.roll.total;
}
/** @inheritdoc */
get expression() {
return `(${this.term})`;
}
/** @inheritdoc */
get isDeterministic() {
return Roll.create(this.term).isDeterministic;
}
/* -------------------------------------------- */
/* Parenthetical Term Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_evaluate(options={}) {
const roll = this.roll || Roll.create(this.term);
if ( this._root ) roll._root = this._root;
if ( options.maximize || options.minimize || roll.isDeterministic ) return this._evaluateSync(roll, options);
return this._evaluateAsync(roll, options);
}
/* -------------------------------------------- */
/**
* Evaluate this parenthetical when it contains any non-deterministic sub-terms.
* @param {Roll} roll The inner Roll instance to evaluate.
* @param {object} [options]
* @returns {Promise<RollTerm>}
* @protected
*/
async _evaluateAsync(roll, options={}) {
this.roll = await roll.evaluate(options);
this.roll.propagateFlavor(this.flavor);
return this;
}
/* -------------------------------------------- */
/**
* Evaluate this parenthetical when it contains only deterministic sub-terms.
* @param {Roll} roll The inner Roll instance to evaluate.
* @param {object} [options]
* @returns {RollTerm}
* @protected
*/
_evaluateSync(roll, options={}) {
this.roll = roll.evaluateSync(options);
this.roll.propagateFlavor(this.flavor);
return this;
}
/* -------------------------------------------- */
/**
* Construct a ParentheticalTerm from an Array of component terms which should be wrapped inside the parentheses.
* @param {RollTerm[]} terms The array of terms to use as internal parts of the parenthetical
* @param {object} [options={}] Additional options passed to the ParentheticalTerm constructor
* @returns {ParentheticalTerm} The constructed ParentheticalTerm instance
*
* @example Create a Parenthetical Term from an array of component RollTerm instances
* ```js
* const d6 = new Die({number: 4, faces: 6});
* const plus = new OperatorTerm({operator: "+"});
* const bonus = new NumericTerm({number: 4});
* t = ParentheticalTerm.fromTerms([d6, plus, bonus]);
* t.formula; // (4d6 + 4)
* ```
*/
static fromTerms(terms, options) {
const roll = Roll.defaultImplementation.fromTerms(terms);
return new this({roll, options});
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
const roll = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(node.term));
return this.fromData({ ...node, roll, term: roll.formula });
}
}

View File

@@ -0,0 +1,361 @@
import RollTerm from "./term.mjs";
import DiceTerm from "./dice.mjs";
import Die from "./die.mjs";
/**
* @typedef {import("../_types.mjs").DiceTermResult} DiceTermResult
*/
/**
* A type of RollTerm which encloses a pool of multiple inner Rolls which are evaluated jointly.
*
* A dice pool represents a set of Roll expressions which are collectively modified to compute an effective total
* across all Rolls in the pool. The final total for the pool is defined as the sum over kept rolls, relative to any
* success count or margin.
*
* @example Keep the highest of the 3 roll expressions
* ```js
* let pool = new PoolTerm({
* terms: ["4d6", "3d8 - 1", "2d10 + 3"],
* modifiers: ["kh"]
* });
* pool.evaluate();
* ```
*/
export default class PoolTerm extends RollTerm {
constructor({terms=[], modifiers=[], rolls=[], results=[], options={}}={}) {
super({options});
this.terms = terms;
this.modifiers = modifiers;
this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
this.results = results;
// If rolls and results were explicitly passed, the term has already been evaluated
if ( rolls.length && results.length ) this._evaluated = true;
}
/* -------------------------------------------- */
/**
* The original provided terms to the Dice Pool
* @type {string[]}
*/
terms;
/**
* The string modifiers applied to resolve the pool
* @type {string[]}
*/
modifiers;
/**
* Each component term of the dice pool as a Roll instance.
* @type {Roll[]}
*/
rolls;
/**
* The array of dice pool results which have been rolled
* @type {DiceTermResult[]}
*/
results;
/**
* Define the modifiers that can be used for this particular DiceTerm type.
* @type {Record<string, function|string>}
*/
static MODIFIERS = {
"k": "keep",
"kh": "keep",
"kl": "keep",
"d": "drop",
"dh": "drop",
"dl": "drop",
"cs": "countSuccess",
"cf": "countFailures"
};
/**
* The regular expression pattern used to identify the opening of a dice pool expression.
* @type {RegExp}
*/
static OPEN_REGEXP = /{/g;
/**
* A regular expression pattern used to identify the closing of a dice pool expression.
* @type {RegExp}
*/
static CLOSE_REGEXP = new RegExp(`}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`, "g");
/**
* A regular expression pattern used to match the entirety of a DicePool expression.
* @type {RegExp}
*/
static REGEXP = new RegExp(`{([^}]+)}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`);
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["terms", "modifiers", "rolls", "results"];
/* -------------------------------------------- */
/* Dice Pool Attributes */
/* -------------------------------------------- */
/**
* Return an Array of each individual DiceTerm instances contained within the PoolTerm.
* @type {DiceTerm[]}
*/
get dice() {
return this.rolls.flatMap(r => r.dice);
}
/* -------------------------------------------- */
/** @inheritdoc */
get expression() {
return `{${this.terms.join(",")}}${this.modifiers.join("")}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
get total() {
if ( !this._evaluated ) return undefined;
return this.results.reduce((t, r) => {
if ( !r.active ) return t;
if ( r.count !== undefined ) return t + r.count;
else return t + r.result;
}, 0);
}
/* -------------------------------------------- */
/**
* Return an array of rolled values which are still active within the PoolTerm
* @type {number[]}
*/
get values() {
return this.results.reduce((arr, r) => {
if ( !r.active ) return arr;
arr.push(r.result);
return arr;
}, []);
}
/* -------------------------------------------- */
/** @inheritdoc */
get isDeterministic() {
return this.terms.every(t => Roll.create(t).isDeterministic);
}
/* -------------------------------------------- */
/**
* Alter the DiceTerm by adding or multiplying the number of dice which are rolled
* @param {any[]} args Arguments passed to each contained Roll#alter method.
* @returns {PoolTerm} The altered pool
*/
alter(...args) {
this.rolls.forEach(r => r.alter(...args));
return this;
}
/* -------------------------------------------- */
/** @inheritdoc */
_evaluate(options={}) {
if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
return this._evaluateAsync(options);
}
/* -------------------------------------------- */
/**
* Evaluate this pool term when it contains any non-deterministic sub-terms.
* @param {object} [options]
* @returns {Promise<PoolTerm>}
* @protected
*/
async _evaluateAsync(options={}) {
for ( const roll of this.rolls ) {
if ( this._root ) roll._root = this._root;
await roll.evaluate(options);
roll.propagateFlavor(this.flavor);
this.results.push({ result: roll.total, active: true });
}
await this._evaluateModifiers();
return this;
}
/* -------------------------------------------- */
/**
* Evaluate this pool term when it contains only deterministic sub-terms.
* @param {object} [options]
* @returns {PoolTerm}
* @protected
*/
_evaluateSync(options={}) {
for ( const roll of this.rolls ) {
if ( this._root ) roll._root = this._root;
roll.evaluateSync(options);
roll.propagateFlavor(this.flavor);
this.results.push({ result: roll.total, active: true });
}
return this;
}
/* -------------------------------------------- */
/**
* Use the same logic as for the DiceTerm to avoid duplication
* @see DiceTerm#_evaluateModifiers
*/
_evaluateModifiers() {
return DiceTerm.prototype._evaluateModifiers.call(this);
}
/* -------------------------------------------- */
/**
* Use the same logic as for the DiceTerm to avoid duplication
* @see DiceTerm#_evaluateModifier
*/
_evaluateModifier(command, modifier) {
return DiceTerm.prototype._evaluateModifier.call(this, command, modifier);
}
/* -------------------------------------------- */
/* Saving and Loading */
/* -------------------------------------------- */
/** @inheritdoc */
static _fromData(data) {
data.rolls = (data.rolls || []).map(r => r instanceof Roll ? r : Roll.fromData(r));
return super._fromData(data);
}
/* -------------------------------------------- */
/** @inheritdoc */
toJSON() {
const data = super.toJSON();
data.rolls = data.rolls.map(r => r.toJSON());
return data;
}
/* -------------------------------------------- */
/**
* Given a string formula, create and return an evaluated PoolTerm object
* @param {string} formula The string formula to parse
* @param {object} [options] Additional options applied to the PoolTerm
* @returns {PoolTerm|null} The evaluated PoolTerm object or null if the formula is invalid
*/
static fromExpression(formula, options={}) {
const rgx = formula.trim().match(this.REGEXP);
if ( !rgx ) return null;
let [terms, modifiers] = rgx.slice(1);
terms = terms.split(",");
modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]);
return new this({terms, modifiers, options});
}
/* -------------------------------------------- */
/**
* Create a PoolTerm by providing an array of existing Roll objects
* @param {Roll[]} rolls An array of Roll objects from which to create the pool
* @returns {RollTerm} The constructed PoolTerm comprised of the provided rolls
*/
static fromRolls(rolls=[]) {
const allEvaluated = rolls.every(t => t._evaluated);
const noneEvaluated = !rolls.some(t => t._evaluated);
if ( !(allEvaluated || noneEvaluated) ) {
throw new Error("You can only call PoolTerm.fromRolls with an array of Roll instances which are either all evaluated, or none evaluated");
}
const pool = new this({
terms: rolls.map(r => r.formula),
modifiers: [],
rolls: rolls,
results: allEvaluated ? rolls.map(r => ({result: r.total, active: true})) : []
});
pool._evaluated = allEvaluated;
return pool;
}
/* -------------------------------------------- */
/** @override */
static fromParseNode(node) {
const rolls = node.terms.map(t => {
return Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(t)).toJSON();
});
const modifiers = Array.from((node.modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(([m]) => m);
return this.fromData({ ...node, rolls, modifiers, terms: rolls.map(r => r.formula) });
}
/* -------------------------------------------- */
/* Modifiers */
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* {1d6,1d8,1d10,1d12}kh2 Keep the 2 best rolls from the pool
* {1d12,6}kl Keep the lowest result in the pool
*
* @param {string} modifier The matched modifier query
*/
keep(modifier) {
return Die.prototype.keep.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Keep a certain number of highest or lowest dice rolls from the result set.
*
* {1d6,1d8,1d10,1d12}dl3 Drop the 3 worst results in the pool
* {1d12,6}dh Drop the highest result in the pool
*
* @param {string} modifier The matched modifier query
*/
drop(modifier) {
return Die.prototype.drop.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Count the number of successful results which occurred in the pool.
* Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
* Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
*
* 20d20cs Count the number of dice which rolled a 20
* 20d20cs>10 Count the number of dice which rolled higher than 10
* 20d20cs<10 Count the number of dice which rolled less than 10
*
* @param {string} modifier The matched modifier query
*/
countSuccess(modifier) {
return Die.prototype.countSuccess.call(this, modifier);
}
/* -------------------------------------------- */
/**
* Count the number of failed results which occurred in a given result set.
* Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
* Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
*
* 6d6cf Count the number of dice which rolled a 1 as failures
* 6d6cf<=3 Count the number of dice which rolled less than 3 as failures
* 6d6cf>4 Count the number of dice which rolled greater than 4 as failures
*
* @param {string} modifier The matched modifier query
*/
countFailures(modifier) {
return Die.prototype.countFailures.call(this, modifier);
}
}

View File

@@ -0,0 +1,45 @@
import RollTerm from "./term.mjs";
/**
* A type of RollTerm used to represent strings which have not yet been matched.
* @extends {RollTerm}
*/
export default class StringTerm extends RollTerm {
constructor({term, options}={}) {
super({options});
this.term = term;
}
/**
* The term's string value.
* @type {string}
*/
term;
/** @inheritdoc */
static SERIALIZE_ATTRIBUTES = ["term"];
/** @inheritdoc */
get expression() {
return this.term;
}
/** @inheritdoc */
get total() {
return this.term;
}
/** @inheritdoc */
get isDeterministic() {
const classified = Roll.defaultImplementation._classifyStringTerm(this.term, {intermediate: false});
if ( classified instanceof StringTerm ) return true;
return classified.isDeterministic;
}
/** @inheritdoc */
evaluate({ allowStrings=false }={}) {
if ( !allowStrings ) throw new Error(`Unresolved StringTerm ${this.term} requested for evaluation`);
return this;
}
}

View File

@@ -0,0 +1,246 @@
import { deepClone } from "../../../common/utils/helpers.mjs";
/**
* @typedef {import("../_types.mjs").RollParseNode} RollParseNode
*/
/**
* An abstract class which represents a single token that can be used as part of a Roll formula.
* Every portion of a Roll formula is parsed into a subclass of RollTerm in order for the Roll to be fully evaluated.
*/
export default class RollTerm {
/**
* @param {object} [options] An object of additional options which describes and modifies the term.
*/
constructor({options={}}={}) {
this.options = options;
}
/**
* An object of additional options which describes and modifies the term.
* @type {object}
*/
options;
/**
* An internal flag for whether the term has been evaluated
* @type {boolean}
* @internal
*/
_evaluated = false;
/**
* A reference to the Roll at the root of the evaluation tree.
* @type {Roll}
* @internal
*/
_root;
/**
* Is this term intermediate, and should be evaluated first as part of the simplification process?
* @type {boolean}
*/
isIntermediate = false;
/**
* A regular expression pattern which identifies optional term-level flavor text
* @type {string}
*/
static FLAVOR_REGEXP_STRING = "(?:\\[([^\\]]+)\\])";
/**
* A regular expression which identifies term-level flavor text
* @type {RegExp}
*/
static FLAVOR_REGEXP = new RegExp(RollTerm.FLAVOR_REGEXP_STRING, "g");
/**
* A regular expression used to match a term of this type
* @type {RegExp}
*/
static REGEXP = undefined;
/**
* An array of additional attributes which should be retained when the term is serialized
* @type {string[]}
*/
static SERIALIZE_ATTRIBUTES = [];
/* -------------------------------------------- */
/* RollTerm Attributes */
/* -------------------------------------------- */
/**
* A string representation of the formula expression for this RollTerm, prior to evaluation.
* @type {string}
*/
get expression() {
throw new Error(`The ${this.constructor.name} class must implement the expression attribute`);
}
/**
* A string representation of the formula, including optional flavor text.
* @type {string}
*/
get formula() {
let f = this.expression;
if ( this.flavor ) f += `[${this.flavor}]`;
return f;
}
/**
* A string or numeric representation of the final output for this term, after evaluation.
* @type {number|string}
*/
get total() {
throw new Error(`The ${this.constructor.name} class must implement the total attribute`);
}
/**
* Optional flavor text which modifies and describes this term.
* @type {string}
*/
get flavor() {
return this.options.flavor || "";
}
/**
* Whether this term is entirely deterministic or contains some randomness.
* @type {boolean}
*/
get isDeterministic() {
return true;
}
/**
* A reference to the RollResolver app being used to externally resolve this term.
* @type {RollResolver}
*/
get resolver() {
return this._root?._resolver;
}
/* -------------------------------------------- */
/* RollTerm Methods */
/* -------------------------------------------- */
/**
* Evaluate the term, processing its inputs and finalizing its total.
* @param {object} [options={}] Options which modify how the RollTerm 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 throw an error when evaluated.
* @returns {Promise<RollTerm>|RollTerm} Returns a Promise if the term is non-deterministic.
*/
evaluate(options={}) {
if ( this._evaluated ) {
throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
}
this._evaluated = true;
return this._evaluate(options);
}
/* -------------------------------------------- */
/**
* Evaluate the term.
* @param {object} [options={}] Options which modify how the RollTerm is evaluated, see RollTerm#evaluate
* @returns {Promise<RollTerm>|RollTerm} Returns a Promise if the term is non-deterministic.
* @protected
*/
_evaluate(options={}) {
return this;
}
/* -------------------------------------------- */
/**
* Determine if evaluating a given RollTerm with certain evaluation options can be done so deterministically.
* @param {RollTerm} term The term.
* @param {object} [options] Options for evaluating the term.
* @param {boolean} [options.maximize] Force the result to be maximized.
* @param {boolean} [options.minimize] Force the result to be minimized.
*/
static isDeterministic(term, { maximize, minimize }={}) {
return maximize || minimize || term.isDeterministic;
}
/* -------------------------------------------- */
/* Serialization and Loading */
/* -------------------------------------------- */
/**
* Construct a RollTerm from a provided data object
* @param {object} data Provided data from an un-serialized term
* @returns {RollTerm} The constructed RollTerm
*/
static fromData(data) {
let cls = CONFIG.Dice.termTypes[data.class];
if ( !cls ) {
cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || foundry.dice.terms.Die;
}
return cls._fromData(data);
}
/* -------------------------------------------- */
/**
* Construct a RollTerm from parser information.
* @param {RollParseNode} node The node.
* @returns {RollTerm}
*/
static fromParseNode(node) {
return this.fromData(deepClone(node));
}
/* -------------------------------------------- */
/**
* Define term-specific logic for how a de-serialized data object is restored as a functional RollTerm
* @param {object} data The de-serialized term data
* @returns {RollTerm} The re-constructed RollTerm object
* @protected
*/
static _fromData(data) {
if ( data.roll && !(data.roll instanceof Roll) ) data.roll = Roll.fromData(data.roll);
const term = new this(data);
term._evaluated = data.evaluated ?? true;
return term;
}
/* -------------------------------------------- */
/**
* Reconstruct a RollTerm instance from a provided JSON string
* @param {string} json A serialized JSON representation of a DiceTerm
* @return {RollTerm} A reconstructed RollTerm from the provided JSON
*/
static fromJSON(json) {
let data;
try {
data = JSON.parse(json);
} catch(err) {
throw new Error("You must pass a valid JSON string");
}
return this.fromData(data);
}
/* -------------------------------------------- */
/**
* Serialize the RollTerm to a JSON string which allows it to be saved in the database or embedded in text.
* This method should return an object suitable for passing to the JSON.stringify function.
* @return {object}
*/
toJSON() {
const data = {
class: this.constructor.name,
options: this.options,
evaluated: this._evaluated
};
for ( let attr of this.constructor.SERIALIZE_ATTRIBUTES ) {
data[attr] = this[attr];
}
return data;
}
}

View File

@@ -0,0 +1,257 @@
/**
* A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator.
*
* @author Raphael Pigulla <pigulla@four66.com>
* @version 0.2.3
* @license
* Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. The names of its contributors may not be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
export default class MersenneTwister {
/**
* Instantiates a new Mersenne Twister.
* @param {number} [seed] The initial seed value, if not provided the current timestamp will be used.
* @constructor
*/
constructor(seed) {
// Initial values
this.MAX_INT = 4294967296.0;
this.N = 624;
this.M = 397;
this.UPPER_MASK = 0x80000000;
this.LOWER_MASK = 0x7fffffff;
this.MATRIX_A = 0x9908b0df;
// Initialize sequences
this.mt = new Array(this.N);
this.mti = this.N + 1;
this.SEED = this.seed(seed ?? new Date().getTime());
};
/**
* Initializes the state vector by using one unsigned 32-bit integer "seed", which may be zero.
*
* @since 0.1.0
* @param {number} seed The seed value.
*/
seed(seed) {
this.SEED = seed;
let s;
this.mt[0] = seed >>> 0;
for (this.mti = 1; this.mti < this.N; this.mti++) {
s = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30);
this.mt[this.mti] =
(((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253) + this.mti;
this.mt[this.mti] >>>= 0;
}
return seed;
};
/**
* Initializes the state vector by using an array key[] of unsigned 32-bit integers of the specified length. If
* length is smaller than 624, then each array of 32-bit integers gives distinct initial state vector. This is
* useful if you want a larger seed space than 32-bit word.
*
* @since 0.1.0
* @param {array} vector The seed vector.
*/
seedArray(vector) {
let i = 1, j = 0, k = this.N > vector.length ? this.N : vector.length, s;
this.seed(19650218);
for (; k > 0; k--) {
s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525))) +
vector[j] + j;
this.mt[i] >>>= 0;
i++;
j++;
if (i >= this.N) {
this.mt[0] = this.mt[this.N-1];
i = 1;
}
if (j >= vector.length) {
j = 0;
}
}
for (k = this.N-1; k; k--) {
s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
this.mt[i] =
(this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i;
this.mt[i] >>>= 0;
i++;
if (i >= this.N) {
this.mt[0] = this.mt[this.N - 1];
i = 1;
}
}
this.mt[0] = 0x80000000;
};
/**
* Generates a random unsigned 32-bit integer.
*
* @since 0.1.0
* @returns {number}
*/
int() {
let y, kk, mag01 = [0, this.MATRIX_A];
if (this.mti >= this.N) {
if (this.mti === this.N+1) {
this.seed(5489);
}
for (kk = 0; kk < this.N - this.M; kk++) {
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 1];
}
for (; kk < this.N - 1; kk++) {
y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 1];
}
y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK);
this.mt[this.N - 1] = this.mt[this.M - 1] ^ (y >>> 1) ^ mag01[y & 1];
this.mti = 0;
}
y = this.mt[this.mti++];
y ^= (y >>> 11);
y ^= (y << 7) & 0x9d2c5680;
y ^= (y << 15) & 0xefc60000;
y ^= (y >>> 18);
return y >>> 0;
};
/**
* Generates a random unsigned 31-bit integer.
*
* @since 0.1.0
* @returns {number}
*/
int31() {
return this.int() >>> 1;
};
/**
* Generates a random real in the interval [0;1] with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
real() {
return this.int() * (1.0 / (this.MAX_INT - 1));
};
/**
* Generates a random real in the interval ]0;1[ with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
realx() {
return (this.int() + 0.5) * (1.0 / this.MAX_INT);
};
/**
* Generates a random real in the interval [0;1[ with 32-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
rnd() {
return this.int() * (1.0 / this.MAX_INT);
};
/**
* Generates a random real in the interval [0;1[ with 32-bit resolution.
*
* Same as .rnd() method - for consistency with Math.random() interface.
*
* @since 0.2.0
* @returns {number}
*/
random() {
return this.rnd();
};
/**
* Generates a random real in the interval [0;1[ with 53-bit resolution.
*
* @since 0.1.0
* @returns {number}
*/
rndHiRes() {
const a = this.int() >>> 5;
const b = this.int() >>> 6;
return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
};
/**
* A pseudo-normal distribution using the Box-Muller transform.
* @param {number} mu The normal distribution mean
* @param {number} sigma The normal distribution standard deviation
* @returns {number}
*/
normal(mu, sigma) {
let u = 0;
while (u === 0) u = this.random(); // Converting [0,1) to (0,1)
let v = 0;
while (v === 0) v = this.random(); // Converting [0,1) to (0,1)
let n = Math.sqrt( -2.0 * Math.log(u) ) * Math.cos(2.0 * Math.PI * v);
return (n * sigma) + mu;
}
/**
* A factory method for generating random uniform rolls
* @returns {number}
*/
static random() {
return twist.random();
}
/**
* A factory method for generating random normal rolls
* @return {number}
*/
static normal(...args) {
return twist.normal(...args);
}
}
// Global singleton
const twist = new MersenneTwister(Date.now());