create_bag.js

import { nonNegativeGaussian, roll, random } from "./random_utils.js";
import { createItem } from "./create_item.js";
import { format, pluralize, titleCase, toList } from "./string_utils.js";
import { logger } from "./utils.js";
import Bag from "./models/bag.js";

class MagazineRack extends Bag {
  constructor(bag) {
    super(bag);
    this.type = "MagazineRack";
  }
  toString() {
    return (
      "A rack for magazines and comic books: " +
      toList(
        this.entries,
        (entry) => {
          let item = entry.item;
          let str = "";
          let kind = item.has("comic") ? " comic book" : " magazine";
          let copies = entry.count > 1 ? ` (${entry.count} copies)` : "";
          if (Object.keys(item.series).length > 0) {
            const pub = item.series.pubDate ? `, ${item.series.pubDate}` : "";
            str += `<i>${item.series.title}</i>${kind}${pub}, ${item.series.issue}/${item.series.total}${copies}`;
          } else if (item.title) {
            str += `<i>${item.title}</i>${kind}${copies}`;
          } else {
            str += pluralize(item, entry.count);
          }
          return str;
        },
        "and",
        "; "
      ) +
      "."
    );
  }
}

class NewspaperBox extends Bag {
  constructor(bag) {
    super(bag);
    this.type = "NewspaperBox";
  }
  toString() {
    return (
      "A newspaper box: " +
      toList(
        this.entries,
        (entry) => {
          return pluralize(entry.item, entry.count);
        },
        "and",
        "; "
      ) +
      "."
    );
  }
}

const containers = {
  "Safe": {
    desc: [
      "Safecracking+1",
      "Safecracking",
      "Safecracking-1",
      "Safecracking-2",
      "Safecracking-3",
      "Safecracking-4",
    ],
    query: {
      totalValue: "3d8+15",
      tags: "cash | firearm -scifi | secured | luxury -food -alcohol",
      maxEnc: 10,
      fillBag: false,
    },
  },
  "Lockbox": {
    desc: ["Unlocked", "Lockpicking", "Lockpicking-1", "Lockpicking-2"],
    query: {
      totalValue: "2d6+2",
      tags: "cash | secured | firearm -scifi | asetofkeys | luxury -food",
      maxEnc: 3,
      fillBag: true,
    },
  },
  "Trunk": {
    desc: [
      "Unlocked",
      "Lockpicking+2, can be broken open",
      "Lockpicking+3, can be broken open",
      "Lockpicking+4, can be broken open",
    ],
    query: {
      totalValue: "2d6",
      tags: "clothing | armor | firearm -scifi",
      maxEnc: 10,
      fillBag: false,
    },
  },
  "Cash Register": {
    desc: ["easily opened"],
    query: {
      totalValue: "3d20/100",
      tags: "cash",
      fillBag: true,
    },
  },
  "Cash On Hand": {
    desc: ["under the counter", "in a lockbox"],
    query: {
      totalValue: "3d6",
      tags: "currency -casino",
      fillBag: false,
      maxValue: 7,
    },
  },
  "Clothes Closet": {
    desc: ["no lock"],
    query: {
      totalValue: "1d8+5",
      tags: "clothing -research -industrial -military",
      fillBag: false,
    },
  },
  "Magazine Rack": {
    query: {
      totalValue: "2d6",
      totalEnc: 3,
      tags: "comic | magazine",
      fillBag: false,
    },
    subclass: MagazineRack,
  },
  "Newspaper Box": {
    query: {
      totalValue: "1d3",
      tags: "newspaper",
      fillBag: false,
    },
    subclass: NewspaperBox,
  },
  "Reception Desk": {
    query: {
      totalValue: "1d8-1",
      tags: "(luxury -food | office -clothing -armor -firearm) & (negligible | tiny | hand)",
      fillBag: false,
    },
  },
  "Office Desk": {
    query: {
      totalValue: "2d6",
      tags: "(luxury -food | office | manuscript) & (negligible | tiny | hand | small) -comic",
      fillBag: false,
    },
  },
  "Tool Rack": {
    query: {
      totalValue: "1d6",
      tags: "tool -medical -agricultural",
      fillBag: false,
    },
  },
};

const containerTypes = Object.keys(containers).sort();

// 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.
    var item = createItem(opts);

    if (item === null || item.value > bagValue) {
      return bag;
    }
    bag.add(item);
    if (bag.totalEnc() > opts.totalEnc) {
      bag.remove(item);
      console.log("exceeded total encumbrance of", opts.totalEnc);
      break;
    }
    bagValue -= item.value;
  }
  console.log("bag enc:", bag.totalEnc(), "bag value:", bag.totalValue());
  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) {
    var 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 (var 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);
}

/**
 * Create a bag with additional properties (representing a container of some kind, like a
 * lockbox or safe).
 *
 * @param params {Object}
 *   @param [params.type] {String} the container type
 * @return {Bag} a bag representing a container
 */
export function createContainer({ type = random(containerTypes) } = {}) {
  logger.start("createContainer", { type });
  if (!getContainerTypes().includes(type)) {
    throw new Error('Invalid container type: "' + type + '"');
  }
  let container = containers[type];
  let totalValue = roll(container.query.totalValue);
  let bagParams = { ...container.query, totalValue };

  let bag = createBag(bagParams);
  if (container.desc) {
    bag.descriptor = format("{0} ({1})", titleCase(type), random(container.desc));
  } else {
    bag.descriptor = titleCase(type);
  }
  if (container.subclass) {
    bag = new container.subclass(bag);
  }
  return logger.end(bag);
}

/**
 * Get container types. One of these values is a valid type to pass to the
 * `createContainer(type)` method.
 *
 * @return {Array} an array of container types
 */
export function getContainerTypes() {
  return containerTypes;
}

/**
 * A simplified bag creation algorithm, currently being tried out with locations at the
 * kibble layer.
 *
 * @param [params] {Object}
 *   @param [params.value] {Number | String} the total value of the bag. The method will
 *      get as close to this value as possible, but it’s not guaranteed. If the value is
 *      a string, it is treated as a dice notation.
 *   @param [params.count] {Number} the maximum number of items of the same type that
 *      are normally found in this type of bag (defaut: 1). Rarely, the number will be
 *      higher than this (Gaussian distribution). Rarity still applies to the items selected.
 *   @param [params.enc] {Number} the total encumbrance of the bag. The method will get as
 *      close to this value as possible, but it’s not guaranteed. By default there is no limit
 *      to the encumbrance.
 *   @param [params.tags] {String} tags to search for items to put in the bag (default “*“).
 *
 * @returns a bag that meets the above criteria.
 */
// TODO: The issue with this approach is that where the tags match many different items, you
// get many different items, and when it doesn't, you don't. You want to balacke this, somehow.
export function newCreateBag({ value = 20, count = 1, enc = Number.MAX_VALUE, tags = "*" } = {}) {
  if (typeof value === "string") {
    value = roll(value);
  }
  logger.start("newCreateBag", { tags, value, count, enc });

  let bag = new Bag();
  let map = new Map();
  let loop = 0;

  while (value > 0 && loop < 50) {
    loop++;
    let item = createItem({ tags, maxEnc: enc });
    if (item === null) {
      return logger.end(bag);
    }

    let limit = map.get(item.name) || nonNegativeGaussian(count) || 1;
    map.set(item.name, limit);
    if (bag.count(item) < limit && bag.totalEnc() + item.enc < enc) {
      bag.add(item);
      value -= item.value;
    }
  }
  return logger.end(bag);
}