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);
}