random_utils.js

/** @module */
import { seed } from "./seed.js";
import { is } from "./utils.js";

const DIE_PARSER = /\d+d\d+/g;
const START_DATE = 1571011491488;
const RANDOM_STRING = /\{[^\{\}]*\|[^\{\}]*\}/g;

/**
 * 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 (var 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. For example, you might wish to create a youth gang
 * where the members are mostly 18, but with outliers that are much
 * younger or older. 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.
 * @return a random number
 */
export function gaussian(stdev, mean = 0) {
  var x = 0,
    y = 0,
    rds,
    c;
  // Uses Box-Muller transform: http://www.protonfish.com/jslib/boxmuller.shtml
  // 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);
  return Math.round(x * c * stdev) + mean;
}

/**
 * As the gaussian random number method, but it will not return negative
 * numbers (without disturbing the rest of the distribution).
 *
 * @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.
 * @returns a random, non-negative number (can include zero)
 */
export function nonNegativeGaussian(stdev, mean) {
  mean = mean < 0 ? 0 : mean;
  var value;
  do {
    value = gaussian(stdev, mean);
  } while (value < 0);
  return value;
}

/**
 * Randomly shuffle the position of the elements in an array (uses Fisher-Yates shuffle). `random()`
 * is usually more efficient, but if you need to iterate through a set of values in a random order,
 * without traversing the same element more than once, `shuffle()` is a better way to randomize
 * your data.
 * @example
 *     var array = ['A','B','C']
 *     atomic.shuffle(array);
 *     =>
 *     array;
 *     => ['C','A','B']
 *
 * @static
 * @method shuffle
 *
 * @param array {Array} The array to shuffle (in place)
 */
export function shuffle(array) {
  var j, temp;
  for (var i = array.length - 1; i > 0; i--) {
    j = ~~(Math.random() * (i + 1));
    temp = array[i];
    array[i] = array[j];
    array[j] = temp;
  }
}

/**
 * It’s common to want to select elements according to probabalities
 * and alternatives. Given the following format, this will return a list
 * of selected strings (which must map to whatever you are working on):
 *
 * {
 *   "oneOf": {
 *     "A & B": 50, // if selected, returns ["A","B"]
 *     "C": 50, // these values should add up to 100, if they don't
 *              // selecting nothing fills the remaining percentage
 *   },
 *   "oneOf:2": { // use anything after "oneOf" to create unique key
 *     A: 50,
 *     B: 50,
 *   },
 *   "allOf": { // all noces that pas d100 check are added (100 = always)
 *     A: 20,
 *     B: 75,
 *   },
 *   // this is 1d2 of the child nodes
 *   "someOf:1d2": ["2d3:Cash Register"], // if selected, add 2d3 nodes
 * };
 *
 *
 * @param {Object} tree
 * @returns {Array} a list of selected elements
 */
export function selectElements(tree) {
  let selected = [];
  walkTree(selected, tree);
  return selected;
}

// we're not walking a tree here, it hasn't been needed so far
function walkTree(selected, node) {
  if (typeof node === "string") {
    addKeys(selected, node);
  } else if (Array.isArray(node)) {
    node.forEach((child) => addKeys(selected, child));
  } else if (node instanceof Object) {
    for (const [key, child] of Object.entries(node)) {
      if (key.startsWith("oneOf")) {
        if (Array.isArray(child)) {
          addKeys(selected, random(child));
        } else {
          var dieRoll = roll("1d100");
          let chance = 0;
          for (const [orString, orChance] of Object.entries(child)) {
            chance += orChance;
            if (chance > dieRoll) {
              addKeys(selected, orString);
              break;
            }
          }
        }
      } else if (key.startsWith("allOf")) {
        if (Array.isArray(child)) {
          child.forEach((newChild) => addKeys(selected, newChild));
        } else {
          for (const [childValue, childChance] of Object.entries(child)) {
            if (test(childChance)) {
              addKeys(selected, childValue);
            }
          }
        }
      } else if (key.startsWith("someOf")) {
        if (!Array.isArray(child)) {
          throw new Error("someOf: value must be an array");
        }
        var dieRoll = key.split(":")[1];
        var set = new Set();
        var count = roll(dieRoll);
        if (count > child.length) {
          count = child.length;
        }
        while (count > 0) {
          let oneChild = randomElement(child);
          if (!set.has(oneChild)) {
            count--;
            set.add(oneChild);
            addKeys(selected, oneChild);
          }
        }
      }
    }
  }
}

// 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) {
  var rollStr = "1";
  if (key.includes(":")) {
    [rollStr, key] = key.split(":");
  }
  let count = roll(rollStr);
  for (let i = 0; i < count; i++) {
    key.split("&").forEach((child) => selected.push(child.trim()));
  }
}

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

export function getAllSelectElementKeys(tree) {
  let selected = [];
  walkSelectionTree(tree, (key) => {
    if (key.includes(":")) {
      key = key.split(":")[1];
    }
    addKeys(selected, key);
  });
  return selected;
}