Source

yzur.js

/**
 * ===============================================================================
 * YZUR:
 * YEAR ZERO UNIVERSAL DICE ROLLER FOR THE FOUNDRY VTT
 * ===============================================================================
 * Author: @Stefouch
 * Version: 6.0.0          for: Foundry VTT V13
 * Date: 2025-06-06
 * License: MIT
 * ===============================================================================
 * Content:
 *
 * - YearZeroRollManager: Interface for registering dice.
 *
 * - YearZeroRoll: Custom implementation of the default Foundry Roll class,
 * with many extra getters and utility functions.
 *
 * - YearZeroDie: Custom implementation of the default Foundry DieTerm class,
 * also with many extra getters.
 *
 * - (Base/Skill/Gear/etc..)Die: Extends of the YearZeroDie class with specific
 * DENOMINATION and LOCKED_VALUE constants.
 *
 * - CONFIG.YZUR.game: The name of the game stored in the Foundry config.
 *
 * - CONFIG.YZUR.Icons.{..}: The dice labels stored in the Foundry config.
 *
 * ===============================================================================
 */

/* -------------------------------------------- */
/*  Custom Dice classes                         */
/* -------------------------------------------- */

/**
 * Custom Die class for Year Zero games.
 * @extends {foundry.dice.terms.Die} The Foundry Die class
 */
class YearZeroDie extends foundry.dice.terms.Die {
  constructor(termData = {}) {
    termData.faces = Number.isInteger(termData.faces) ? termData.faces : 6;
    super(termData);

    if (this.maxPush == undefined) {
      this.maxPush = termData.maxPush ?? 1;
    }
  }

  /**
   * The denomination of the die.
   * @type {string}
   * @readonly
   */
  get denomination() {
    return this.constructor.DENOMINATION;
  }

  /**
   * The type of the die.
   * @type {DieTypeString}
   * @readonly
   */
  get type() {
    return this.constructor.TYPE;
  }

  /**
   * Whether the die can be pushed (according to its type).
   * @type {boolean}
   * @readonly
   */
  get pushable() {
    if (this.pushCount >= this.maxPush) return false;
    for (const r of this.results) {
      if (!r.active || r.discarded) continue;
      if (!this.constructor.LOCKED_VALUES.includes(r.result)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Number of times this die has been pushed.
   * @type {number}
   * @readonly
   */
  get pushCount() {
    return this.results.reduce((c, r) => Math.max(c, r.indexPush || 0), 0);
  }

  /**
   * Whether this die has been pushed.
   * @type {boolean}
   * @readonly
   */
  get pushed() {
    return this.pushCount > 0;
  }

  /**
   * Tells if it's a YearZero Die.
   * @type {boolean}
   * @readonly
   */
  get isYearZeroDie() {
    // return this instanceof YearZeroDie;
    return true;
  }

  /**
   * Number of successes rolled.
   * @type {number}
   * @readonly
   */
  get success() {
    if (!this._evaluated) return undefined;
    const s = this.results.reduce((tot, r) => {
      if (!r.active) return tot;
      if (r.count !== undefined) return tot + r.count;
      if (this.constructor.SUCCESS_TABLE) {
        return tot + this.constructor.SUCCESS_TABLE[r.result];
      }
      return tot + (r.result >= 6 ? 1 : 0);
    }, 0);
    return this.type === 'neg' ? -s : s;
  }

  /**
   * Number of banes rolled.
   * @type {number}
   * @readonly
   */
  get failure() {
    if (!this._evaluated) return undefined;
    return this.results.reduce((tot, r) => {
      if (!r.active) return tot;
      return tot + (r.result <= 1);
    }, 0);
  }

  /* -------------------------------------------- */

  /** 
   * Rolls 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 smallest possible value
   * @returns {Promise<YearZeroDieTermResult>} The produced result
   * @see (Foundry) {@link https://foundryvtt.com/api/DiceTerm.html#roll|DiceTerm.roll}
   * @override
   */
  async roll(options = {}) {
    // Modifies the result.
    const roll = await super.roll(options);

    // Stores indexes
    roll.indexResult = options.indexResult;
    if (roll.indexResult == undefined) {
      roll.indexResult = 1 + this.results.reduce((c, r) => {
        let i = r.indexResult;
        if (i == undefined) i = -1;
        return Math.max(c, i);
      }, -1);
    }
    roll.indexPush = options.indexPush ?? this.pushCount;

    // Overwrites the result.
    this.results[this.results.length - 1] = roll;
    return roll;
  }

  /* -------------------------------------------- */

  /**
   * Counts the number of times a single value appears.
   * @param {number} n The single value to count
   * @returns {number}
   */
  count(n) {
    return this.values.filter(v => v === n).length;
  }

  /* -------------------------------------------- */

  /**
   * Pushes the dice.
   * @returns {YearZeroDie} this dice, pushed
   */
  push() {
    if (!this.pushable) return this;
    const indexPush = this.pushCount + 1;
    const indexesResult = [];
    for (const r of this.results) {
      if (!r.active || r.locked) continue;
      if (!this.constructor.LOCKED_VALUES.includes(r.result)) {
        // Removes the die from the total score.
        r.active = false;
        // Greys-out the die in the chat tooltip.
        r.discarded = true;
        // Marks the die as pushed.
        r.pushed = true;
        // Hides the die for DsN.
        r.hidden = true;
        // Stores the die's index for the chat tooltip.
        indexesResult.push(r.indexResult);
      }
      else {
        // Hides the die for DsN.
        r.hidden = true;
      }
    }

    // Then, rolls a new die for each pushed die.
    // With the indexes as options.
    for (let i = 0; i < indexesResult.length; i++) {
      this.roll({
        indexResult: indexesResult[i],
        indexPush,
      });
    }
    return this;
  }

  /* -------------------------------------------- */
  /*  Term Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Roll Modifier method that blocks pushes.
   */
  nopush() {
    this.maxPush = 0;
  }

  /**
   * Roll modifier method that sets the max number of pushes.
   * @param {string} modifier
   */
  setpush(modifier) {
    const rgx = /p([0-9]+)?/i;
    const match = modifier.match(rgx);
    if (!match) return false;
    let [, max] = match;
    max = parseInt(max) ?? 1;
    this.maxPush = max;
  }

  /* -------------------------------------------- */
  /*  Dice Term Methods                           */
  /* -------------------------------------------- */

  /** 
   * Returns a string used as the label for each rolled result.
   * @param {YearZeroDieTermResult} result The rolled result
   * @returns {string} The result label
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/DiceTerm.html#getResultLabel|DiceTerm.getResultLabel}
   * @override
   */
  getResultLabel(result) {
    // Do not forget to stringify the label because
    // numbers return an error with DiceSoNice!
    return CONFIG.YZUR.Icons.getLabel(
      this.constructor.TYPE,
      result.result,
    );
  }

  /**
   * Gets the CSS classes that should be used to display each rolled result.
   * @param {YearZeroDieTermResult} result The rolled result
   * @returns {string[]} The desired classes
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/DiceTerm.html#getResultCSS|DiceTerm.getResultCSS}
   * @override
   */
  getResultCSS(result) {
    // This is copy-pasted from the source code,
    // with modified parts between ==> arrows <==.
    const hasSuccess = result.success !== undefined;
    const hasFailure = result.failure !== undefined;
    //* Refactors the isMin & isMax for YZE dice.
    // const isMax = result.result === this.faces;
    // const isMin = result.result === 1;
    let isMax = false, isMin = false;
    if (this.type === 'neg') {
      isMax = false;
      isMin = result.result === 6;
    }
    else if (this instanceof YearZeroDie) {
      const noMin = ['skill', 'arto', 'loc'];
      isMax = result.result === this.faces || result.result >= 6;
      isMin = result.result === 1 && !noMin.includes(this.type);
    }
    else {
      isMax = result.result === this.faces;
      isMin = result.result === 1;
    }
    //* <==
    return [
      this.constructor.name.toLowerCase(),
      'd' + this.faces,
      //* ==>
      // result.success ? 'success' : null,
      // result.failure ? 'failure' : null,
      hasSuccess ? 'success' : null,
      hasFailure ? 'failure' : null,
      //* <==
      result.rerolled ? 'rerolled' : null,
      result.exploded ? 'exploded' : null,
      result.discarded ? 'discarded' : null,
      //* ==>
      //* Adds a CSS property for pushed dice.
      result.pushed ? 'pushed' : null,
      //* <==
      !(hasSuccess || hasFailure) && isMin ? 'min' : null,
      !(hasSuccess || hasFailure) && isMax ? 'max' : null,
    ];
  }

  /** 
   * Renders the tooltip HTML for a Roll instance.
   * @returns {Object} The data object used to render the default tooltip template for this DiceTerm
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/DiceTerm.html#getTooltipData|DiceTerm.getTooltipData}
   * @override
   */
  getTooltipData() {
    // This is copy-pasted from the source code,
    // with modified parts between ==> arrows <==.
    return {
      formula: this.expression,
      //* ==>
      // total: this.total,
      total: this.success,
      banes: this.failure,
      //* <==
      faces: this.faces,
      //* ==>
      //* Adds the number of dice, used in the chat for the pushed dice matrix.
      number: this.number,
      //* Adds the type, for sorting options.
      type: this.type,
      //* Adds whether its a YearZeroDie.
      isYearZeroDie: this.isYearZeroDie,
      //* Adds a default flavor for the die.
      // flavor: this.flavor,
      flavor: this.options.flavor ?? (
        CONFIG.YZUR?.Dice?.localizeDieTerms
          ? game.i18n.localize(`YZUR.DIETERMS.${this.constructor.name}`)
          : null
      ),
      //* <==
      rolls: this.results.map(r => {
        return {
          result: this.getResultLabel(r),
          classes: this.getResultCSS(r).filterJoin(' '),
          //* ==>
          //* Adds row and col indexes.
          row: r.indexPush,
          col: r.indexResult,
          //* <==
        };
      }),
    };
  }
}

/**
 * The type of the die.
 * @type {string}
 * @constant
 * @static
 */
YearZeroDie.TYPE = 'blank';

/**
 * An array of values that disallow the die to be pushed.
 * @type {number[]}
 * @constant
 * @static
 */
YearZeroDie.LOCKED_VALUES = [6];

/**
 * An array of additional attributes which should be retained when the term is serialized.
 * Addition: **maxPush**
 * @type {string[]}
 * @constant
 * @static
 * @inheritdoc
 */
YearZeroDie.SERIALIZE_ATTRIBUTES.push('maxPush');

/** @inheritdoc */
YearZeroDie.MODIFIERS = foundry.utils.mergeObject(
  {
    'p' : 'setpush',
    'np': 'nopush',
  },
  foundry.dice.terms.Die.MODIFIERS,
);

/* -------------------------------------------- */

/**
 * Base Die: 1 & 6 cannot be re-rolled.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class BaseDie extends YearZeroDie {}
BaseDie.TYPE = 'base';
BaseDie.DENOMINATION = 'b';
BaseDie.LOCKED_VALUES = [1, 6];

/**
 * Skill Die: 6 cannot be re-rolled.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class SkillDie extends YearZeroDie {}
SkillDie.TYPE = 'skill';
SkillDie.DENOMINATION = 's';

/**
 * Gear Die: 1 & 6 cannot be re-rolled.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class GearDie extends YearZeroDie {}
GearDie.TYPE = 'gear';
GearDie.DENOMINATION = 'g';
GearDie.LOCKED_VALUES = [1, 6];

/**
 * Negative Die: 6 cannot be re-rolled.
 * @extends {SkillDie}
 * @category OTHER DICE
 */
class NegativeDie extends SkillDie {}
NegativeDie.TYPE = 'neg';
NegativeDie.DENOMINATION = 'n';

/* -------------------------------------------- */

/**
 * Stress Die: 1 & 6 cannot be re-rolled.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class StressDie extends YearZeroDie {}
StressDie.TYPE = 'stress';
StressDie.DENOMINATION = 'z';
StressDie.LOCKED_VALUES = [1, 6];

/* -------------------------------------------- */

/**
 * Artifact Die: 6+ cannot be re-rolled.
 * @extends {SkillDie}
 * @category OTHER DICE
 */
class ArtifactDie extends SkillDie {
  /** @override */
  getResultLabel(result) {
    return CONFIG.YZUR.Icons.getLabel(
      `d${this.constructor.DENOMINATION}`,
      result.result,
    );
  }
}
ArtifactDie.TYPE = 'arto';
ArtifactDie.SUCCESS_TABLE = [null, 0, 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4];
ArtifactDie.LOCKED_VALUES = [6, 7, 8, 9, 10, 11, 12];

class D8ArtifactDie extends ArtifactDie {
  constructor(termData = {}) {
    termData.faces = 8;
    super(termData);
  }
}
D8ArtifactDie.DENOMINATION = '8';

class D10ArtifactDie extends ArtifactDie {
  constructor(termData = {}) {
    termData.faces = 10;
    super(termData);
  }
}
D10ArtifactDie.DENOMINATION = '10';

class D12ArtifactDie extends ArtifactDie {
  constructor(termData = {}) {
    termData.faces = 12;
    super(termData);
  }
}
D12ArtifactDie.DENOMINATION = '12';

/* -------------------------------------------- */

/**
 * Twilight Die: 1 & 6+ cannot be re-rolled.
 * @extends {ArtifactDie} But LOCKED_VALUES are not the same
 * @category OTHER DICE
 */
class TwilightDie extends ArtifactDie {
  /** @override */
  getResultLabel(result) {
    return CONFIG.YZUR.Icons.getLabel('base', result.result);
  }
}
TwilightDie.TYPE = 'base';
TwilightDie.SUCCESS_TABLE = [null, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2];
TwilightDie.LOCKED_VALUES = [1, 6, 7, 8, 9, 10, 11, 12];

class D6TwilightDie extends TwilightDie {
  constructor(termData = {}) {
    termData.faces = 6;
    super(termData);
  }
}
D6TwilightDie.DENOMINATION = '6';

class D8TwilightDie extends TwilightDie {
  constructor(termData = {}) {
    termData.faces = 8;
    super(termData);
  }
}
D8TwilightDie.DENOMINATION = '8';

class D10TwilightDie extends TwilightDie {
  constructor(termData = {}) {
    termData.faces = 10;
    super(termData);
  }
}
D10TwilightDie.DENOMINATION = '10';

class D12TwilightDie extends TwilightDie {
  constructor(termData = {}) {
    termData.faces = 12;
    super(termData);
  }
}
D12TwilightDie.DENOMINATION = '12';

/* -------------------------------------------- */

/**
 * Ammunition Die for Twilight 2000.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class AmmoDie extends YearZeroDie {
  constructor(termData = {}) {
    termData.faces = 6;
    super(termData);
  }
}
AmmoDie.TYPE = 'ammo';
AmmoDie.DENOMINATION = 'm';
AmmoDie.LOCKED_VALUES = [1, 6];

/* -------------------------------------------- */

/**
 * Location/Hit Die for Twilight 2000.
 * @extends {YearZeroDie}
 * @category OTHER DICE
 */
class LocationDie extends YearZeroDie {
  constructor(termData = {}) {
    termData.faces = 6;
    super(termData);
  }
  /** @override */
  get pushable() { return false; }

  /** @override */
  roll(options) {
    const roll = super.roll(options);
    roll.count = 0;
    this.results[this.results.length - 1] = roll;
    return roll;
  }
}
LocationDie.TYPE = 'loc';
LocationDie.DENOMINATION = 'l';
LocationDie.SUCCESS_TABLE = [null, 0, 0, 0, 0, 0, 0];
LocationDie.LOCKED_VALUES = [1, 2, 3, 4, 5, 6];

/* -------------------------------------------- */

/**
 * BladeRunner Die: 1 cannot be re-rolled.
 * @extends {ArtifactDie} But LOCKED_VALUES are not the same
 * @category OTHER DICE
 */
class BladeRunnerDie extends ArtifactDie {
  /** @override */
  getResultLabel(result) {
    return CONFIG.YZUR.Icons.getLabel('base', result.result);
  }
}
BladeRunnerDie.TYPE = 'base';
BladeRunnerDie.SUCCESS_TABLE = [null, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2];
BladeRunnerDie.LOCKED_VALUES = [1];

class D6BladeRunnerDie extends BladeRunnerDie {
  constructor(termData = {}) {
    termData.faces = 6;
    super(termData);
  }
}
D6BladeRunnerDie.DENOMINATION = '6';
D6BladeRunnerDie.LOCKED_VALUES = [1, 6];

class D8BladeRunnerDie extends BladeRunnerDie {
  constructor(termData = {}) {
    termData.faces = 8;
    super(termData);
  }
}
D8BladeRunnerDie.DENOMINATION = '8';
D8BladeRunnerDie.LOCKED_VALUES = [1, 6, 7, 8];

class D10BladeRunnerDie extends BladeRunnerDie {
  constructor(termData = {}) {
    termData.faces = 10;
    super(termData);
  }
}
D10BladeRunnerDie.DENOMINATION = '10';
D10BladeRunnerDie.LOCKED_VALUES = [1, 10];

class D12BladeRunnerDie extends BladeRunnerDie {
  constructor(termData = {}) {
    termData.faces = 12;
    super(termData);
  }
}
D12BladeRunnerDie.DENOMINATION = '12';
D12BladeRunnerDie.LOCKED_VALUES = [1, 10, 11, 12];

var YearZeroDice = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmmoDie: AmmoDie,
  ArtifactDie: ArtifactDie,
  BaseDie: BaseDie,
  BladeRunnerDie: BladeRunnerDie,
  D10ArtifactDie: D10ArtifactDie,
  D10BladeRunnerDie: D10BladeRunnerDie,
  D10TwilightDie: D10TwilightDie,
  D12ArtifactDie: D12ArtifactDie,
  D12BladeRunnerDie: D12BladeRunnerDie,
  D12TwilightDie: D12TwilightDie,
  D6BladeRunnerDie: D6BladeRunnerDie,
  D6TwilightDie: D6TwilightDie,
  D8ArtifactDie: D8ArtifactDie,
  D8BladeRunnerDie: D8BladeRunnerDie,
  D8TwilightDie: D8TwilightDie,
  GearDie: GearDie,
  LocationDie: LocationDie,
  NegativeDie: NegativeDie,
  SkillDie: SkillDie,
  StressDie: StressDie,
  TwilightDie: TwilightDie,
  YearZeroDie: YearZeroDie
});

/* -------------------------------------------- */

/**
 * All constants used by YZUR which are stored in Foundry's `CONFIG.YZUR`.
 * @constant
 * @global
 * @property {!string} game The identifier for the game
 * @property {Object}           Chat                 Options for the chat
 * @property {boolean}         [Chat.showInfos=true] Whether to show the additional information under the roll result
 * @property {DieTypeString[]} [Chat.diceSorting=['base', 'skill', 'neg', 'gear', 'arto', 'loc', 'ammo']]
 *   Defines the default order
 * @property {Object}  Roll                 Options for the YearZeroRoll class
 * @property {!string} Roll.chatTemplate    Path to the chat template
 * @property {!string} Roll.tooltipTemplate Path to the tooltip template
 * @property {!string} Roll.infosTemplate   Path to the infos template
 * @property {Object}          Dice     Options for the YearZeroDie class
 * @property {boolean}        [Dice.localizeDieTypes=true]
 *   Whether to localize the type of the die
 * @property {DieTypeString[]} Dice.DIE_TYPES
 *   An array of YearZeroDie types
 * @property {Object.<DieTermString, class>}  Dice.DIE_TERMS
 *   An enumeration of YearZeroDie classes
 * @property {Object}    Icons    Options for the icons and what's on the die faces
 * @property {function} [Icons.getLabel=getLabel( type: DieTypeString, result: number )] 
 *   A customizable helper function for creating the labels of the die.
 *   Note: You must return a string or DsN will throw an error.
 * @property {Object.<DieTypeString, Object.<string, string|number>>} Icons.yzGame
 *   Defines the labels for your dice. Change `yzGame` with the game identifier
 */
const YZUR = {
  game: '',
  Chat: {
    showInfos: true,
    diceSorting: ['base', 'skill', 'neg', 'gear', 'arto', 'loc', 'ammo'],
  },
  Roll: {
    chatTemplate: 'templates/dice/roll.html',
    tooltipTemplate: 'templates/dice/tooltip.html',
    infosTemplate: 'templates/dice/infos.hbs',
  },
  Dice: {
    localizeDieTerms: true,
    DIE_TYPES: ['base', 'skill', 'neg', 'gear', 'stress', 'arto', 'ammo', 'loc'],
    DIE_TERMS: {
      'base': BaseDie,
      'skill': SkillDie,
      'neg': NegativeDie,
      'gear': GearDie,
      'stress': StressDie,
      'artoD8': D8ArtifactDie,
      'artoD10': D10ArtifactDie,
      'artoD12': D12ArtifactDie,
      'a': D12TwilightDie,
      'b': D10TwilightDie,
      'c': D8TwilightDie,
      'd': D6TwilightDie,
      'ammo': AmmoDie,
      'loc': LocationDie,
      'brD12': D12BladeRunnerDie,
      'brD10': D10BladeRunnerDie,
      'brD8': D8BladeRunnerDie,
      'brD6': D6BladeRunnerDie,
    },
  },
  Icons: {
    /**
     * A customizable helper function for creating the labels of the die.
     * Note: You must return a string or DsN will throw an error.
     * @param {DieTypeString} type
     * @param {number} result
     * @returns {string}
     */
    getLabel: function (type, result) {
      const arto = ['d8', 'd10', 'd12'];
      if (arto.includes(type)) type = 'arto';
      return String(CONFIG.YZUR.Icons[CONFIG.YZUR.game][type][result]);
    },
    myz: {
      base: {
        '1': '☣',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '☢',
      },
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '☢',
      },
      neg: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '➖',
      },
      gear: {
        '1': '💥',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '☢',
      },
    },
    fbl: {
      base: {
        '1': '☠',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '⚔️',
      },
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '⚔️',
      },
      neg: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '➖',
      },
      gear: {
        '1': '💥',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '⚔️',
      },
      arto: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': 6,
        '7': 7,
        '8': 8,
        '9': 9,
        '10': 10,
        '11': 11,
        '12': 12,
      },
    },
    alien: {
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '💠', // '❇',
      },
      stress: {
        '1': '😱', // '⚠',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '💠',
      },
    },
    tales: {
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '⚛️', // '👑',
      },
    },
    cor: {
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '🐞',
      },
    },
    vae: {
      skill: {
        '1': 1,
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '🦋',
      },
    },
    t2k: {
      base: {
        '1': '💥',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': 6,
        '7': 7,
        '8': 8,
        '9': 9,
        '10': 10,
        '11': 11,
        '12': 12,
      },
      ammo: {
        '1': '💥',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': '🎯',
      },
      loc: {
        '1': 'L',
        '2': 'T',
        '3': 'T',
        '4': 'T',
        '5': 'A',
        '6': 'H',
      },
    },
    br: {
      base: {
        '1': '🦄',
        '2': 2,
        '3': 3,
        '4': 4,
        '5': 5,
        '6': 6,
        '7': 7,
        '8': 8,
        '9': 9,
        '10': 10,
        '11': 11,
        '12': 12,
      },
    },
  },
};

/* -------------------------------------------- */
/*  Definitions                                 */
/* -------------------------------------------- */

/**
 * Defines a Year Zero game.
 * - `myz`: Mutant Year Zero
 * - `fbl`: Forbidden Lands
 * - `alien`: Alien RPG
 * - `cor`: Coriolis The Third Horizon
 * - `tales`: Tales From the Loop & Things From the Flood
 * - `vae`: Vaesen
 * - `t2k`: Twilight 2000
 * - `br`: Blade Runner RPG
 * @typedef {string} GameTypeString
 */

/**
 * Defines a term of a YZ die. It's a shortcut to its class.
 * - `base`: Base Die (locked on 1 and 6, trauma on 1)
 * - `skill`: Skill Die (locked on 6)
 * - `gear`: Gear Die (locked on 1 and 6, gear damage on 1)
 * - `neg`: Negative Die (locked on 6, negative success)
 * - `stress`: Stress Die (locked on 1 and 6, stress, panic)
 * - `artoD8`: D8 Artifact Die (locked on 6+, multiple successes)
 * - `artoD10`: D10 Artifact Die (locked on 6+, multiple successes)
 * - `artoD12`: D12 Artifact Die (locked on 6+, multiple successes)
 * - `a`: Twilight 2000's D12 Die (locked on 1 and 6+, multiple successes)
 * - `b`: Twilight 2000's D10 Die (locked on 1 and 6+, multiple successes)
 * - `c`: Twilight 2000's D8 Die (locked on 1 and 6+)
 * - `d`: Twilight 2000's D6 Die (locked on 1 and 6+)
 * - `ammo`: Twilight 2000's Ammo Die (locked on 1 and 6, not success but hit)
 * - `loc`: Twilight 2000's Location Die
 * - `brD12`: Blade Runner's D12 Die (locked on 1 and 10+)
 * - `brD10`: Blade Runner's D10 Die (locked on 1 and 10)
 * - `brD8`: Blade Runner's D8 Die (locked on 1 and 6+)
 * - `brD6`: Blade Runner's D6 Die (locked on 1 and 6)
 * @typedef {string} DieTermString
 */

/**
 * Defines a type of a YZ die, its generic role and function.
 * - `base`: Base Die
 * - `skill`: Skill Die
 * - `gear`: Gear Die
 * - `neg`: Negative Die
 * - `stress`: Stress Die
 * - `arto`: Artifact Die
 * - `ammo`: Ammo Die
 * - `loc`: Location Die
 * @typedef {string} DieTypeString
 */

/**
 * Defines a YZ die's denomination.
 * @typedef {string} DieDeno
 */

/**
 * An object that is used to build a new class that extends the YearZeroDie class.
 * @typedef {Object} DieClassData
 * @property {!string}        name          The name of the new Die class
 * @property {!DieDeno}       denomination  The denomination of the new Die class
 * @property {!faces}         faces         The number of faces of the new Die class
 * @property {DieTypeString} [type]         The type of the new Die class
 * @property {number[]}      [lockedValues] An array of values that disallow the die to be pushed
 */

/**
 * An object that is used to define a YearZero DieTerm.
 * @typedef  {Object}   TermBlok
 * @property {!DieDeno} term     The denomination of the dice to create
 * @property {!number}  number   The quantity of those dice
 * @property {string}  [flavor]  (optional) Any flavor tied to those dice
 * @property {number}  [maxPush] (optional) Special maxPush modifier but only for the those dice
 */

/**
 * Result of a rolled YearZero DieTerm.
 * @typedef {Object} YearZeroDieTermResult
 * @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?
 * @property {boolean} pushed      ✨ Was this result pushed?
 * @property {boolean} hidden      ✨ Hides the die for DsN
 * @property {number}  indexResult ✨ Index of the result, and column position in the chat tooltip
 * @property {number}  indexPush   ✨ Index of the push, and row position in the chat tooltip
 * @see ✨ Extra features added by the override.
 * @see (FoundryVTT) {@link https://foundryvtt.com/api/global.html#DiceTermResult|DieTermResult} 
 */

/* -------------------------------------------- */

class GameTypeError extends TypeError {
  constructor(msg) {
    super(`Unknown game: "${msg}". Allowed games are: ${YearZeroRollManager.GAMES.join(', ')}.`);
    this.name = 'YZUR | GameType Error';
  }
}

class DieTermError extends TypeError {
  constructor(msg) {
    super(`Unknown die term: "${msg}". Allowed terms are: ${Object.keys(CONFIG.YZUR.Dice.DIE_TERMS).join(', ')}.`);
    this.name = 'YZUR | DieTerm Error';
  }
}

// class RollError extends SyntaxError {
//   constructor(msg, obj) {
//     super(msg);
//     this.name = 'YZUR | Roll Error';
//     if (obj) console.error(obj);
//   }
// }

/* -------------------------------------------- */

/**
 * Custom Roll class for Year Zero games.
 * @extends {Roll} The Foundry Roll class
 */
class YearZeroRoll extends Roll {

  /**
   * @param {string} formula The string formula to parse
   * @param {Object}         [data]         The data object against which to parse attributes within the formula
   * @param {GameTypeString} [data.game]    The game used
   * @param {string}         [data.name]    The name of the roll
   * @param {number}         [data.maxPush] The maximum number of times the roll can be pushed
   * @param {Object}         [options]         Additional data which is preserved in the database
   * @param {GameTypeString} [options.game]    The game used
   * @param {string}         [options.name]    The name of the roll
   * @param {number}         [options.maxPush] The maximum number of times the roll can be pushed
   * @param {boolean}        [options.yzur]    Forces the roll of a YearZeroRoll in Foundry
   */
  constructor(formula, data = {}, options = {}) {
    if (options.name == undefined) options.name = data.name;
    if (options.game == undefined) options.game = data.game;
    if (options.maxPush == undefined) options.maxPush = data.maxPush;

    super(formula, data, options);

    if (!this.game) this.game = CONFIG.YZUR.game ?? 'myz';
    if (options.maxPush != undefined) this.maxPush = options.maxPush;
  }

  /* -------------------------------------------- */

  /**
   * The game used.
   * @type {string}
   * @readonly
   */
  get game() { return this.options.game; }
  set game(yzGame) { this.options.game = yzGame; }

  /**
   * The name of the roll.
   * @type {string}
   * @readonly
   */
  get name() { return this.options.name; }
  set name(str) { this.options.name = str; }

  /**
   * The maximum number of pushes.
   * @type {number}
   */
  set maxPush(n) {
    this.options.maxPush = n;
    for (const t of this.terms) {
      if (t instanceof YearZeroDie) {
        t.maxPush = n;
      }
    }
  }
  get maxPush() {
    // Note: Math.max(null, n) returns a number between [0, n[.
    return this.terms.reduce((max, t) => t instanceof YearZeroDie ? Math.max(max, t.maxPush) : max, null);
  }

  /**
   * The total number of dice in the roll.
   * @type {number}
   * @readonly
   */
  get size() {
    return this.terms.reduce((s, t) => t instanceof YearZeroDie ? s + t.number : s, 0);
  }

  /**
   * The number of times the roll has been pushed.
   * @type {number}
   * @readonly
   */
  get pushCount() {
    return this.terms.reduce((c, t) => Math.max(c, t.pushCount || 0), 0);
  }

  /**
   * Whether the roll was pushed or not.
   * @type {boolean}
   * @readonly
   */
  get pushed() {
    return this.pushCount > 0;
  }

  /**
   * Tells if the roll is pushable.
   * @type {boolean}
   * @readonly
   */
  get pushable() {
    return (
      this.pushCount < this.maxPush
      && this.terms.some(t => t.pushable)
      // && !this.mishap
    );
  }

  /**
   * The quantity of successes.
   * @type {number}
   * @readonly
   */
  get successCount() {
    return this.terms.reduce((sc, t) => sc + (t.success ?? 0), 0);
  }

  /**
   * The quantity of ones (banes).
   * @type {number}
   * @readonly
   */
  get baneCount() {
    // return this.terms.reduce((bc, t) => bc + t.failure, 0);
    const banableTypes = ['base', 'gear', 'stress', 'ammo'];
    let count = 0;
    for (const bt of banableTypes) {
      count += this.count(bt, 1);
    }
    return count;
  }

  /**
   * The quantity of traumas ("1" on base dice).
   * @type {number}
   * @readonly
   */
  get attributeTrauma() {
    return this.count('base', 1);
  }

  /**
   * The quantity of gear damage ("1" on gear dice).
   * @type {number}
   * @readonly
   */
  get gearDamage() {
    return this.count('gear', 1);
  }

  /**
   * The quantity of stress dice.
   * @type {number}
   * @readonly
   */
  get stress() {
    return this.count('stress');
  }

  /**
   * The quantity of panic ("1" on stress dice).
   * @type {number}
   * @readonly
   */
  get panic() {
    return this.count('stress', 1);
  }

  /**
   * Tells if the roll is a mishap (double 1's).
   * @type {boolean}
   * @readonly
   * @deprecated
   */
  get mishap() {
    // if (this.game !== 't2k') return false;
    // return this.baneCount >= 2 || this.baneCount >= this.size;
    console.warn('YZUR | YearZeroRoll#mishap is deprecated.');
    return false;
  }

  /**
   * The sum of the ammo dice's values.
   * @type {number}
   * @readonly
   */
  get ammoSpent() {
    const mt = this.getTerms('ammo');
    if (!mt.length) return 0;
    return mt.reduce((tot, t) => tot + t.values.reduce((a, b) => a + b, 0), 0);
  }

  /**
   * The quantity of successes on ammo dice.
   * @type {number}
   * @readonly
   */
  get hitCount() {
    return this.count('ammo', 6);
  }

  /**
   * The quantity of ones (banes) on base dice and ammo dice.
   * @type {number}
   * @readonly
   */
  get jamCount() {
    const n = this.count('ammo', 1);
    return n > 0 ? n + this.attributeTrauma : 0;
  }

  /**
   * Tells if the roll caused a weapon jam.
   * @type {boolean}
   * @readonly
   */
  get jammed() {
    return this.pushed ? (this.jamCount >= 2) : false;
  }

  /**
   * The total successes produced by base dice.
   * @type {number}
   * @readonly
   */
  get baseSuccessQty() {
    return this.successCount - this.hitCount;
  }

  /**
   * The rolled hit locations.
   * @type {number[]}
   * @readonly
   */
  get hitLocations() {
    const lt = this.getTerms('loc');
    if (!lt.length) return [];
    return lt.reduce((tot, t) => tot.concat(t.values), []);
  }

  /**
   * The best rolled hit location.
   * @type {number}
   * @readonly
   */
  get bestHitLocation() {
    if (!this.hitLocations.length) return undefined;
    return Math.max(...this.hitLocations);
  }

  /* -------------------------------------------- */
  /*  Static Class Methods                        */
  /* -------------------------------------------- */

  /**
   * A factory method which constructs a Roll instance using the default configured Roll class.
   * @param {string}  formula     The formula used to create the Roll instance
   * @param {Object} [data={}]    The data object which provides component data for the formula
   * @param {Object} [options={}] Additional options which modify or describe this Roll
   * @returns {YearZeroRoll} The constructed Roll instance
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/Roll.html#.create|Roll.create}
   * @override
   */
  static create(formula, data = {}, options = {}) {
    return new YearZeroRoll(formula, data, options);
  }

  /* -------------------------------------------- */

  /**
   * Generates a roll based on the number of dice.
   * @param {TermBlok|TermBlok[]} dice An array of objects that define the dice
   * @param {Object}         [data={}]        Additional data to forge the dice
   * @param {string}         [data.title]     The name of the roll
   * @param {GameTypeString} [data.yzGame]    The game used
   * @param {number}         [data.maxPush=1] The maximum number of pushes
   * @param {Object}         [options]        Additional data which is preserved in the database
   * @returns {YearZeroRoll}
   * @static
   */
  static forge(dice = [], { title, yzGame = null, maxPush = 1 } = {}, options = {}) {
    // Checks the game.
    yzGame = yzGame ?? options.game ?? CONFIG.YZUR?.game;
    if (!YearZeroRollManager.GAMES.includes(yzGame)) throw new GameTypeError(yzGame);

    // Converts old format DiceQuantities.
    // ? Was: {Object.<DieTermString, number>}
    // ! This is temporary support. @deprecated
    const isOldFormat = !Array.isArray(dice) && typeof dice === 'object' && !Object.keys(dice).includes('term');
    if (isOldFormat) {
      // eslint-disable-next-line max-len
      console.warn(`YZUR | ${YearZeroRoll.name} | You are using an old "DiceQuanties" format which is deprecated and could be removed in a future release. Please refer to ".forge()" for the newer format.`);
      const _dice = [];
      for (const [term, n] of Object.entries(dice)) {
        if (n <= 0) continue;
        let deno = CONFIG.YZUR.Dice.DIE_TERMS[term].DENOMINATION;
        const cls = CONFIG.Dice.terms[deno];
        deno = cls.DENOMINATION;
        _dice.push({ term: deno, number: n });
      }
      dice = _dice;
    }

    // Converts to an array.
    if (!Array.isArray(dice)) dice = [dice];

    // Builds the formula.
    const out = [];
    for (const d of dice) {
      out.push(YearZeroRoll._getTermFormulaFromBlok(d));
    }
    let formula = out.join(' + ');

    if (!YearZeroRoll.validate(formula)) {
      console.warn(`YZUR | ${YearZeroRoll.name} | Invalid roll formula: "${formula}"`);
      formula = yzGame === 't2k' ? '1d6' : '1ds';
    }

    // Creates the roll.
    if (options.name == undefined) options.name = title;
    if (options.game == undefined) options.game = yzGame;
    if (options.maxPush == undefined) options.maxPush = maxPush;
    const roll = YearZeroRoll.create(formula, {}, options);
    if (CONFIG.debug.dice) console.log(roll);
    return roll;
  }

  /* -------------------------------------------- */

  /** @deprecated */
  // eslint-disable-next-line no-unused-vars
  static createFromDiceQuantities(dice = {}, { title, yzGame = null, maxPush = 1, push = false } = {}) {
    // eslint-disable-next-line max-len
    console.warn('YZUR | createFromDiceQuantities() is deprecated and will be removed in a future release. Use forge() instead.');
    return YearZeroRoll.forge(dice, { title, yzGame, maxPush });
  }

  /* -------------------------------------------- */

  /**
   * Creates a roll formula based on a TermBlok.
   * @see YearZeroRoll.generateTermFormula
   * @param {TermBlok} termBlok
   * @returns {string}
   * @private
   * @static
   */
  static _getTermFormulaFromBlok(termBlok) {
    const { term, number, flavor, maxPush } = termBlok;
    return YearZeroRoll.generateTermFormula(number, term, flavor, maxPush);
  }

  /**
   * Creates a roll formula based on number of dice.
   * @param {number}  number   The quantity of those dice
   * @param {DieDeno} term     The denomination of the dice to create
   * @param {string} [flavor]  (optional) Any flavor tied to those dice
   * @param {number} [maxPush] (optional) Special maxPush modifier but only for those dice
   * @returns {string}
   * @static
   */
  static generateTermFormula(number, term, flavor = '', maxPush = null) {
    let f = `${number}d${term}`;
    if (typeof maxPush === 'number') f += `p${maxPush}`;
    if (flavor) f += `[${flavor}]`;
    return f;
  }

  /* -------------------------------------------- */
  /*  YearZeroRoll Utility Methods                */
  /* -------------------------------------------- */

  /**
   * Gets all the dice terms of a certain type or that match an object of values.
   * @param {DieTypeString|{}} search Die type to search or an object with comparison values
   * @returns {YearZeroDie[]|DiceTerm[]}
   * 
   * @example
   * // Gets all terms with the type "skill".
   * let terms = getTerms('skill');
   * 
   * // Gets all terms that have exactly these specifications (it follows the structure of a DiceTerm).
   * let terms = getTerms({
   *   type: 'skill',
   *   number: 1,
   *   faces: 6,
   *   options: {
   *     flavor: 'Attack',
   *     // ...etc...
   *   },
   *   results: {
   *     result: 3,
   *     active: true,
   *     // ...etc...
   *   },
   * });
   */
  getTerms(search) {
    if (typeof search === 'string') return this.terms.filter(t => t.type === search);
    return this.terms.filter(t => {
      let f = true;
      if (search.type != undefined) f = f && search.type === t.type;
      if (search.number != undefined) f = f && search.number === t.number;
      if (search.faces != undefined) f = f && search.faces === t.faces;
      if (search.options) {
        for (const key in search.options) {
          f = f && search.options[key] === t.options[key];
        }
      }
      if (search.results) {
        for (const key in search.results) {
          f = f && t.results.some(r => r[key] === search.results[key]);
        }
      }
      return f;
    });
  }

  /* -------------------------------------------- */

  /**
   * Counts the values of a certain type in the roll.
   * If `seed` is omitted, counts all the dice of a certain type.
   * @param {DieTypeString} type  The type of the die
   * @param {number}       [seed] The value to search, if any
   * @param {string}       [comparison='='] The comparison to use against the seed: `>`, `>=`, `<`, `<=` or `=`
   * @returns {number} Total count
   */
  count(type, seed = null, comparison = '=') {
    return this.terms.reduce((c, t) => {
      if (t.type === type) {
        if (t.results.length) {
          for (const r of t.results) {
            if (!r.active) continue;
            if (seed != null) {
              if (comparison === '>') { if (r.result > seed) c++; }
              else if (comparison === '>=') { if (r.result >= seed) c++; }
              else if (comparison === '<') { if (r.result < seed) c++; }
              else if (comparison === '<=') { if (r.result <= seed) c++; }
              else if (r.result === seed) { c++; }
            }
            else {
              c++;
            }
          }
        }
        else if (seed != null) {
          c += 0;
        }
        else {
          c += t.number;
        }
      }
      return c;
    }, 0);
  }

  /* -------------------------------------------- */

  /**
   * Adds a number of dice to the roll.
   * Note: If a negative quantity is passed, instead it removes that many dice.
   * @param {number}        qty      The quantity to add
   * @param {DieTermString} type     The type of dice to add
   * @param {number}       [range=6] The number of faces of the die
   * @param {number}       [value]   The predefined value for the new dice
   * @param {Object}       [options] Additional options that modify the term
   * @returns {Promise.<YearZeroRoll>} This roll
   * @async
   */
  async addDice(qty, type, { range = 6, value = null, options } = {}) {
    if (!qty) return this;
    const search = { type, faces: range, options };
    if (qty < 0) return this.removeDice(-qty, search);
    if (value != undefined && !this._evaluated) await this.roll();

    let term = this.getTerms(search)[0];
    if (term) {
      for (; qty > 0; qty--) {
        term.number++;
        if (this._evaluated) {
          term.roll();
          // TODO missing term._evaluateModifiers() for this new result only
          if (value != undefined) {
            term.results[term.results.length - 1].result = value;
          }
        }
      }
    }
    // If the DieTerm doesn't exist, creates it.
    else {
      const cls = CONFIG.YZUR.Dice.DIE_TERMS[type];
      term = new cls({
        number: qty,
        faces: range,
        maxPush: this.maxPush ?? 1,
        options,
      });
      if (this._evaluated) {
        await term.evaluate();
        if (value != undefined) {
          term.results.forEach(r => r.result = value);
        }
      }
      if (this.terms.length > 0) {
        // eslint-disable-next-line no-undef
        this.terms.push(new OperatorTerm({ operator: type === 'neg' ? '-' : '+' }));
      }
      this.terms.push(term);
    }
    // Updates the cache of the Roll.
    this._formula = this.constructor.getFormula(this.terms);
    if (this._evaluated) this._total = this._evaluateTotal();

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Removes a number of dice from the roll.
   * @param {number}           qty      The quantity to remove
   * @param {DieTypeString|{}} search   The type of dice to remove, or an object of values for comparison
   * @param {boolean}         [discard] Whether the term should be marked as "discarded" instead of removed
   * @param {boolean}         [disable] Whether the term should be marked as "active: false" instead of removed
   * @returns {YearZeroRoll} This roll
   */
  removeDice(qty, search, { discard = false, disable = false } = {}) {
    if (!qty) return this;

    for (; qty > 0; qty--) {
      const term = this.getTerms(search)[0];
      if (term) {
        term.number--;
        if (term.number <= 0) {
          const type = search.type ?? search;
          const index = this.terms.findIndex(t => t.type === type && t.number === 0);
          this.terms.splice(index, 1);
          if (this.terms[index - 1]?.operator) {
            this.terms.splice(index - 1, 1);
          }
        }
        else if (this._evaluated) {
          const index = term.results.findIndex(r => r.active);
          if (index < 0) break;
          if (discard || disable) {
            if (discard) term.results[index].discarded = discard;
            if (disable) term.results[index].active = !disable;
          }
          else {
            term.results.splice(index, 1);
          }
        }
      }
      else { break; }
    }

    const terms = this.terms;
    // eslint-disable-next-line no-undef
    if (terms[0] instanceof OperatorTerm) {
      terms.shift();
    }
    // Updates the cache of the Roll.
    this._formula = this.constructor.getFormula(this.terms);
    if (this._evaluated) {
      if (this.terms.length) this._total = this._evaluateTotal();
      else this._total = 0;
    }

    return this;
  }

  /* -------------------------------------------- */
  /*  Push                                        */
  /* -------------------------------------------- */

  /**
   * Pushes the roll, following the YZ rules.
   * @param {Object}  [options={}]          Options which inform how the Roll is evaluated
   * @returns {Promise.<YearZeroRoll>} The roll instance, pushed
   * @async
   */
  async push() {
    if (!this._evaluated) await this.evaluate();
    if (!this.pushable) return this;

    // Step 1 — Pushes the terms.
    this.terms.forEach(t => t instanceof YearZeroDie ? t.push() : t);

    // Step 2 — Re-evaluates all pushed terms.
    //   The evaluate() method iterates each terms and runs only
    //   the term's own evaluate() method on new (pushed) dice.
    this._evaluated = false;
    await this.evaluate();

    return this;
  }

  /* -------------------------------------------- */
  /*  Modify                                      */
  /* -------------------------------------------- */

  /**
   * Applies a difficulty modifier to the roll.
   * @param {number} mod Difficulty modifier (bonus or malus)
   * @returns {Promise.<YearZeroRoll>} This roll, modified
   * @async
   */
  async modify(mod = 0) {
    if (!mod) return this;

    // TWILIGHT 2000 & BLADE RUNNER
    // --------------------------------------------
    else if (this.game === 't2k' || this.game === 'br') {
      const diceMap = [null, 6, 8, 10, 12, Infinity];
      const typesMap = ['d', 'd', 'c', 'b', 'a', 'a'];
      const refactorRange = (range, n) => diceMap[diceMap.indexOf(range) + n];
      const getTypeFromRange = range => typesMap[diceMap.indexOf(range)];

      const _terms = this.getTerms('base');
      const dice = _terms.flatMap(t => new Array(t.number).fill(t.faces));

      // BLADE RUNNER
      if (this.game === 'br') {
        // Gets the lowest term.
        const lowest = Math.min(...dice);

        // A positive modifier means advantage.
        // An advantage adds a third base die, same value as lowest.
        if (mod > 0) {
          dice.push(lowest);
        }
        // A negative modifier means disadvantage.
        // A disadvantage removes the lowest die.
        else if (mod < 0) {
          const i = dice.indexOf(lowest);
          dice.splice(i, 1);
        }
        mod = 0;
      }

      // TWILIGHT 2000
      else {
        // 1 — Modifies the dice ranges.
        while (mod !== 0) {
          let i;
          // 1.1.1 — A positive modifier increases the lowest term.
          if (mod > 0) {
            // Adds an extra die if there is only 1 die.
            if (dice.length < 2) {
              i = 1;
              dice.push(diceMap[1]);
            }
            else {
              i = dice.indexOf(Math.min(...dice));
              dice[i] = refactorRange(dice[i], 1);
            }
            mod--;
          }
          // 1.1.2 — A negative modifier decreases the highest term.
          else {
            i = dice.indexOf(Math.max(...dice));
            dice[i] = refactorRange(dice[i], -1);
            mod++;
          }
          // 1.2 — Readjusts term faces.
          if (dice[i] === Infinity) {
            dice[i] = refactorRange(dice[i], -1);
          }
          else if (dice[i] === null) {
            if (dice.length > 1) {
              dice.splice(i, 1);
            }
            else {
              dice[i] = refactorRange(dice[i], 1);
            }
          }
          else if (dice[i] === undefined) {
            throw new Error(`YZUR | YearZeroRoll#modify<T2K> | dice[${i}] is out of bounds (mod: ${mod})`);
          }
        }
      }
      // 2 — Filters out all the base terms.
      //       This way, it will also remove leading operator terms.
      this.removeDice(100, 'base');

      // 3 — Reconstructs the base terms.
      const skilled = _terms.length > 1 && dice.length > 1;
      for (let index = 0; index < dice.length; index++) {
        const ti = Math.min(index, skilled ? 1 : 0);
        await this.addDice(1, getTypeFromRange(dice[index]), {
          range: dice[index],
          options: foundry.utils.deepClone(_terms[ti].options),
        });
      }
      // Note: reconstructed terms are evaluated
      // at the end of this method.
    }
    // MUTANT YEAR ZERO & FORBIDDEN LANDS
    // --------------------------------------------
    else if (['myz', 'fbl', 'alien'].includes(this.game)) {
      // Modifies skill & neg dice.
      const skill = this.count('skill');
      const neg = Math.min(skill + mod, 0);
      await this.addDice(mod, 'skill');
      if (neg < 0) {
        if (this.game === 'alien') {
          await this.addDice(neg, 'stress');
        }
        else {
          await this.addDice(neg, 'neg');
        }
      }

      // Balances skill & neg dice.
      while (this.count('skill') > 0 && this.count('neg') > 0) {
        this.removeDice(1, 'skill');
        this.removeDice(1, 'neg');
      }
    }
    // ALL OTHER GAMES (CORIOLIS, VAESEN, TFTL, etc.)
    // --------------------------------------------
    else {
      const skill = this.count('skill');
      if (mod < 0) {
        // Minimum of 1 skill die.
        mod = Math.max(-skill + 1, mod);
      }
      await this.addDice(mod, 'skill');
    }

    // --------------------------------------------

    // Re-evaluates all terms that were left unevaluated.
    if (this._evaluated) {
      for (const t of this.terms) {
        if (!t._evaluated) await t.evaluate();
      }
    }

    return this;
  }

  /* -------------------------------------------- */
  /*  Templating                                  */
  /* -------------------------------------------- */

  /** 
   * Renders the tooltip HTML for a Roll instance.
   * @returns {Promise.<string>} The rendered HTML tooltip as a string
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/Roll.html#getTooltip|Roll.getTooltip}
   * @async
   * @override
   */
  async getTooltip() {
    const parts = this.dice.map(d => d.getTooltipData())
    // ==>
      .sort((a, b) => {
        const sorts = CONFIG?.YZUR?.Chat?.diceSorting
          || YZUR.Chat.diceSorting
          || [];
        if (!sorts.length) return 0;
        const at = sorts.indexOf(a.type);
        const bt = sorts.indexOf(b.type);
        return at - bt;
      });
    // <==
    // START MODIFIED PART ==>
    if (this.pushed) {
      // Converts "parts.rolls" into a matrix.
      for (const part of parts) {
        // Builds the matrix;
        const matrix = [];
        const n = part.number;
        let p = this.pushCount;
        for (; p >= 0; p--) matrix[p] = new Array(n).fill(undefined);

        // Fills the matrix.
        for (const r of part.rolls) {
          const row = r.row || 0;
          const col = r.col || 0;
          matrix[row][col] = r;
        }
        part.rolls = matrix;
      }
    }
    // // return renderTemplate(this.constructor.TOOLTIP_TEMPLATE, { parts });
    return foundry.applications.handlebars.renderTemplate(this.constructor.TOOLTIP_TEMPLATE, {
      parts,
      pushed: this.pushed,
      pushCounts: this.pushed
        ? [...Array(this.pushCount + 1).keys()].sort((a, b) => b - a)
        : undefined,
      config: CONFIG.YZUR ?? {},
      options: this.options,
    });
    // <== END MODIFIED PART
  }

  /* -------------------------------------------- */

  /**
   * Renders the infos of a Year Zero roll.
   * @param {string} [template] The path to the template
   * @returns {Promise.<string>}
   * @async
   */
  async getRollInfos(template = null) {
    template = template ?? CONFIG.YZUR?.Roll?.infosTemplate;
    const context = { roll: this };
    return foundry.applications.handlebars.renderTemplate(template, context);
  }

  /* -------------------------------------------- */

  /**
   * Renders a Roll instance to HTML.
   * @param {Object}  [chatOptions]               An object configuring the behavior of the resulting chat message,
   *   which is also passed to the template
   * @param {string}  [chatOptions.user]          The ID of the user that renders the roll
   * @param {string}  [chatOptions.flavor]        The flavor of the message
   * @param {string}  [chatOptions.template]      The path to the template
   *   that renders the roll
   * @param {string}  [chatOptions.infosTemplate] ✨ The path to the template
   *   that renders the infos box under the roll tooltip
   * @param {boolean} [chatOptions.blind]         Whether this is a blind roll
   * @param {boolean} [chatOptions.isPrivate]     Whether this roll is private
   *   (displays sensitive infos with `???` instead)
   * @returns {Promise.<string>}
   * @see ✨ Extra features added by the override.
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/Roll.html#render|Roll.render}
   * @async
   * @override
   */
  async render(chatOptions = {}) {
    if (CONFIG.debug.dice) console.warn(this);

    chatOptions = foundry.utils.mergeObject({
      user: game.user.id,
      flavor: this.name,
      template: this.constructor.CHAT_TEMPLATE,
      blind: false,
    }, chatOptions);
    const isPrivate = chatOptions.isPrivate;

    // Executes the roll, if needed.
    if (!this._evaluated) await this.evaluate();

    // Defines chat data.
    const chatData = {
      formula: isPrivate ? '???' : this._formula,
      flavor: isPrivate ? null : chatOptions.flavor,
      user: chatOptions.user,
      tooltip: isPrivate ? '' : await this.getTooltip(),
      total: isPrivate ? '?' : Math.round(this.total * 100) / 100,
      success: isPrivate ? '?' : this.successCount,
      showInfos: isPrivate ? false : CONFIG.YZUR?.Chat?.showInfos,
      infos: isPrivate ? null : await this.getRollInfos(chatOptions.infosTemplate),
      pushable: isPrivate ? false : this.pushable,
      options: chatOptions,
      isPrivate,
      roll: this,
    };

    // Renders the roll display template.
    return foundry.applications.handlebars.renderTemplate(chatOptions.template, chatData);
  }

  /* -------------------------------------------- */

  /**
   * Transform a Roll instance into a ChatMessage, displaying the roll result.
   * This function can either create the ChatMessage directly, or return the data object that will be used to create.
   * @param {Object}  [messageData]         The data object to use when creating the message
   * @param {string}  [messageData.user]    The ID of the user that sends the message
   * @param {Object}  [messageData.speaker] ✨ The identified speaker data
   * @param {string}  [messageData.content] The HTML content of the message,
   *   overriden by the `roll.render()`'s returned content if left unchanged
   * @param {number}  [messageData.type=0]    The type to use for the message from `CONST.CHAT_MESSAGE_STYLES`
   * @param {string}  [messageData.sound]   The path to the sound played with the message (WAV format)
   * @param {options} [options]             Additional options which modify the created message.
   * @param {string}  [options.rollMode]    The template roll mode to use for the message from CONFIG.Dice.rollModes
   * @param {boolean} [options.create=true] Whether to automatically create the chat message,
   *   or only return the prepared chatData object.
   * @return {Promise.<ChatMessage|ChatMessageData>} A promise which resolves to the created ChatMessage entity
   *   if create is true
   *   or the Object of prepared chatData otherwise.
   * @see ✨ Extra features added by the override.
   * @see (FoundryVTT) {@link https://foundryvtt.com/api/Roll.html#toMessage|Roll.toMessage}
   * @async
   * @override
   */
  async toMessage(messageData = {}, { rollMode = null, create = true } = {}) {
    messageData = foundry.utils.mergeObject({
      user: game.user.id,
      speaker: ChatMessage.getSpeaker(),
      // "content" is overwritten by ChatMessage.create() (called in super)
      // with the HTML returned by roll.render(), but only if content is left unchanged.
      // So you can overwrite it here with a custom content in messageData.
      content: this.total,
      // type: CONST.CHAT_MESSAGE_TYPES.ROLL,
      // sound: CONFIG.sounds.dice, // Already added in super.
    }, messageData);
    // messageData.roll = this; // Already added in super.
    return await super.toMessage(messageData, { rollMode, create });
  }

  /* -------------------------------------------- */
  /*  JSON                                        */
  /* -------------------------------------------- */

  /**
   * Creates a deep clone copy of the roll.
   * @returns {YearZeroRoll} A copy of this roll instance
   */
  duplicate() {
    return this.constructor.fromData(this.toJSON());
  }
}

/* -------------------------------------------- */
/*  Custom Dice Registration                    */
/* -------------------------------------------- */

/**
 * Interface for registering Year Zero dice.
 * 
 * To register the game and its dice,
 * call the static `YearZeroRollManager.register()` method
 * at the start of the `init` Hooks.
 * 
 * @abstract
 * 
 * @throws {SyntaxError} When instanciated
 * 
 * @example
 * import { YearZeroRollManager } from './lib/yzur.js';
 * Hooks.once('init', function() {
 *   YearZeroRollManager.register('yourgame', config, options);
 *   ...
 * });
 * 
 */
class YearZeroRollManager {
  constructor() {
    throw new SyntaxError(`YZUR | ${this.constructor.name} cannot be instanciated!`);
  }

  /**
   * Registers the Year Zero dice for the specified game.
   * 
   * You must call this method in `Hooks.once('init')`.
   * 
   * @param {GameTypeString} yzGame  The game used (for the choice of die types to register)
   * @param {Object}        [config] Custom config to merge with the initial config
   * @param {Object} [options]       Additional options
   * @param {number} [options.index] Index of the registration
   * @see YearZeroRollManager.registerConfig
   * @see YearZeroRollManager.registerDice
   * @see YearZeroRollManager.registerDie
   * @static
   */
  static register(yzGame, config, options = {}) {
    // Override DiceTerm.fromData until we have a better solution.
    YearZeroRollManager._overrideDiceTermFromData();
    // Registers the config.
    YearZeroRollManager.registerConfig(config);
    // Registers the YZ game.
    YearZeroRollManager._initialize(yzGame);
    // Registers the dice.
    YearZeroRollManager.registerDice(yzGame, options?.index);
    console.log('YZUR | Registration complete!');
  }

  /* -------------------------------------------- */

  /**
   * Registers the Year Zero Universal Roller config.
   * *(See the config details at the very bottom of this file.)*
   * @param {string} [config] Custom config to merge with the initial config
   * @static
   */
  static registerConfig(config) {
    CONFIG.YZUR = foundry.utils.mergeObject(YZUR, config);
  }

  /* -------------------------------------------- */

  /**
   * Registers all the Year Zero Dice of the chosen game.
   * @param {GameTypeString} [yzGame] The game used (for the choice of die types to register)
   * @param {number}         [i=0]    Index of the registration
   * @see YearZeroRollManager.registerDie
   * @static
   */
  static registerDice(yzGame, i) {
    // Exists early if `game` is omitted.
    if (!yzGame || typeof yzGame !== 'string') {
      throw new SyntaxError('YZUR | A game must be specified for the registration.');
    }

    // Checks the game validity.
    if (!YearZeroRollManager.GAMES.includes(yzGame)) {
      console.warn(`YZUR | Unsupported game identifier "${yzGame}"`);
      if (!YearZeroRollManager.DIE_TERMS_MAP[yzGame]) {
        YearZeroRollManager.DIE_TERMS_MAP[yzGame] = [];
      }
    }

    // Registers the game's dice.
    const diceTypes = YearZeroRollManager.DIE_TERMS_MAP[yzGame];
    for (const type of diceTypes) YearZeroRollManager.registerDie(type);

    // Finally, registers our custom Roll class for Year Zero games.
    YearZeroRollManager.registerRoll(undefined, i);
  }

  /* -------------------------------------------- */

  /**
   * Registers the roll.
   * @param {class}  [cls] The roll class to register
   * @param {number} [i=0] Index of the registration
   * @static
   */
  static registerRoll(cls = YearZeroRoll, i = 0) {
    CONFIG.Dice.rolls[i] = cls;
    CONFIG.Dice.rolls[i].CHAT_TEMPLATE = CONFIG.YZUR.Roll.chatTemplate;
    CONFIG.Dice.rolls[i].TOOLTIP_TEMPLATE = CONFIG.YZUR.Roll.tooltipTemplate;
    CONFIG.YZUR.Roll.index = i;
    if (i > 0) YearZeroRollManager._overrideRollCreate(i);
  }

  /* -------------------------------------------- */

  /**
   * Registers a die in Foundry.
   * @param {DieTermString} term Class identifier of the die to register
   * @static
   */
  static registerDie(term) {
    const cls = CONFIG.YZUR.Dice.DIE_TERMS[term];
    if (!cls) throw new DieTermError(term);

    const deno = cls.DENOMINATION;
    if (!deno) {
      throw new SyntaxError(`YZUR | Undefined DENOMINATION for "${cls.name}".`);
    }

    // Registers the die in the Foundry CONFIG.
    const reg = CONFIG.Dice.terms[deno];
    if (reg) {
      console.warn(
        `YZUR | Die Registration: "${deno}" | Overwritting ${reg.name} with "${cls.name}".`,
      );
    }
    else {
      console.log(`YZUR | Die Registration: "${deno}" with ${cls.name}.`);
    }
    CONFIG.Dice.terms[deno] = cls;
  }

  /* -------------------------------------------- */

  /**
   * Registers a custom die in Foundry.
   * @param {DieTermString} term Class identifier of the die to register
   * @param {DieClassData}  data Data for creating the custom die class
   * @see YearZeroRollManager.createDieClass
   * @see YearZeroRollManager.registerDie
   */
  static registerCustomDie(term, data) {
    if (!YearZeroRollManager.GAMES.includes(CONFIG.YZUR.game)) {
      throw new GameTypeError('YZUR | Unregistered game. Please register a game before registering a custom die.');
    }

    const cls = YearZeroRollManager.createDieClass(data);

    if (CONFIG.YZUR.Dice.DIE_TERMS[term]) {
      console.warn(`YZUR | Overwriting an existing die "${CONFIG.YZUR.Dice.DIE_TERMS[term]}" with: "${term}"`);
    }
    CONFIG.YZUR.Dice.DIE_TERMS[term] = cls;

    YearZeroRollManager.DIE_TERMS_MAP[CONFIG.YZUR.game].push(term);
    YearZeroRollManager.registerDie(term);
  }
  /* -------------------------------------------- */

  /**
   * @param {GameTypeString} yzGame The game used (for the choice of die types to register)
   * @private
   * @static
   */
  static _initialize(yzGame) {
    if (!CONFIG.YZUR) throw new ReferenceError('YZUR | CONFIG.YZUR does not exists!');
    if (CONFIG.YZUR.game) {
      console.warn(
        `YZUR | Overwriting the default Year Zero game "${CONFIG.YZUR.game}" with: "${yzGame}"`,
      );
    }
    CONFIG.YZUR.game = yzGame;
    console.log(`YZUR | The name of the Year Zero game is: "${yzGame}".`);
  }

  /* -------------------------------------------- */

  /**
   * Overrides the default Foundry DiceTerm prototype to inject our own fromData() function.
   * When creating a dice term, the DiceTerm prototype will now check if the term has a YZE pattern.
   * If so, it uses our method, otherwise it returns to the Foundry defaults.
   * @private
   * @static
   * @see DiceTerm.fromData
  */
  static _overrideDiceTermFromData() {
    foundry.dice.terms.DiceTerm.prototype.constructor.fromData = function (data) {
      let cls = CONFIG.Dice.termTypes[data.class];
      if (!cls) {
        const termkeys = Object.keys(CONFIG.Dice.terms);
        const stringifiedFaces = String(data.faces);
        if (data.class === 'Die' && termkeys.includes(stringifiedFaces)) {
          cls = CONFIG.Dice.terms[stringifiedFaces];
          data.class = cls.name;
        }
        else
          cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || foundry.dice.terms.Die;
      }

      return cls._fromData(data);
    };
  };

  /* -------------------------------------------- */

  /**
   * Overrides the default Foundry Roll prototype to inject our own create() function. 
   * When creating a roll, the Roll prototype will now check if the formula has a YZE pattern. 
   * If so, it uses our method, otherwise it returns to the Foundry defaults.
   * @param {number} [index=1] What index of our own Roll class in the Foundry CONFIG.Dice.rolls array.
   * @returns {YearZerRoll|Roll}
   * @private
   * @static
   */
  static _overrideRollCreate(index = 1) {
    Roll.prototype.constructor.create = function (formula, data = {}, options = {}) {
      const isYZURFormula = options.yzur ?? (
        'game' in data ||
        'maxPush' in data ||
        'game' in options ||
        'maxPush' in options ||
        formula.match(/\d*d(:?[bsngzml]|6|8|10|12)/i)
      );
      const n = isYZURFormula ? index : 0;
      const cls = CONFIG.Dice.rolls[n];
      return new cls(formula, data, options);
    };
  }

  /* -------------------------------------------- */

  /**
   * Creates a new custom Die class that extends the {@link YearZeroDie} class.
   * @param {DieClassData} data An object with
   * @returns {class}
   * @see YearZeroDie
   * @static
   * 
   * @example
   * YZUR.YearZeroRollManager.createDieClass({
   *   name: 'D6SpecialDie',
   *   denomination: 's',
   *   faces: 6,
   *   type: 'gear',
   *   lockedValues: [4, 5, 6],
   * });
   */
  static createDieClass(data) {
    if (!data || typeof data !== 'object') {
      throw new SyntaxError('YZUR | To create a Die class, you must pass a DieClassData object!');
    }

    // eslint-disable-next-line no-shadow
    const { name, denomination: deno, faces, type, lockedValues } = data;

    if (typeof faces !== 'number' || faces <= 0) {
      throw new DieTermError(`YZUR | Invalid die class faces "${faces}"`);
    }

    const YearZeroCustomDie = class extends YearZeroDie {
      constructor(termData = {}) {
        termData.faces = faces;
        super(termData);
      }
    };

    // Defines the name of the new die class.
    if (!name | typeof name !== 'string') {
      throw new DieTermError(`YZUR | Invalid die class name "${name}"`);
    }
    Object.defineProperty(YearZeroCustomDie, 'name', { value: name });

    // Defines the denomination of the new die class.
    if (!deno || typeof deno !== 'string') {
      throw new DieTermError(`YZUR | Invalid die class denomination "${deno}"`);
    }
    YearZeroCustomDie.DENOMINATION = deno;

    // Defines the type of the new die class, if any.
    if (type != undefined) {
      if (typeof type !== 'string') {
        throw new DieTermError(`YZUR | Invalid die class type "${type}"`);
      }
      if (!CONFIG.YZUR.Dice.DIE_TYPES.includes(type)) {
        console.warn(`YZUR | Unsupported DieTypeString: "${type}"`);
      }
      if (!CONFIG.YZUR.Icons[CONFIG.YZUR.game][type]) {
        console.warn(`YZUR | No icons defined for type "${type}"`);
      }
      YearZeroCustomDie.TYPE = type;
    }

    // Defines the locked values of the new die class, if any.
    if (lockedValues != undefined) {
      if (!Array.isArray(lockedValues)) {
        throw new DieTermError(`YZUR | Invalid die class locked values "${lockedValues}" (Not an Array)`);
      }
      for (const [i, v] of lockedValues.entries()) {
        if (typeof v !== 'number') {
          throw new DieTermError(`YZUR | Invalid die class locked value "${v}" at [${i}] (Not a Number)`);
        }
      }
      YearZeroCustomDie.LOCKED_VALUES = lockedValues;
    }
    return YearZeroCustomDie;
  }
}

/* -------------------------------------------- */
/*  Members                                     */
/* -------------------------------------------- */

/**
 * Die Types mapped with Games.
 * Used by the register method to choose which dice to activate.
 * @enum {DieTermString[]}
 * @constant
 */
YearZeroRollManager.DIE_TERMS_MAP = {
  // Mutant Year Zero
  'myz': ['base', 'skill', 'gear', 'neg'],
  // Forbidden Lands
  'fbl': ['base', 'skill', 'gear', 'neg', 'artoD8', 'artoD10', 'artoD12'],
  // Alien RPG
  'alien': ['skill', 'stress'],
  // Tales From the Loop
  'tales': ['skill'],
  // Coriolis
  'cor': ['skill'],
  // Vaesen
  'vae': ['skill'],
  // Twilight 2000
  't2k': ['a', 'b', 'c', 'd', 'ammo', 'loc'],
  // Blade Runner
  'br': ['brD12', 'brD10', 'brD8', 'brD6'],
};

/**
 * List of identifiers for the games.
 * @enum {GameTypeString}
 * @constant
 * @readonly
 */
YearZeroRollManager.GAMES;
Object.defineProperty(YearZeroRollManager, 'GAMES', {
  get: () => Object.keys(YearZeroRollManager.DIE_TERMS_MAP),
});
// YearZeroRollManager.GAMES = Object.keys(YearZeroRollManager.DIE_TERMS_MAP);

export { YZUR, YearZeroDice, YearZeroRoll, YearZeroRollManager };