create_bag.js

import { roll, test } from "./random_utils.js";
import { createItem } from "./create_item.js";
import { logger } from "./utils.js";
import Bag from "./models/bag.js";

// Uses the existing API to get the right frequency of objects, but can lead to many duplicates.
// of all the fill* methods, this is the only one that respects all the createBag() options,
// the rest are specific to creating a believable kit.

function fill(bag, opts) {
  let bagValue = opts.totalValue;

  let escape = 0;

  while (bagValue > 0.99) {
    escape++;
    if (escape === 1000) {
      break;
    }
    // Take the the max value or the bag value, unless they are less than the min value,
    // then take the min value. For that reason, the item's value has to be tested below
    // to verify it is less than the remaining bag value (if it's not, just quit and return
    // the bag).
    opts.maxValue = Math.max(Math.min(opts.maxValue, bagValue), opts.minValue);

    // Because maxValue changes every query, we can't cache any of this.
    const item = createItem(opts);

    if (item === null || item.value > bagValue) {
      return bag;
    }
    bag.add(item);
    if (bag.totalEnc() > opts.totalEnc) {
      bag.remove(item);
      break;
    }
    bagValue -= item.value;
  }
  return bag;
}
function getClusterSpread(value) {
  switch (value) {
    case "low":
      return roll("2d3+5"); // 7-11
    case "medium":
      return roll("2d3+2"); // 4-8
    case "high":
      return roll("1d3+2"); // 3-5
  }
}
function getClusterCount(value) {
  switch (value) {
    case "low":
      return roll("1d3*2"); // 2-6
    case "medium":
      return roll("(1d3*2)+2"); // 3-8
    case "high":
      return roll("(1d3*2)+4"); // 6-10
  }
}

function fillCurrency(bag, amount) {
  while (amount > 0) {
    const currency = createItem({ tags: "currency", maxValue: amount });
    if (currency === null) {
      return;
    }
    bag.add(currency);
    amount -= currency.value;
  }
}
function kitUniques(bag, count, tags) {
  let set = new Set();
  for (let i = 0; i < count; i++) {
    let item = createItem({ tags });
    if (item) {
      set.add(item);
    }
  }
  setToBag(bag, set);
}
function setToBag(bag, set) {
  Array.from(set).forEach((item) => bag.add(item));
  return bag;
}

/**
 * Generate a collection of items.
 *
 * @example
 * let bag = createBag({ totalValue: 500, minValue: 10, tags: 'firearm'});
 * bag.toString()
 * => "2 Browning Automatic Rifles, a M14 Rifle...and a pulse rifle."
 *
 * @param [params] {Object}
 *      @param [params.tags] {String} One or more query tags specifying the items in the bag
 *      @param [params.minValue] {Number}
 *      @param [params.maxValue] {Number}
 *      @param [params.minEnc] {Number}
 *      @param [params.maxEnc] {Number}
 *      @param [params.totalValue=20] {Number} The total value of the bag
 *      @param [params.fillBag=true] {Boolean} Should the bag's value be filled with
 *      currency if it hasn't been filled any other way? Usually currency has a value of
 *      1 or less, and can cover a gap otherwise caused by search criteria, but this
 *      isn't always desirable.
 */
export function createBag({
  tags = "*",
  totalValue = 20,
  fillBag = true,
  minValue = 0,
  maxValue = Number.MAX_VALUE,
  minEnc = 0,
  maxEnc = Number.MAX_VALUE,
  totalEnc = Number.MAX_VALUE,
} = {}) {
  logger.start("createBag", { tags, totalValue, minValue, maxValue, minEnc, maxEnc, totalEnc });
  if (maxValue <= 0 || maxEnc <= 0 || minValue > maxValue || minEnc > maxEnc) {
    throw new Error(
      "Conditions cannot match any taggable: " +
        JSON.stringify({ minValue, maxValue, minEnc, maxEnc })
    );
  }
  if (typeof totalValue <= 0) {
    throw new Error("Bag value must be more than 0");
  }
  let bag = fill(new Bag(), { tags, totalValue, minValue, maxValue, minEnc, maxEnc, totalEnc });
  if (fillBag !== false) {
    fillCurrency(bag, totalValue - bag.value());
  }
  return logger.end(bag);
}

/**
 * Like creating a bag but with many more repeated items (purposefully repeated, not
 * accidentally repeated), as if collected for a cache, shop, or storeroom. Honors the
 * `totalValue` limit (in fact will usually fall short of it), but `fillBag` will always be
 * treated as false.
 *
 * @param [params] {Object}
 *   @param [params.tags] {String} One or more query tags specifying the items in the bag
 *   @param [params.cluster="medium"] {String} "low", "medium" or "high". Alters the
 *     amount of stockpiling from a little to a lot.
 *   @param [params.minValue] {Number}
 *   @param [params.maxValue] {Number}
 *   @param [params.minEnc] {Number}
 *   @param [params.maxEnc] {Number}
 *   @param [params.totalValue=20] {Number} The total value of the stockpile. In practice,
 *     the stockpile will be worth less than this. Must be at least 20.
 *
 * @returns {Bag} a bag representing a stockpile
 */
export function createStockpile({
  tags = "*",
  cluster = "medium",
  fillBag = false,
  totalValue = 20,
  minValue = 0,
  maxValue = Number.MAX_VALUE,
  minEnc = 0,
  maxEnc = Number.MAX_VALUE,
} = {}) {
  logger.start("createStockpile", {
    tags,
    cluster,
    fillBag,
    totalValue,
    minValue,
    maxValue,
    minEnc,
    maxEnc,
  });
  if (cluster === "none") {
    let bag = createBag({ tags, totalValue, fillBag, minValue, maxValue, minEnc, maxEnc });
    return logger.end(bag);
  }
  let bag = new Bag();
  let count = getClusterSpread(cluster);

  kitUniques(bag, count, "-currency " + tags);

  bag.entries.forEach(function (entry) {
    count = getClusterCount(cluster);
    bag.add(entry.item, count);
  });
  return logger.end(bag);
}

/**
 * A simplified bag creation algorithm. Create a bag by iteratively adding items until
 * constraints are met.
 *
 * @param [params] {Object}
 *   @param [params.value=20] {Number | String} the total value of items to add to the bag.
 *     The method will get as close to this value as possible. If the value is a string,
 *     it is treated as dice notation.
 *   @param [params.repetition=20] {Number} affects how many times items can repeat.
 *     This is the percentage chance that the last item selected will be added again, unless
 *     it would exceed the value or encumbrance limits.
 *   @param [params.enc=Number.MAX_VALUE] {Number} the maximum encumbrance of the bag.
 *     The method will get as close to this value as possible, but it's not guaranteed.
 *   @param [params.uncountable=false] {Boolean} if true, items in the bag are treated as
 *     uncountable.
 *   @param [params.tags="*"] {String} tags to search for items to put in the bag.
 *
 * @returns {Bag} a bag containing items that meet the specified criteria
 */
export function newCreateBag({
  value = 20,
  repetition = 20,
  enc = Number.MAX_VALUE,
  uncountable = false,
  tags = "*",
} = {}) {
  if (typeof value === "string") {
    value = roll(value);
  }
  logger.start("newCreateBag", { tags, value, repetition, enc, uncountable });

  let bag = new Bag({ uncountable });
  let currentValue = value;
  let currentEnc = enc;
  let lastItem = null;
  let loops = 0;
  while (currentValue > 0 && currentEnc > 0 && loops++ < 50) {
    let item =
      test(repetition) && lastItem !== null
        ? lastItem
        : createItem({ tags, maxEnc: currentEnc, maxValue: currentValue });
    if (item === null) {
      break;
    }
    // stores (uncountable) don't sell things of no value...it's a weird edge case
    if (item.value === 0 && uncountable) {
      continue;
    }
    lastItem = item;
    bag.add(item);
    currentValue -= item.value;
    currentEnc -= item.enc;
  }
  return logger.end(bag);
}