random_utils.js

/** @module */
import { FREQ } from "./constants.js";
import { hasColonOutsideParens, splitOn } from "./string_utils.js";
import { is } from "./utils.js";
import { seed } from "./seed.js";
import RarityTable from "./tables/rarity_table.js";
import Table from "./tables/table.js";

const DIE_PARSER = /\d+d\d+/g;
const FREQ_KEYS = Object.keys(FREQ);
const NUMBER_PREFIX = /^\d{1,2}\:/;
const RANDOM_STRING = /\{[^\{\}]*\|[^\{\}]*\}/g;
const RARITY_PREFIX = /^[CUR]\:/;
const START_DATE = 1571011491488;
const RARITIES = {
  C: "common",
  U: "uncommon",
  R: "rare",
};

/**
 * Generates a unique string (not guaranteed to be globally unique, but certainly unique in any
 * given context).
 *
 * @return {String} a context unique string
 * @example
 * guid()
 * => "6v2w2762322fwsds"
 *
 */
export function guid() {
  return (
    Math.floor(seed.random() * Math.pow(10, 15)).toString(36) +
    (seed.timestamp() - START_DATE).toString(36)
  );
}

/**
 * Returns a random number based on a die roll string or a provided number. Math operations are supported.
 *
 * @param {Number} number the maximum value to return
 * @return {Number} a value from 1 to N
 * @example
 * roll(8)
 * => 4
 *
 * @also
 * @param {String} notation a notation for a dice roll
 * @return {Number} a die roll value
 * @example
 * roll("3d6+2")
 * => 9
 * roll("(2d4*10)+500")
 * => 540
 */
export function roll(value) {
  if (typeof value === "number") {
    return value > 0 ? ~~(seed.random() * value) + 1 : 0;
  } else if (typeof value === "string") {
    // Finds and extracts dice notation, rolls the die, and then puts the
    // result into the full expression. Then evals that to do the math.
    value = value.replace(DIE_PARSER, (value) => {
      const split = value.split("d");
      const rolls = parseInt(split[0], 10);
      const face = parseInt(split[1], 10);
      let result = 0;
      for (let i = 0; i < rolls; i++) {
        result += ~~(seed.random() * face) + 1;
      }
      return result;
    });
    try {
      return eval.call(null, value);
    } catch (e) {
      return 0;
    }
  }
  throw new Error("Invalid value: " + value);
}

/**
 * Test against a percentage that something will occur.
 *
 * @example
 * if (test(80)) {
 *     // Happens 80% of the time.
 * }
 * test(15, 'A', 'B')
 * => 'B'
 *
 * @param percentage {Number} The percentage chance that the function returns true
 * @param [value1] {any} the value to return if the test succeeds
 * @param [value2] {any} the value to return if the test fails
 * @return {Boolean|any} true if test passes, false otherwise
 */
export function test(percentage) {
  if (percentage < 0) {
    console.warn(`Adjusting percentage ${percentage} to 0`);
    percentage = 0;
  }
  if (percentage > 100) {
    console.warn(`Adjusting percentage ${percentage} to 100`);
    percentage = 100;
  }
  if (arguments.length === 3) {
    return roll(100) <= percentage ? arguments[1] : arguments[2];
  }
  return roll(100) <= percentage;
}

/**
 * Return a random value from a "strategy" value, recursively. An element will be selected
 * from an array, a function will be executed for its return value, and a string with
 * variants will be returned with a variant selected. The value is then checked again to
 * see if it can still be randomized further, and this process continues until a primitive
 * value is returned (a number, object, string, or boolean).
 *
 * @example
 * random("A")
 * => "A"
 * random(['A','B','C']);
 * => 'A'
 * random("{Big|Bad|Black} Dog");
 * => 'Bad Dog'
 * random(() => ["A","B","C"]);
 * => "B"
 *
 * @param value {String|Array|Function} A string with optional variants, or an array from
 *      which to select an element, or a function that returns a value.
 * @return {Object} a single randomized instance, based on the value passed in
 */
export function random(value) {
  if (is(value, "Array")) {
    value = value.length ? value[~~(seed.random() * value.length)] : null;
    return random(value);
  } else if (is(value, "Function")) {
    return random(value());
  } else if (is(value, "String")) {
    return value.replace(RANDOM_STRING, function (token) {
      return random(token.substring(1, token.length - 1).split("|"));
    });
  }
  return value;
}

/**
 * A function that takes an array and returns a random element in the array. Does not
 * execute functions, select a string out of string expressions, or perform any other
 * recursive randomization.
 * @param {collection} an array
 * @returns one element of the array
 */
export function randomElement(collection) {
  return collection[~~(seed.random() * collection.length)];
}

/**
 * Generate a whole random number, on a normal (Gaussian, "bell curve")
 * distribution. This random number generator will give more useful results
 * than `random()` if you do not want the numbers to be evenly distributed.
 *
 * @param stdev {Number} The amount of variance from the mean, where about
 *   68% of the numbers will be a number +/- this amount.
 * @param [mean=0] {Number} The mean around which random numbers will be
 *   generated.
 * @param [negative=false] {Boolean} should the returned value be negative?
 *   This is false by default and will only return positive numbers
 *
 * @return a random number
 */
export function gaussian(stdev, mean = 0, negative = false) {
  let x = 0,
    y = 0,
    rds,
    c;
  // Uses Box-Muller transform: https://en.wikipedia.org/wiki/Box%E2%80%93Muller_transform
  // Two values get generated. You could cache the y value, but the time savings
  // is trivial and this causes issues when mocking randomness for the tests. So don't.
  do {
    x = seed.random() * 2 - 1;
    y = seed.random() * 2 - 1;
    rds = x * x + y * y;
  } while (rds === 0 || rds > 1);
  c = Math.sqrt((-2 * Math.log(rds)) / rds);
  const result = Math.round(x * c * stdev) + mean;
  return negative ? result : Math.abs(result);
}

class SomeTable {
  constructor(key, config) {
    this.rollStr = key.split(":")[1];
    if (Array.isArray(config)) {
      this.table = new RarityTable();
      config.forEach((token) => this.table.add("common", token));
      //this.array = config.map((token) => ({ token, per: 100 }));
    } else if (Object.values(config).every((v) => FREQ[v])) {
      this.table = new RarityTable();
      for (const [token, rarity] of Object.entries(config)) {
        this.table.add(RARITIES[rarity], token);
      }
    } else {
      this.array = Object.entries(config).map((e) => ({ token: e[0], per: e[1] }));
    }
  }
  add(selected) {
    const num = roll(this.rollStr);
    for (let i = 0; i < num; i++) {
      if (this.array) {
        let oneRoll = roll(100);
        for (let j = 0; j < this.array.length; j++) {
          let e = this.array[j];
          if (e.per >= oneRoll) {
            addKeys(selected, e.token);
            break;
          }
          oneRoll -= e.per;
        }
      } else {
        addKeys(selected, this.table.get());
      }
    }
  }
}

class AnyTable {
  constructor(config) {
    this.array = [];
    if (Array.isArray(config)) {
      config.forEach((el) => this.array.push({ object: el, frequency: 100 }));
    } else {
      for (const [object, frequency] of Object.entries(config)) {
        this.array.push({ frequency, object });
      }
    }
  }
  add(selected) {
    for (const entry of this.array) {
      if (entry.frequency >= roll(100)) {
        addKeys(selected, entry.object);
      }
    }
  }
}

class AllTable {
  constructor(config) {
    if (Array.isArray(config)) {
      this.array = config;
    } else {
      this.array = Object.keys(config);
    }
  }
  add(selected) {
    this.array.forEach((element) => addKeys(selected, element));
  }
}

/**
 * This method takes a JSON description of how to select elements in a pseudo-
 * random fashion. It returns an array of selected tokens.
 *
 * **tokens.** A token may consist of the string to be returned, plus a cardinality,
 * in the form "[cardinality]:[string]". The cardinality can be a number ("4") or a
 * dice notation ("2d6"). This is how many times the token will appear in the output
 * array, _if it is selected._ This happens any time we say the token is “added” to
 * the array below.
 *
 * **rules.** There are three: some, any, and all. If an object is passed to the
 * method, these are the property names of the object. In order to repeat rules in
 * this object, they can be postfixed with any value such as all-1” and “all-2”.
 * Since each rule is a property, it is associated to a property value descrining the
 * way to select elements from that value:
 *
 * - **some** selects some of the tokens from the array or object. The exact number is
 * indicated by the rule, for example “some:2d3" or “some:2”. If it’s an array, tokens
 * are randomly selected. If it's an object, the weighted selection is based on the
 * object as described below. Percentages must add up to 100%.
 *
 * - **one** synonymous with “some:1”
 *
 * - **any** selects any of the tokens that match against a percentage test from an
 * array or object. If it’s an array, this is functionally equivalent to “some:1”. If
 * it’s an object, the items are tested in order against a random percentage and those
 * that match are added to the output array. If the table uses rarity values, these are
 * tested in order, a similar fashion, and added to the array. Percentages do _not_
 * have to add up to 100%.
 *
 * - **all** add all tokens from an array to the output stream. If an object is
 * associated to the rule, all the values are added and percentage chance or rarity are
 * ignored.
 *
 * **Object tables.** The keys of the object are tokens and the values are percentage
 * values (of type number) or rarity values ("C", "U", "R"). The rule will select
 * tokens from the object keys based on the values of the object (in effect this object
 * forms a weighted table similar to the Table and RarityTable classes).
 *
 * @example
 * "3:Stall"
 * => ["Stall", "Stall", "Stall"]
 *
 * [ "A", "2:B", "1d3:C" ]
 * => ["A", "B", "B", "C"]
 * => ["A", "B", "B", "C", "C", "C"]
 *
 * // "&" can be used to group tokens
 * { "some:1": { "A & B": 50, "C": 50 } }
 * => ["A", "B"]
 * => ["C"]
 *
 * // same result; the postfix allows properties under the same key
 * { "some-2:1": { "D": 50, "E": 50 } }
 * => ["E"]
 *
 * { "some:1": ["A", "B"] }
 * => ["B"]
 *
 * { "some:1": [ "A", "2:B", "1d3:C" ] }
 * => ["A"]
 * => ["B", "B"]
 * => ["C", "C", "C"]
 *
 * // 1d2 elements, each added by percentage weight. must add up to 100%
 * { "some:1d2": { "A": 20, "B": 20, "C": 60 } }
 * => ["A"]
 * => ["C", "B"]
 * => ["C", "C"]
 *
 * // common, uncommon, and rare
 * { "some:1": { "A": "C", "B": "U", "C": "R"} }
 * => ["A"]
 *
 * { "some:1d2": { "A": "C", "B": "U" } }
 * => ["A"]
 * => ["B", "A"]
 * => ["B", "B"]
 *
 * // all does *not* have to add up to 100%
 * { "all": { "A": 20, "2d3:B": 75 } }
 * => []
 * => ["A"]
 * => ["A", "B", "B"]
 * => ["B", "B", "B", "B", "B", "B"]
 *
 * // four times
 * { "all": ["4:A"] }
 * => ["A", "A", "A", "A"]
 *
 * { "all": ["1d3:D", "1d3:E", "1d3:F"] }
 * => ["D","D","D","E","F","F","F"]
 */
export function selectElements(config) {
  const selected = [];
  if (typeof config === "string") {
    addKeys(selected, config);
  } else if (Array.isArray(config)) {
    config.forEach((key) => addKeys(selected, key));
  } else {
    for (const [keys, table] of Object.entries(config)) {
      if (keys.startsWith("some")) {
        new SomeTable(keys, table).add(selected);
      } else if (keys.startsWith("one")) {
        new SomeTable("some:1", table).add(selected);
      } else if (keys.startsWith("any")) {
        new AnyTable(table).add(selected);
      } else if (keys.startsWith("all")) {
        new AllTable(table).add(selected);
      }
    }
  }
  return selected;
}

// A key can have a location ID, or a die expression and an ID, separated
// with a ":". If there's a die roll, we use it to add the ID that number of
// times to the array, allowing for repeated elements.
function addKeys(selected, key) {
  let rollStr = "1";
  if (hasColonOutsideParens(key)) {
    [rollStr, key] = splitOn(key);
  }
  for (let i = 0, len = roll(rollStr); i < len; i++) {
    key.split("&").forEach((child) => selected.push(child.trim()));
  }
}

/**
 * Extracts all possible element keys from a selection tree structure.
 *
 * @param {Object|Array|string} tree - The selection tree to extract keys from
 * @returns {String[]} Array of all possible element keys that could be selected
 * @example
 * selectElementsKeys(["apple", "banana", "cherry"])
 * => ["apple", "banana", "cherry"]
 *
 * selectElementsKeys({ fruits: ["2d4:apple", "orange&pear"] })
 * => ["apple", "orange", "pear"]
 */
export function selectElementsKeys(tree) {
  let selected = [];
  walkSelectionTree(tree, (key) => {
    if (key.includes(":")) {
      key = key.split(":")[1];
    }
    addKeys(selected, key);
  });
  return Array.from(new Set(selected));
}

function walkSelectionTree(node, visitor) {
  if (Array.isArray(node)) {
    node.forEach(visitor);
  } else if (node instanceof Object) {
    for (const [key, child] of Object.entries(node)) {
      if (Array.isArray(child)) {
        child.forEach(visitor);
      } else {
        Object.keys(child).forEach(visitor);
      }
    }
  } else {
    visitor(node);
  }
}