Files
Foundry-VTT-Docker/resources/app/client-esm/dice/terms/dice.mjs
2025-01-04 00:34:03 +01:00

706 lines
24 KiB
JavaScript

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