/**
* @module RandomSelection
*/
import { FREQ } from "./constants.js";
import { hasColonOutsideParens, splitOn } from "./string_utils.js";
import { roll } from "./random_utils.js";
import RarityTable from "./tables/rarity_table.js";
const RARITIES = {
C: "common",
U: "uncommon",
R: "rare",
};
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));
} 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));
}
}
// 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);
}
let addedKey = false;
for (let i = 0, len = roll(rollStr); i < len; i++) {
let children = key.split("&").map((el) => el.trim());
for (let j = 0; j < children.length; j++) {
let child = children[j];
addedKey = true;
selected.push(child);
}
}
return addedKey;
}
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);
}
}
/**
* 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;
}
/**
* 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));
}