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,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;
}
}