code:
/* eslint-disable no-unused-vars */
/*
 * Height.mean(nationality, race, genes, age) - returns the mean height for the given combination and age in years (>=2)
 * Height.mean(nationality, race, genes) - returns the mean adult height for the given combination
 * Height.mean(slave) - returns the mean (expected) height for the given slave
 *
 * Height.random(nationality, race, genes, age) - returns a random height using the skew-normal distribution
 *													around the mean height for the given arguments
 * Height.random(nationality, race, genes) - returns a random height for the given combination of an adult, as above
 * Height.random(slave[, options]) - returns a random height for the given slave, as above.
 *										The additional options object can modify how the values are generated
 *										in the same way setting them as global configuration would, but only for this
 *										specific generation.
 *
 *										Example: Only generate above-average heights based on $activeSlave:
 *										Height.random($activeSlave, {limitMult: [0, 5]})
 *
 * Height.forAge(height, age, genes) - returns the height adapted to the age and genes
 * Height.forAge(height, slave) - returns the height adapted to the slave's age and genes
 *
 * Height.config(configuration) - configures the random height generator globally and returns the current configuration
 *	The options and their default values are:
 *	limitMult: [-3, 3] - Limit to the values the underlying (normal) random generator returns.
 *						In normal use, the values are almost never reached; only 0.27% of values are
 *						outside this range and need to be regenerated. With higher skew (see below),
 *						this might change.
 *	spread: 0.05 - The random values generated are multiplied by this and added to 1 to generate
 *					the final height multiplier. The default value together with the default limitMult
 *					means that the generated height will always fall within (1 - 0.05 * 3) = 85% and
 *					(1 + 0.05 * 3) = 115% of the mean height.
 *					Minimum value: 0.001; maximum value: 0.5
 *	skew: 0 - How much the height distribution skews to the right (positive) or left (negative) side
 *				of the height.
 *				Minimum value: -1000, maximum value: 1000
 *	limitHeight: [0, 999] - Limit the resulting height range. Warning: A small height limit range
 *							paired with a high spread value results in the generator having to
 *							do lots of work generating and re-generating random heights until
 *							one "fits".
 *
 * Anon's explanation:
 * limitMult: [0, -30]
 *
 * This specifies a range going up from 0 to -30. It needs to go [-30, 0] instead. Same thing with [0, -5] two lines down. note: technically, this isn't true, because for some bizarre reason Height.random reverses the numbers for you if you get them wrong. But it's important to establish good habits, so.
 *
 * Skew, spread, limitMult: These are statistics things. BTW, a gaussian distribution is a normal distribution. Just a naming thing.
 *
 * Skew: The shape parameter of a skew-normal distribution. See http://azzalini.stat.unipd.it/SN/Intro/intro.html for more details. Basically a measure of how asymmetrical the curve is. It doesn't move the main mass of the distribution. Rather, it's more like it moves mass from one of the tails into the main mass of the distribution.
 *
 * Spread: Changes the average distance from the mean, making the graph wider and shorter. Moves "mass" from the center to the tail. It's basically standard deviation, but named funny because FC codebase. Changing this can have dramatic effects. It's advised to keep this at or below 0.1 for usual height generation.
 *
 * limitMult: A clamp, expressed in z-score. (z=1 is one standard dev above mean, for ex.) If it excludes too much of the distribution the other parameters describe, you're going to spend lots of CPU making and throwing away heights. Don't worry about this unless you run into it.
 *
 * There's also limitHeight which you're not using. It's basically limitMult in different units.
 */
window.Height = (function() {
	"use strict";

	// Global configuration (for different game modes/options/types)
	let minMult = -3.0;
	let maxMult = 3.0;
	let skew = 0.0;
	let spread = 0.05;
	let minHeight = 0;
	let maxHeight = 999;

	/**
	 * Configuration method for the above values
	 * @param {any} [conf]
	 * @returns {object}
	 */
	const _config = function(conf) {
		if (_.isUndefined(conf)) {
			return {
				limitMult: [minMult, maxMult],
				limitHeight: [minHeight, maxHeight],
				skew: skew,
				spread: spread
			};
		}
		if (_.isFinite(conf.skew)) {
			skew = Math.clamp(conf.skew, -1000, 1000);
		}
		if (_.isFinite(conf.spread)) {
			spread = Math.clamp(conf.spread, 0.001, 0.5);
		}
		if (_.isArray(conf.limitMult) && conf.limitMult.length === 2 && conf.limitMult[0] !== conf.limitMult[1] &&
			_.isFinite(conf.limitMult[0]) && _.isFinite(conf.limitMult[1])) {
			minMult = Math.min(conf.limitMult[0], conf.limitMult[1]);
			maxMult = Math.max(conf.limitMult[0], conf.limitMult[1]);
		}
		if (_.isArray(conf.limitHeight) && conf.limitHeight.length === 2 && conf.limitHeight[0] !== conf.limitHeight[1] &&
			_.isFinite(conf.limitHeight[0]) && _.isFinite(conf.limitHeight[1])) {
			minHeight = Math.min(conf.limitHeight[0], conf.limitHeight[1]);
			maxHeight = Math.max(conf.limitHeight[0], conf.limitHeight[1]);
		}
		return {
			limitMult: [minMult, maxMult],
			limitHeight: [minHeight, maxHeight],
			skew: skew,
			spread: spread
		};
	};

	/* if you can find an average for an undefined, add it in! */
	const xxMeanHeight = {
		"Afghan": 155.08,
		"Albanian": 161.77,
		"Algerian": 159.09,
		"American.asian": 158.4,
		"American.black": 163.6,
		"American.latina": 158.9,
		"American.white": 165,
		"American": 163.54,
		"Andorran": 162.90,
		"Angolan": 157.31,
		"Antiguan": 160.65,
		"Argentinian": 159.18,
		"Armenian": 158.09,
		"Aruban": 158,
		"Australian": 165.86,
		"Austrian": 164.62,
		"Azerbaijani": 158.25,
		"Bahamian": 160.68,
		"Bahraini": 156.69,
		"Bangladeshi": 150.79,
		"Barbadian": 165.28,
		"Belarusian": 166.35,
		"Belgian": 165.49,
		"Belizean": 156.88,
		"Beninese": 156.16,
		"Bermudian": 160.69,
		"Bhutanese": 153.63,
		"Bissau-Guinean": 158.24,
		"Bolivian": 153.89,
		"Bosnian": 165.85,
		"Brazilian": 160.86,
		"British": 164.40,
		"Bruneian": 153.98,
		"Bulgarian": 164.80,
		"Burkinabé": 160.19,
		"Burmese": 154.37,
		"Burundian": 154.02,
		"Cambodian": 152.91,
		"Cameroonian": 158.82,
		"Canadian": 163.91,
		"Cape Verdean": 161.65,
		"Catalan": 163.4,
		"Central African": 158.04,
		"Chadian": 160.17,
		"Chilean": 159.36,
		"Chinese": 159.71,
		"Colombian": 156.85,
		"Comorian": 155.58,
		"Congolese": 157.57,
		"a Cook Islander": 163.19,
		"Costa Rican": 156.37,
		"Croatian": 165.63,
		"Cuban": 157.98,
		"Curaçaoan": 158,
		"Cypriot": 162.27,
		"Czech": 168.46,
		"Danish": 167.21,
		"Djiboutian": 156.11,
		"Dominican": 159.03,
		"Dominiquais": 164.34,
		"Dutch": 168.72,
		"East Timorese": 151.15,
		"Ecuadorian": 154.23,
		"Egyptian": 157.33,
		"Emirati": 158.68,
		"Equatoguinean": 157.33,
		"Eritrean": 156.39,
		"Estonian": 168.67,
		"Ethiopian": 155.71,
		"Fijian": 161.69,
		"Filipina": 149.60,
		"Finnish": 165.90,
		"French Guianan": 157,
		"French Polynesian": 164.52,
		"French": 164.88,
		"Gabonese": 158.84,
		"Gambian": 160.93,
		"Georgian": 162.98,
		"German": 165.86,
		"Ghanan": 157.91,
		"Greek": 164.87,
		"Greenlandic": 161.55,
		"Grenadian": 164.51,
		"Guamanian": 153.7,
		"Guatemalan": 149.39,
		"Guinean": 157.80,
		"Guyanese": 157.92,
		"Haitian": 158.72,
		"Honduran": 153.84,
		"Hungarian": 163.66,
		"I-Kiribati": 157.00,
		"Icelandic": 165.95,
		"Indian": 152.59,
		"Indonesian": 152.80,
		"Iranian": 159.67,
		"Iraqi": 158.67,
		"Irish": 165.11,
		"Israeli": 161.80,
		"Italian": 164.61,
		"Ivorian": 158.07,
		"Jamaican": 163.12,
		"Japanese": 158.31,
		"Jordanian": 158.83,
		"Kazakh": 158.58,
		"Kenyan": 158.16,
		"Kittitian": 159.20,
		"Korean": 160.65,
		"Kosovan": 165.7,
		"Kurdish": 165,
		"Kuwaiti": 159.43,
		"Kyrgyz": 159.35,
		"Laotian": 151.28,
		"Latvian": 169.80,
		"Lebanese": 162.43,
		"Liberian": 157.3,
		"Libyan": 162.08,
		"a Liechtensteiner": 164.3,
		"Lithuanian": 166.61,
		"Luxembourgian": 164.43,
		"Macedonian": 159.75,
		"Malagasy": 151.18,
		"Malawian": 154.40,
		"Malaysian": 156.30,
		"Maldivian": 155.02,
		"Malian": 160.47,
		"Maltese": 160.85,
		"Marshallese": 151.31,
		"Mauritanian": 157.72,
		"Mauritian": 157.24,
		"Mexican": 156.85,
		"Micronesian": 156.09,
		"Moldovan": 163.24,
		"Monégasque": 164.61,
		"Mongolian": 158.22,
		"Montenegrin": 164.86,
		"Moroccan": 157.82,
		"Mosotho": 155.71,
		"Motswana": 161.38,
		"Mozambican": 153.96,
		"Namibian": 158.78,
		"Nauruan": 153.98,
		"Nepalese": 150.86,
		"New Caledonian": 158.0,
		"a New Zealander": 164.94,
		"Ni-Vanuatu": 158.17,
		"Nicaraguan": 154.39,
		"Nigerian": 156.32,
		"Nigerien": 158.25,
		"Niuean": 164.80,
		"Norwegian": 165.56,
		"Omani": 157.19,
		"Pakistani": 153.84,
		"Palauan": 156.22,
		"Palestinian": 158.75,
		"Panamanian": 155.47,
		"Papua New Guinean": 154.87,
		"Paraguayan": 159.86,
		"Peruvian": 152.93,
		"Polish": 164.59,
		"Portuguese": 163.04,
		"Puerto Rican": 159.20,
		"Qatari": 159.38,
		"Romanian": 162.73,
		"Russian": 165.27,
		"Rwandan": 154.79,
		"Sahrawi": 157.82,
		"Saint Lucian": 162.31,
		"Salvadoran": 154.55,
		"Sammarinese": 164.61,
		"Samoan": 161.97,
		"São Toméan": 158.91,
		"Saudi": 155.88,
		"Scottish": 163,
		"Senegalese": 162.52,
		"Serbian": 167.69,
		"Seychellois": 162.08,
		"Sierra Leonean": 156.60,
		"Singaporean": 160.32,
		"Slovak": 167.47,
		"Slovene": 166.05,
		"a Solomon Islander": 154.42,
		"Somali": 156.06,
		"South African": 158.03,
		"South Sudanese": 169.0,
		"Spanish": 163.40,
		"Sri Lankan": 154.56,
		"Sudanese": 156.04,
		"Surinamese": 160.66,
		"Swazi": 158.64,
		"Swedish": 165.70,
		"Swiss": 163.45,
		"Syrian": 158.65,
		"Taiwanese": 161.45,
		"Tajik": 157.33,
		"Tanzanian": 156.6,
		"Thai": 157.87,
		"Tibetan": 158.75,
		"Togolese": 158.30,
		"Tongan": 165.52,
		"Trinidadian": 160.64,
		"Tunisian": 160.35,
		"Turkish": 160.50,
		"Turkmen": 161.73,
		"Tuvaluan": 158.10,
		"Ugandan": 156.72,
		"Ukrainian": 166.34,
		"Uruguayan": 162.13,
		"Uzbek": 157.82,
		"Vatican": 162.5,
		"Venezuelan": 157.44,
		"Vietnamese": 153.59,
		"Vincentian": 160.70,
		"Yemeni": 153.97,
		"Zairian": 155.25,
		"Zambian": 155.82,
		"Zimbabwean": 158.22,
		"": 159.65, // default
	};
	const xyMeanHeight = {
		"Afghan": 165.26,
		"Albanian": 173.39,
		"Algerian": 170.07,
		"American.asian": 172.5,
		"American.black": 177.4,
		"American.latina": 172.5,
		"American.white": 178.2,
		"American": 177.13,
		"Andorran": 176.06,
		"Angolan": 167.31,
		"Antiguan": 164.8,
		"Argentinian": 174.62,
		"Armenian": 172.00,
		"Aruban": 165.1,
		"Australian": 179.20,
		"Austrian": 177.41,
		"Azerbaijani": 169.75,
		"Bahamian": 172.75,
		"Bahraini": 167.74,
		"Bangladeshi": 163.81,
		"Barbadian": 175.92,
		"Belarusian": 178.44,
		"Belgian": 181.70,
		"Belizean": 168.73,
		"Beninese": 167.06,
		"Bermudian": 172.69,
		"Bhutanese": 165.31,
		"Bissau-Guinean": 167.90,
		"Bolivian": 166.85,
		"Bosnian": 180.87,
		"Brazilian": 173.55,
		"British": 177.49,
		"Bruneian": 165.01,
		"Bulgarian": 178.24,
		"Burkinabé": 169.33,
		"Burmese": 164.67,
		"Burundian": 166.64,
		"Cambodian": 163.33,
		"Cameroonian": 167.82,
		"Canadian": 178.09,
		"Cape Verdean": 173.22,
		"Catalan": 175.8,
		"Central African": 166.67,
		"Chadian": 170.44,
		"Chilean": 171.81,
		"Chinese": 171.83,
		"Colombian": 169.50,
		"Comorian": 166.19,
		"Congolese": 167.45,
		"a Cook Islander": 174.77,
		"Costa Rican": 168.93,
		"Croatian": 180.78,
		"Cuban": 172.00,
		"Curaçaoan": 165.1,
		"Cypriot": 174.99,
		"Czech": 180.10,
		"Danish": 181.39,
		"Djiboutian": 166.57,
		"Dominican": 172.75,
		"Dominiquais": 176.31,
		"Dutch": 182.54,
		"East Timorese": 159.79,
		"Ecuadorian": 167.08,
		"Egyptian": 166.68,
		"Emirati": 170.46,
		"Equatoguinean": 167.36,
		"Eritrean": 168.36,
		"Estonian": 181.59,
		"Ethiopian": 166.23,
		"Fijian": 173.90,
		"Filipina": 163.23,
		"Finnish": 179.59,
		"French Guianan": 168,
		"French Polynesian": 177.41,
		"French": 179.74,
		"Gabonese": 167.94,
		"Gambian": 165.40,
		"Georgian": 174.34,
		"German": 179.88,
		"Ghanan": 168.85,
		"Greek": 177.32,
		"Greenlandic": 174.87,
		"Grenadian": 176.97,
		"Guamanian": 169.8,
		"Guatemalan": 163.41,
		"Guinean": 167.54,
		"Guyanese": 170.21,
		"Haitian": 172.64,
		"Honduran": 166.39,
		"Hungarian": 177.26,
		"I-Kiribati": 169.20,
		"Icelandic": 180.49,
		"Indian": 164.95,
		"Indonesian": 163.55,
		"Iranian": 170.3,
		"Iraqi": 170.43,
		"Irish": 178.93,
		"Israeli": 176.86,
		"Italian": 177.77,
		"Ivorian": 166.53,
		"Jamaican": 174.53,
		"Japanese": 170.82,
		"Jordanian": 171.03,
		"Kazakh": 171.14,
		"Kenyan": 169.64,
		"Kittitian": 169.62,
		"Korean": 173.46,
		"Kosovan": 179.5,
		"Kurdish": 175,
		"Kuwaiti": 172.07,
		"Kyrgyz": 171.24,
		"Laotian": 160.52,
		"Latvian": 181.42,
		"Lebanese": 174.39,
		"Liberian": 163.66,
		"Libyan": 173.53,
		"a Liechtensteiner": 175.4,
		"Lithuanian": 179.03,
		"Luxembourgian": 177.86,
		"Macedonian": 178.33,
		"Malagasy": 161.55,
		"Malawian": 166,
		"Malaysian": 167.89,
		"Maldivian": 167.68,
		"Malian": 171.3,
		"Maltese": 173.32,
		"Marshallese": 162.81,
		"Mauritanian": 163.28,
		"Mauritian": 170.50,
		"Mexican": 169.01,
		"Micronesian": 168.51,
		"Moldovan": 175.49,
		"Monégasque": 177.77,
		"Mongolian": 169.07,
		"Montenegrin": 178.28,
		"Moroccan": 170.40,
		"Mosotho": 165.59,
		"Motswana": 171.63,
		"Mozambican": 164.80,
		"Namibian": 166.96,
		"Nauruan": 167.83,
		"Nepalese": 162.32,
		"New Caledonian": 171.0,
		"a New Zealander": 177.74,
		"Ni-Vanuatu": 168.09,
		"Nicaraguan": 166.71,
		"Nigerian": 165.91,
		"Nigerien": 167.68,
		"Niuean": 175.83,
		"Norwegian": 179.75,
		"Omani": 169.16,
		"Pakistani": 166.95,
		"Palauan": 167.69,
		"Palestinian": 172.09,
		"Panamanian": 168.49,
		"Papua New Guinean": 163.57,
		"Paraguayan": 172.83,
		"Peruvian": 165.23,
		"Polish": 177.33,
		"Portuguese": 172.93,
		"Puerto Rican": 172.08,
		"Qatari": 170.48,
		"Romanian": 174.74,
		"Russian": 176.46,
		"Rwandan": 162.68,
		"Sahrawi": 170.40,
		"Saint Lucian": 171.95,
		"Salvadoran": 169.77,
		"Sammarinese": 177.77,
		"Samoan": 174.38,
		"São Toméan": 167.38,
		"Saudi": 167.67,
		"Scottish": 177.6,
		"Senegalese": 173.14,
		"Serbian": 180.57,
		"Seychellois": 174.21,
		"Sierra Leonean": 164.41,
		"Singaporean": 172.57,
		"Slovak": 179.50,
		"Slovene": 179.80,
		"a Solomon Islander": 164.14,
		"Somali": 166.60,
		"South African": 166.68,
		"South Sudanese": 175.9,
		"Spanish": 176.59,
		"Sri Lankan": 165.69,
		"Sudanese": 166.63,
		"Surinamese": 172.72,
		"Swazi": 168.13,
		"Swedish": 179.74,
		"Swiss": 178.42,
		"Syrian": 170.43,
		"Taiwanese": 174.52,
		"Tajik": 171.26,
		"Tanzanian": 164.80,
		"Thai": 169.16,
		"Tibetan": 168.91,
		"Togolese": 168.33,
		"Tongan": 176.76,
		"Trinidadian": 173.74,
		"Tunisian": 173.95,
		"Turkish": 174.21,
		"Turkmen": 171.97,
		"Tuvaluan": 169.64,
		"Ugandan": 165.62,
		"Ukrainian": 178.46,
		"Uruguayan": 173.43,
		"Uzbek": 169.38,
		"Vatican": 176.5,
		"Venezuelan": 171.59,
		"Vietnamese": 164.45,
		"Vincentian": 172.78,
		"Yemeni": 159.89,
		"Zairian": 166.80,
		"Zambian": 166.52,
		"Zimbabwean": 168.59,
		"": 171.42, // defaults
	};

	/**
	 * FIXME: JSdoc syntax error
	 * Helper method - table lookup for nationality/race combinations
	 * @param {object} table	// I think
	 * @param {string|Object} nationality
	 * @param {string} race
	 * @param {undefined} [def]
	 * @returns {object}	// I think
	 */
	const nationalityMeanHeight = function(table, nationality, race, def) {
		return table[`${nationality}.${race}`] || table[nationality] || table[`.${race}`] || table[""] || def;
	};

	/**
	 * Helper method: Generate a skewed normal random variable with the skew s
	 * Reference: http://azzalini.stat.unipd.it/SN/faq-r.html
	 * @param {number} s
	 * @returns {number}
	 */
	const skewedGaussian = function(s) {
		let randoms = gaussianPair();
		if (s === 0) {
			// Don't bother, return an unskewed normal distribution
			return randoms[0];
		}
		let delta = s / Math.sqrt(1 + s * s);
		let result = delta * randoms[0] + Math.sqrt(1 - delta * delta) * randoms[1];
		return randoms[0] >= 0 ? result : -result;
	};

	/**
	 * Height multiplier generator; skewed gaussian according to global parameters
	 * @returns {number}
	 */
	const multGenerator = function() {
		let result = skewedGaussian(skew);
		while (result < minMult || result > maxMult) {
			result = skewedGaussian(skew);
		}
		return result;
	};

	/**
	 * Helper method: Generate a height based on the mean one and limited according to config.
	 * @param {number} mean
	 * @returns {number}
	 */
	const heightGenerator = function(mean) {
		let result = mean * (1 + multGenerator() * spread);
		while (result < minHeight || result > maxHeight) {
			result = mean * (1 + multGenerator() * spread);
		}
		return Math.round(result);
	};

	/**
	 * Helper method - apply age and genes to the adult height
	 * @param {number} height
	 * @param {number} age
	 * @param {string} genes
	 * @returns {number}
	 */
	const applyAge = function(height, age, genes) {
		if (!_.isFinite(age) || age < 2 || age >= 20) {
			return height;
		}
		let minHeight = 0;
		let midHeight = 0;
		let midAge = 15;
		switch (genes) {
			case "XX": // female
			case "XXX": // Triple X syndrome female
				minHeight = 85;
				midHeight = height * 157 / 164;
				midAge = 13;
				break;
			case "XY": // male
			case "XXY": // Klinefelter syndrome male
			case "XYY": // XYY syndrome male
				minHeight = 86;
				midHeight = height * 170 / 178;
				midAge = 15;
				break;
			case "X0":
			case "X": // Turner syndrome female
				minHeight = 85 * 0.93;
				midHeight = height * 157 / 164;
				midAge = 13;
				break;
			default:
				minHeight = 85.5;
				midHeight = height * 327 / 342;
				midAge = 14;
				break;
		}
		if (age > midAge) {
			// end of puberty to 20
			return interpolate(midAge, midHeight, 20, height, age);
		} else {
			// 2 to end of puberty
			return interpolate(2, minHeight, midAge, midHeight, age);
		}
	};

	/**
	 * @param {string|{nationality: string, race: string, genes: string, physicalAge: number, birthWeek: number}} nationality
	 * @param {string} race
	 * @param {string} genes
	 * @param {number} age
	 * @returns {number}
	 */
	const _meanHeight = function(nationality, race, genes, age) {
		if (_.isObject(nationality)) {
			// We got called with a single slave as the argument
			return _meanHeight(nationality.nationality, nationality.race, nationality.genes, nationality.physicalAge + nationality.birthWeek / 52.0);
		}
		let result = 0;
		switch (genes) {
			case "XX": // female
				result = nationalityMeanHeight(xxMeanHeight, nationality, race);
				break;
			case "XY": // male
				result = nationalityMeanHeight(xyMeanHeight, nationality, race);
				break;
				// special cases. Extra SHOX genes on X and Y chromosomes make for larger people
			case "X0":
			case "X": // Turner syndrome female
				result = nationalityMeanHeight(xxMeanHeight, nationality, race) * 0.93;
				break;
			case "XXX": // Triple X syndrome female
				result = nationalityMeanHeight(xxMeanHeight, nationality, race) * 1.03;
				break;
			case "XXY": // Klinefelter syndrome male
				result = nationalityMeanHeight(xyMeanHeight, nationality, race) * 1.03;
				break;
			case "XYY": // XYY syndrome male
				result = nationalityMeanHeight(xyMeanHeight, nationality, race) * 1.04;
				break;
			case "Y":
			case "Y0":
			case "YY":
			case "YYY":
				// eslint-disable-next-line no-console
				console.log(`Height.mean(): non-viable genes ${genes}`);
				break;
			default:
				// eslint-disable-next-line no-console
				console.log(`Height.mean(): unknown genes ${genes}, returning mean between XX and XY`);
				result = nationalityMeanHeight(xxMeanHeight, nationality, race) * 0.5 + nationalityMeanHeight(xyMeanHeight, nationality, race) * 0.5;
				break;
		}
		return applyAge(result, age, genes);
	};

	/**
	 * @param {any} nationality
	 * @param {any} race
	 * @param {string} genes
	 * @param {number} age
	 * @returns {Function}	// I think
	 */
	const _randomHeight = function(nationality, race, genes, age) {
		const mean = _meanHeight(nationality, race, genes, age);
		// If we got called with a slave object and options, temporarily modify
		// our configuration.
		if (_.isObject(nationality) && _.isObject(race)) {
			const currentConfig = _config();
			_config(race);
			const result = heightGenerator(mean);
			_config(currentConfig);
			return result;
		}
		return heightGenerator(mean);
	};

	/**
	 * @param {number} height
	 * @param {number|{physicalAge:number, birthWeek:number, genes:string}} age
	 * @param {string} genes
	 * @returns {number}
	 */
	const _forAge = function(height, age, genes) {
		if (_.isObject(age)) {
			// We got called with a slave as a second argument
			return applyAge(height, age.physicalAge + age.birthWeek / 52.0, age.genes);
		} else {
			return applyAge(height, age, genes);
		}
	};

	return {
		mean: _meanHeight,
		random: _randomHeight,
		forAge: _forAge,
		config: _config,
	};
})();

/**
 *  Intelligence.random(options) - returns a random intelligence. If no options are passed, the generated number
 *									will be on a normal distribution with mean 0 and standard deviation 45.
 *
 *										Example: Only generate above-average intelligence based on $activeSlave:
 *										Intelligence.random({limitIntelligence: [0, 100]})
 *
 *  Intelligence.config(configuration) - configures the random height generator globally and returns the current configuration
 *
 *	The options and their default values are:
 *	mean: 0 - What the average intelligence will be. Increasing this will make it more likely
 *				to generate a smart slave, but will not guarantee it.
 *				Minimum value: -100, maximum value: 100
 *	limitMult: [-3, 3] - Limit to this many standard deviations from the mean.
 *						In normal use, the values are almost never reached; only 0.27% of values are
 *						outside this range and need to be regenerated. With higher skew (see below),
 *						this might change.
 *	spread: 45 - The random standard deviation of the calculated distribution. A higher value
 *				will make it more likely to have extreme values, a lower value will make any
 *				generated values cluster around the mean. If spread is 0, it will always return the mean.
 *	skew: 0 - How much the height distribution skews to the right (positive) or left (negative) side
 *				of the height. Unless you have a very specific reason, you should not need to change this.
 *				Minimum value: -1000, maximum value: 1000
 *	limitIntelligence: [-100,100] - Limit the resulting height range.
 *									Warning: A small intelligence limit range not containing the
 *									mean, and with a low spread value results in the generator
 *									having to do lots of work generating and re-generating random
 *									intelligences until one "fits".
 *
 *  This was modeled using the Height generator above. For some more information, see the comments for that.
 * @returns {{random: number, _config: Object}}
 */
window.Intelligence = (function() {
	"use strict";

	// Global configuration (for different game modes/options/types)
	let mean = 0;
	let minMult = -3.0;
	let maxMult = 3.0;
	let skew = 0.0;
	let spread = 45;
	let minIntelligence = -101;
	let maxIntelligence = 100;

	/**
	 * Configuration method for the above values
	 * @param {object} [conf]	// I'm pretty sure
	 * @returns {object}
	 */
	const _config = function(conf) {
		if (_.isUndefined(conf)) {
			return {
				mean: mean,
				limitMult: [minMult, maxMult],
				limitIntelligence: [minIntelligence, maxIntelligence],
				skew: skew,
				spread: spread
			};
		}
		if (_.isFinite(conf.mean)) {
			mean = Math.clamp(conf.mean, -100, 100);
		}
		if (_.isFinite(conf.skew)) {
			skew = Math.clamp(conf.skew, -1000, 1000);
		}
		if (_.isFinite(conf.spread)) {
			spread = Math.clamp(conf.spread, 0.1, 100);
		}
		if (_.isArray(conf.limitMult) && conf.limitMult.length === 2 && conf.limitMult[0] !== conf.limitMult[1] &&
			_.isFinite(conf.limitMult[0]) && _.isFinite(conf.limitMult[1])) {
			minMult = Math.min(conf.limitMult[0], conf.limitMult[1]);
			maxMult = Math.max(conf.limitMult[0], conf.limitMult[1]);
		}
		if (_.isArray(conf.limitIntelligence) && conf.limitIntelligence.length === 2 && conf.limitIntelligence[0] !== conf.limitIntelligence[1] &&
			_.isFinite(conf.limitIntelligence[0]) && _.isFinite(conf.limitIntelligence[1])) {
			minIntelligence = Math.clamp(Math.min(conf.limitIntelligence[0], conf.limitIntelligence[1]), -101, 100);
			maxIntelligence = Math.clamp(Math.max(conf.limitIntelligence[0], conf.limitIntelligence[1]), -101, 100);
		}
		return {
			limitMult: [minMult, maxMult],
			limitIntelligence: [minIntelligence, maxIntelligence],
			skew: skew,
			spread: spread
		};
	};

	/**
	 * Helper method: Generate a skewed normal random variable with the skew s
	 * Reference: http://azzalini.stat.unipd.it/SN/faq-r.html
	 * @param {number} s
	 * @returns {number}
	 */
	const skewedGaussian = function(s) {
		let randoms = gaussianPair();
		if (s === 0) {
			// Don't bother, return an unskewed normal distribution
			return randoms[0];
		}
		let delta = s / Math.sqrt(1 + s * s);
		let result = delta * randoms[0] + Math.sqrt(1 - delta * delta) * randoms[1];
		return randoms[0] >= 0 ? result : -result;
	};

	// Intelligence multiplier generator; skewed gaussian according to global parameters
	const multGenerator = function() {
		let result = skewedGaussian(skew);
		while (result < minMult || result > maxMult) {
			result = skewedGaussian(skew);
		}
		return result;
	};

	// Helper method: Transform the values from multGenerator to have the appropriate mean and standard deviation.
	const intelligenceGenerator = function() {
		let result = multGenerator() * spread + mean;
		while (result < minIntelligence || result > maxIntelligence) {
			result = multGenerator() * spread + mean;
		}
		return Math.ceil(result);
	};

	/**
	 * FIXME: JSdoc syntax error
	 * @param {boolean} settings	// no idea for this one
	 * @returns {object}
	 */
	const _randomIntelligence = function(settings) {
		if (settings) {
			const currentConfig = _config();
			_config(settings);
			const result = intelligenceGenerator();
			_config(currentConfig);
			return result;
		}
		return intelligenceGenerator();
	};

	return {
		random: _randomIntelligence,
		config: _config,
	};
})();

/**
 * Helper method - generate two independent Gaussian numbers using Box-Muller transform
 * @returns {number[]}
 */
window.gaussianPair = function() {
	let r = Math.sqrt(-2.0 * Math.log(1 - Math.random()));
	let sigma = 2.0 * Math.PI * (1 - Math.random());
	return [r * Math.cos(sigma), r * Math.sin(sigma)];
};

/*
A categorizer is used to "slice" a value range into distinct categories in an efficient manner.

If the values are objects their property named 'value' will be set to whatever
the value used for the choice was. This is important for getters, where it can be accessed
via this.value.

--- Example ---
Original SugarCube code
<<if _Slave.muscles > 95>>
	Musc++
<<elseif _Slave.muscles > 30>>
	Musc+
<<elseif _Slave.muscles > 5>>
	Toned
<<elseif _Slave.muscles > -6>>
<<elseif _Slave.muscles > -31>>
	<span class="red">weak</span>
<<elseif _Slave.muscles > -96>>
	<span class="red">weak+</span>
<<else>>
	<span class="red">weak++</span>
<</if>>

As a categorizer
<<if ndef $cats>><<set $cats = {}>><</if>>
<<if ndef $cats.muscleCat>>
	<!-- This only gets set once, skipping much of the code evaluation, and can be set outside of the code in an "init" passage for further optimization -->
	<<set $cats.muscleCat = new Categorizer([96, 'Musc++'], [31, 'Musc+'], [6, 'Toned'], [-5, ''], [-30, '<span class="red">weak</span>'], [-95, '<span class="red">weak+</span>'], [-Infinity, '<span class="red">weak++</span>'])>>
<</if>>
<<print $cats.muscleCat.cat(_Slave.muscles)>>
*/

window.Categorizer = function() {
	this.cats = Array.prototype.slice.call(arguments)
		.filter(function(e, i, a) {
			return e instanceof Array && e.length === 2 && typeof e[0] === "number" && !isNaN(e[0]) &&
				a.findIndex(function(val) {
					return e[0] === val[0];
				}) === i; /* uniqueness test */
		})
		.sort(function(a, b) {
			return b[0] - a[0]; /* reverse sort */
		});
};

window.Categorizer.prototype.cat = function(val, def) {
	let result = def;
	if (typeof val === "number" && !isNaN(val)) {
		let foundCat = this.cats.find(function(e) {
			return val >= e[0];
		});
		if (foundCat) {
			result = foundCat[1];
		}
	}
	// Record the value for the result's getter, if it is an object
	// and doesn't have the property yet
	if (result === Object(result)) {
		result.value = val;
	}
	return result;
};

/**
 * Returns numbers as text, e.g. 10 as "ten".
 * @param {number} x
 * @returns {string}
 */
window.num = function(x) {
	const V = State.variables;
	const max = V.showNumbersMax;

	const ONE_TO_NINETEEN = [
		"one", "two", "three", "four", "five",
		"six", "seven", "eight", "nine", "ten",
		"eleven", "twelve", "thirteen", "fourteen", "fifteen",
		"sixteen", "seventeen", "eighteen", "nineteen",
	];

	const TENS = [
		"ten", "twenty", "thirty", "forty", "fifty",
		"sixty", "seventy", "eighty", "ninety",
	];

	const SCALES = ["thousand", "million", "billion", "trillion"];

	/**
	 * helper function for use with Array.filter
	 * @param {any} item
	 * @returns {boolean}
	 */
	function isTruthy(item) {
		return !!item;
	}

	/**
	 * convert a number into "chunks" of 0-999
	 * @param {number} number
	 * @returns {number[]}
	 */
	function chunk(number) {
		const thousands = [];

		while (number > 0) {
			thousands.push(number % 1000);
			number = Math.floor(number / 1000);
		}

		return thousands;
	}

	/**
	 * translate a number from 1-999 into English
	 * @param {number} number
	 * @returns {string}
	 */
	function inEnglish(number) {
		let hundreds;
		let tens;
		let ones;
		const words = [];

		if (number < 20) {
			return ONE_TO_NINETEEN[number - 1]; // may be undefined
		}

		if (number < 100) {
			ones = number % 10;
			tens = number / 10 | 0; // equivalent to Math.floor(number / 10)

			words.push(TENS[tens - 1]);
			words.push(inEnglish(ones));

			return words.filter(isTruthy).join("-");
		}

		hundreds = number / 100 | 0;
		words.push(inEnglish(hundreds));
		words.push("hundred");
		words.push(inEnglish(number % 100));

		return words.filter(isTruthy).join(" ");
	}

	/**
	 * append the word for a scale. Made for use with Array.map
	 * @param {string} chunk
	 * @param {number} exp
	 * @returns {string}
	 */
	function appendScale(chunk, exp) {
		let scale;
		if (!chunk) {
			return null;
		}
		scale = SCALES[exp - 1];
		return [chunk, scale].filter(isTruthy).join(" ");
	}

	/**
	 * @param {number} s
	 * @returns {string}
	 */
	function commaNum(s) {
		if (!s) {
			return "0";
		}
		if (State.variables.formatNumbers !== 1) {
			return s.toString();
		} else {
			return s.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
		}
	}

	if (V.showNumbers !== 2) {
		if (x === 0) {
			return "zero";
		}

		let numberAsString = chunk(x)
			.map(inEnglish)
			.map(appendScale)
			.filter(isTruthy)
			.reverse()
			.join(" ");

		if (V.showNumbers === 1) {
			if (x <= max) {
				if (x > 0) {
					return numberAsString;
				} else {
					return `negative ${numberAsString}`;
				}
			} else {
				return commaNum(x);
			}
		} else {
			if (x > 0) {
				return numberAsString;
			} else {
				return `negative ${numberAsString}`;
			}
		}
	} else {
		return commaNum(x);
	}
};

/**
 * @param {number} s
 * @returns {string}
 */
window.cashFormat = function(s) {
	if (!s) {
		s = 0;
	} else {
		s = Math.trunc(s);
	}
	if (s > 0) {
		return `¤${num(s)}`;
	} else {
		return `¤${s.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
	}
};

/**
 * @param {number} s
 * @returns {string}
 */
window.repFormat = function(s) {
	const V = State.variables;
	/* if (!s) { s = 0; }*/
	if (V.cheatMode === 1 || V.debugMode === 1) {
		if (s > 0) {
			return `<span class="green">${num(Math.round(s * 100) / 100)} rep</span>`;
		} else if (s < 0) {
			return `<span class="red">${num(Math.round(s * 100) / 100)} rep</span>`;
		} else {
			return `${num(Math.round(s * 100) / 100)} rep`;
		}
	} else {
		/* In order to calculate just how much any one category matters so we can show a "fuzzy" symbolic value to the player, we need to know how "busy" reputation was this week. To calculate this, I ADD income to expenses. Why? 100 - 100 and 10000 - 10000 BOTH are 0, but a +50 event matters a lot more in the first case than the second. I exclude overflow from the calculation because it's not a "real" expense for our purposes, and divide by half just to make percentages a bit easier. */
		let weight = s / (((V.lastWeeksRepIncome.Total - V.lastWeeksRepExpenses.Total) + V.lastWeeksRepExpenses.overflow) / 2);
		if (weight > 0.60) {
			return `<span class="green">+++++ rep</span>`;
		} else if (weight > 0.45) {
			return `<span class="green">++++ rep</span>`;
		} else if (weight > 0.30) {
			return `<span class="green">+++ rep</span>`;
		} else if (weight > 0.15) {
			return `<span class="green">++ rep</span>`;
		} else if (weight > 0.0) {
			return `<span class="green">+ rep</span>`;
		} else if (weight === 0) {
			return "0 rep";
		} else if (weight < -0.60) {
			return `<span class="red">----- rep</span>`;
		} else if (weight < -0.45) {
			return `<span class="red">---- rep</span>`;
		} else if (weight < -0.30) {
			return `<span class="red">--- rep</span>`;
		} else if (weight < -0.15) {
			return `<span class="red">-- rep</span>`;
		} else if (weight < 0) {
			return `<span class="red">- rep</span>`;
		}
		/* return weight;*/
	}
};

/**
 * @param {number} s
 * @returns {string}
 */
window.massFormat = function(s) {
	if (!s) {
		s = 0;
	}
	if (s >= 1000) {
		s = num(Math.trunc(s / 1000));
		if (s !== 1) {
			return `${s} tons`;
		} else {
			return `${s} ton`;
		}
	} else {
		return `${num(s)} kg`;
	}
};

/**
 * @param {string} category
 * @param {string} title
 * @returns {string}
 */
window.budgetLine = function(category, title) {
	let income;
	let expenses;
	let profits;
	if (passage() === "Rep Budget") {
		income = "$lastWeeksRepIncome";
		expenses = "$lastWeeksRepExpenses";
		profits = "$lastWeeksRepProfits";

		return `<<if ${income}.${category} || ${expenses}.${category}>><tr>\
		<td>${title}</td>\
		<td><<print repFormat(${income}.${category})>></td>\
		<td><<print repFormat(${expenses}.${category})>></td>\
		<<set ${profits}.${category} = (${income}.${category} + ${expenses}.${category})>>\
		<td><<print repFormat(${profits}.${category})>></td>\
		</tr><</if>>`;
	} else if (passage() === "Costs Budget") {
		income = "$lastWeeksCashIncome";
		expenses = "$lastWeeksCashExpenses";
		profits = "$lastWeeksCashProfits";

		return `<<if ${income}.${category} || ${expenses}.${category}>><tr>\
		<td>${title}</td>\
		<td>\
			<<if (${income}.${category}) > 0>>\
				<span class="yellowgreen"><<print cashFormat(${income}.${category})>></span> /*please don't put a plus sign in front of income, it's not done on a budget sheet. Safe to assume money is money unless it's in parenthesis or with a - sign.*/
			<<else>>\
				<<print cashFormat(${income}.${category})>>\
			<</if>>\
		</td>\
		<td>\
			<<if (${expenses}.${category}) < 0>>\
				<span class="red">-<<print cashFormat(Math.abs(${expenses}.${category}))>></span>\
			<<else>>\
				<<print cashFormat(${expenses}.${category})>>\
			<</if>>\
		</td>\
		<<set ${profits}.${category} = (${income}.${category} + ${expenses}.${category})>>\
		<td>\
			<<if (${profits}.${category}) > 0>>\
				<span class="yellowgreen"><<print cashFormat(${profits}.${category})>>.</span>
			<<elseif (${profits}.${category}) < 0>>\
				<span class="red">-<<print cashFormat(Math.abs(${profits}.${category}))>>.</span>
			<<else>>\
				<<print cashFormat(${profits}.${category})>>\
			<</if>>\
		</td>\
		</tr><</if>>`;
	}
};

/**
 * @param {number} n
 * @returns {boolean}
 */
window.isFloat = function(n) {
	return Number.isFinite(n) && Math.floor(n) !== n;
};

/**
 * Returns a random number between min and max
 * @param {number} min
 * @param {number} max
 * @returns {number}
 */
window.jsRandom = function(min, max) {
	return Math.floor(Math.random() * (max - min + 1) + min);
};

/**
 * @param {number[]} arr
 * @param {number} count
 * @returns {number[]}
 */
window.jsRandomMany = function(arr, count) {
	let result = [];
	let _tmp = arr.slice();
	for (let i = 0; i < count; i++) {
		let index = Math.ceil(Math.random() * 10) % _tmp.length;
		result.push(_tmp.splice(index, 1)[0]);
	}
	return result;
};

/**
 * Accepts both an array and a list, returns undefined if nothing is passed.
 * @param {number|string|number[]|string[]} choices
 * @param {...(number|string|number[]|string[])} otherChoices
 * @returns {number|string|number[]|string[]|undefined}
 */
window.jsEither = function(choices, ...otherChoices) {
	if (otherChoices.length === 0 && Array.isArray(choices)) {
		return choices[Math.floor(Math.random() * choices.length)];
	}
	const allChoices = otherChoices;
	allChoices.push(choices);
	return allChoices[Math.floor(Math.random() * allChoices.length)];
};

/*
Make everything waiting for this execute. Usage:

let doSomething = function() {
	... your initialization code goes here ...
};
if(typeof Categorizer === 'function') {
	doSomething();
} else {
	jQuery(document).one('categorizer.ready', doSomething);
}
*/
jQuery(document).trigger("categorizer.ready");

/**
 * @param {number[]} obj
 * @returns {number}
 */
window.hashChoice = function hashChoice(obj) {
	let randint = Math.floor(Math.random() * hashSum(obj));
	let ret;
	Object.keys(obj).some((key) => {
		if (randint < obj[key]) {
			ret = key;
			return true;
		} else {
			randint -= obj[key];
			return false;
		}
	});
	return ret;
};

/**
 * @param {number[]} obj
 * @returns {number}
 */
window.hashSum = function hashSum(obj) {
	let sum = 0;
	Object.keys(obj).forEach((key) => {
		sum += obj[key];
	});
	return sum;
};

/**
 * @param {Array} arr
 * @returns {Object}
 */
window.arr2obj = function arr2obj(arr) {
	const obj = {};
	arr.forEach((item) => {
		obj[item] = 1;
	});
	return obj;
};

window.hashPush = function hashPush(obj, ...rest) {
	rest.forEach((item) => {
		if (obj[item] === undefined) {
			obj[item] = 1;
		} else {
			obj[item] += 1;
		}
	});
};

window.weightedArray2HashMap = function weightedArray2HashMap(arr) {
	const obj = {};
	arr.forEach((item) => {
		if (obj[item] === undefined) {
			obj[item] = 1;
		} else {
			obj[item] += 1;
		}
	});
	return obj;
};

/**
 * Determines if a is between low and high
 * @param {number} a
 * @param {number} low
 * @param {number} high
 * @returns {boolean}
 */
window.between = function between(a, low, high) {
	if (low === null) { low = -Infinity; }
	if (high === null) { high = Infinity; }
	return (a > low && a < high);
};

// generate a random, almost unique ID that is compliant (possibly) with RFC 4122
window.generateNewID = function generateNewID() {
	let date = Date.now(); // high-precision timer
	let uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
		let r = (date + Math.random() * 16) % 16 | 0;
		date = Math.floor(date / 16);
		return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
	});
	return uuid;
};

/**
 * @param {Array} array
 * @param {number} a
 * @param {number} b
 */
window.arraySwap = function arraySwap(array, a, b) {
	const tmp = array[a];
	array[a] = array[b];
	array[b] = tmp;
};

// circumvents sugarcube, allowing a plain HTML5 UI within it
window.html5passage = function html5passage(passageFunction) {
	$(document).one(":passagedisplay", (ev) => {
		const element = document.createElement("div");
		element.classList.add("passage");
		document.getElementById("passages").appendChild(element);
		passageFunction(element);
		$(document).off(":passagedisplay");
	});
};

/**
 * If you want to include a SugarCube passage in a JS function use this. The result must be printed using the <<print>> macro.
 * @param {string} passageTitle
 * @returns {string}
 */
window.jsInclude = function(passageTitle) {
	if (Story.has(passageTitle)) {
		return Story.get(passageTitle).processText();
	} else {
		return `<span class="red">Error: Passage ${passageTitle} does not exist.</span>`;
	}
};

/**
 * @param {string} string
 * @returns {string}
 */
window.capFirstChar = function capFirstChar(string) {
	return string.charAt(0).toUpperCase() + string.substr(1);
};

/**
 * @param {string} word
 * @returns {string}
 */
window.addA = function(word) {
	let vocal = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
	if (vocal.includes(word.charAt(0))) {
		return `an ${word}`;
	}
	return `a ${word}`;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
window.getSlaveDevotionClass = function(slave) {
	if ((!slave) || (!State)) {
		return undefined;
	}
	if (slave.fetish === "mindbroken") {
		return "mindbroken";
	}
	if (slave.devotion < -95) {
		return "very-hateful";
	} else if (slave.devotion < -50) {
		return "hateful";
	} else if (slave.devotion < -20) {
		return "resistant";
	} else if (slave.devotion <= 20) {
		return "ambivalent";
	} else if (slave.devotion <= 50) {
		return "accepting";
	} else if (slave.devotion <= 95) {
		return "devoted";
	} else {
		return "worshipful";
	}
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
window.getSlaveTrustClass = function(slave) {
	if ((!slave) || (!State)) {
		return undefined;
	}

	if (slave.fetish === "mindbroken") {
		return "";
	}

	if (slave.trust < -95) {
		return "extremely-terrified";
	} else if (slave.trust < -50) {
		return "terrified";
	} else if (slave.trust < -20) {
		return "frightened";
	} else if (slave.trust <= 20) {
		return "fearful";
	} else if (slave.trust <= 50) {
		if (slave.devotion < -20) {
			return "hate-careful";
		} else {
			return "careful";
		}
	} else if (slave.trust <= 95) {
		if (slave.devotion < -20) {
			return "bold";
		} else {
			return "trusting";
		}
	} else {
		if (slave.devotion < -20) {
			return "defiant";
		} else {
			return "profoundly-trusting";
		}
	}
};

/**
 * Takes an integer e.g. $activeSlave.hLength, returns a string in the format 10 inches
 * @param {number} s
 * @returns {string}
 */
window.cmToInchString = function(s) {
	let inches = (Math.round(s / 2.54)).toString();
	if (inches === "0") {
		if (s === 0) {
			inches += " inches";
		} else {
			inches = "less than an inch";
		}
	} else if (inches === "1") {
		inches += " inch";
	} else {
		inches += " inches";
	}
	return inches;
};

/**
 * takes an integer e.g. $activeSlave.height, returns a string in the format 6'5"
 * @param {number} s
 * @returns {string}
 */
window.cmToFootInchString = function(s) {
	if (Math.round(s / 2.54) < 12) {
		return cmToInchString(s);
	}
	return `${Math.trunc(Math.round(s/2.54)/12)}'${Math.round(s/2.54)%12}"`;
};

/**
 * takes a dick value e.g. $activeSlave.dick, returns a string in the format 6 inches
 * @param {number} s
 * @returns {string}
 */
window.dickToInchString = function(s) {
	return cmToInchString(dickToCM(s));
};

/**
 * takes a dick value e.g. $activeSlave.dick, returns an int of the dick length in cm
 * @param {number} s
 * @returns {number}
 */
window.dickToCM = function(s) {
	if (s < 9) {
		return s * 5;
	} else if (s === 9) {
		return 50;
	}
	return s * 6;
};

// takes a ball value e.g. $activeSlave.balls, returns a string in the format 3 inches
window.ballsToInchString = function(s) {
	return cmToInchString(ballsToCM(s));
};

// takes a ball value e.g. $activeSlave.balls, returns an int of the ball size in cm
window.ballsToCM = function(s) {
	if (s < 2) {
		return 0;
	}
	return (s < 10 ? (s - 1) * 2 : s * 2);
};

// takes a dick value e.g. $activeSlave.dick, returns a string in the format of either `20cm (8 inches)`, `8 inches`, or `20cm`
window.dickToEitherUnit = function(s) {
	if (State.variables.showInches === 1) {
		return `${dickToCM(s)}cm (${dickToInchString(s)})`;
	}
	if (State.variables.showInches === 2) {
		return dickToInchString(s);
	}
	return `${dickToCM(s)}cm`;
};

// takes a ball value e.g. $activeSlave.balls, returns a string in the format of either `20cm (8 inches)`, `8 inches`, or `20cm`
window.ballsToEitherUnit = function(s) {
	if (State.variables.showInches === 1) {
		return `${ballsToCM(s)}cm (${ballsToInchString(s)})`;
	}
	if (State.variables.showInches === 2) {
		return ballsToInchString(s);
	}
	return `${ballsToCM(s)}cm`;
};

// takes an int in centimeters e.g. $activeSlave.height, returns a string in the format of either `200cm (6'7")`, `6'7"`, or `200cm`
window.heightToEitherUnit = function(s) {
	if (State.variables.showInches === 1) {
		return `${s}cm (${cmToFootInchString(s)})`;
	}
	if (State.variables.showInches === 2) {
		return cmToFootInchString(s);
	}
	return `${s}cm`;
};

// takes an int in centimeters e.g. $activeSlave.hLength, returns a string in the format of either `30cm (12 inches)`, `12 inches`, or `30cm`
window.lengthToEitherUnit = function(s) {
	if (State.variables.showInches === 1) {
		return `${s}cm (${cmToInchString(s)})`;
	}
	if (State.variables.showInches === 2) {
		return cmToInchString(s);
	}
	return `${s}cm`;
};

window.ordinalSuffix = function ordinalSuffix(i) {
	let j = i % 10;
	let k = i % 100;
	if (j === 1 && k !== 11) {
		return `${i}st`;
	}
	if (j === 2 && k !== 12) {
		return `${i}nd`;
	}
	if (j === 3 && k !== 13) {
		return `${i}rd`;
	}
	return `${i}th`;
};

/**
 * @param {Iterable<any>} array
 * @returns {Set<any>}
 */
window.removeDuplicates = function removeDuplicates(array) {
	return [...new Set(array)];
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
window.induceLactation = function induceLactation(slave) {
	let pronouns = getPronouns(slave);
	let His = capFirstChar(pronouns.possessive);
	let r = "";
	if (slave.induceLactation >= 10) {
		if (jsRandom(1, 100) < slave.induceLactation) {
			r += `${His} breasts have been stimulated often enough to <span class="lime">induce lactation.</span>`;
			slave.induceLactation = 0;
			slave.lactationDuration = 2;
			slave.lactation = 1;
		}
	}
	return r;
};

window.ResearchLabStockPile = function() {
	const V = State.variables;
	return `__Prosthetics__
	Prosthetics interfaces: ${num(V.prosthetics.interfaceP1.amount + V.prosthetics.interfaceP2.amount)}
	 Basic: $prosthetics.interfaceP1.amount
	 Advanced: $prosthetics.interfaceP2.amount
	Limbs: ${num(V.prosthetics.basicL.amount + V.prosthetics.sexL.amount + V.prosthetics.beautyL.amount + V.prosthetics.combatL.amount + V.prosthetics.cyberneticL.amount)}
	 Basic: $prosthetics.basicL.amount
	 Sex: $prosthetics.sexL.amount
	 Beauty: $prosthetics.beautyL.amount
	 Combat: $prosthetics.combatL.amount
	 Cybernetic: $prosthetics.cyberneticL.amount
	Implants: ${num(V.prosthetics.ocular.amount + V.prosthetics.cochlear.amount + V.prosthetics.electrolarynx.amount)}
	 Ocular: $prosthetics.ocular.amount
	 Cochlear: $prosthetics.cochlear.amount
	// Erectile: $prosthetics.erectile.amount//
	 Electrolarynx: $prosthetics.electrolarynx.amount
	Tail interface: $prosthetics.interfaceTail.amount
	Tails: ${num(V.prosthetics.modT.amount + V.prosthetics.sexT.amount + V.prosthetics.combatT.amount)}
	 Modular: $prosthetics.modT.amount
	 Pleasure: $prosthetics.sexT.amount
	 Combat: $prosthetics.combatT.amount`;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
window.originPronounReplace = function(slave) {
	let r = slave.origin;
	switch (r) {
		case "She was the result of unprotected sex with a client. Her mother tracked you down years after her birth to force her upon you.":
			return `${capFirstChar(slave.pronoun)} was the result of unprotected sex with a client. ${capFirstChar(slave.possessive)} mother tracked you down years after ${slave.possessive} birth to force ${slave.object} upon you.`;
		case "You kept her after her owner failed to pay your bill for performing surgery on her.":
			return `You kept ${slave.object} after ${slave.possessive} owner failed to pay your bill for performing surgery on ${slave.object}.`;
		case "She comes from old money and sold herself into slavery to satisfy her obsession with the practice, believing her family would buy her back out of slavery later.":
			return `${capFirstChar(slave.pronoun)} comes from old money and sold herself into slavery to satisfy ${slave.possessive} obsession with the practice, believing ${slave.possessive} family would buy ${slave.object} back out of slavery later.`;
		case "When you took her from her previous owner, she was locked into a beautiful rosewood box lined with red velvet, crying.":
			return `When you took ${slave.object} from ${slave.possessive} previous owner, ${slave.pronoun} was locked into a beautiful rosewood box lined with red velvet, crying.`;
		case "Her husband sold her into slavery to escape his debts.":
			return `${capFirstChar(slave.possessive)} husband sold ${slave.object} into slavery to escape his debts.`;
		case "She was voluntarily enslaved after she decided that your arcology was the best place for her to get the steroids that she'd allowed to define her life.":
			return `${capFirstChar(slave.pronoun)} was voluntarily enslaved after ${slave.pronoun} decided that your arcology was the best place for ${slave.object} to get the steroids that ${slave.pronoun}'d allowed to define ${slave.possessive} life.`;
		case "She came to you to escape being sold to a cruel master after her producer informed her of her debt.":
			return `${capFirstChar(slave.pronoun)} came to you to escape being sold to a cruel master after ${slave.possessive} producer informed ${slave.object} of ${slave.possessive} debt.`;
		case "You tricked her into enslavement, manipulating her based on her surgical addiction.":
			return `You tricked ${slave.object} into enslavement, manipulating ${slave.object} based on ${slave.possessive} surgical addiction.`;
		case "You helped free her from a POW camp after being abandoned by her country, leaving her deeply indebted to you.":
			return `You helped free ${slave.object} from a POW camp after being abandoned by ${slave.possessive} country, leaving ${slave.object} deeply indebted to you.`;
		case "You purchased her in order to pave the way for her brother to take the throne.":
			return `You purchased ${slave.object} in order to pave the way for ${slave.possessive} brother to take the throne.`;
		case "You purchased her as a favor to her father.":
			return `You purchased ${slave.object} as a favor to ${slave.possessive} father.`;
		case "You purchased her from a King after his son put an illegitimate heir in her womb.":
			return `You purchased ${slave.object} from a King after his son put an illegitimate heir in ${slave.possessive} womb.`;
		case "You acquired her in the last stages of your career as a successful venture capitalist.":
		case "Drugs and alcohol can be a potent mix; the night that followed it can sometimes be hard to remember. Needless to say, once your belly began swelling with her, you had to temporarily switch to a desk job for your mercenary group.":
		case "You acquired her in the last stages of your career as a noted private military contractor.":
		case "You never thought you would be capable of impregnating yourself, but years of pleasuring yourself with yourself after missions managed to create her.":
		case "A fresh capture once overpowered you and had his way with you. You kept her as a painful reminder to never lower your guard again.":
		case "Your slaving troop kept several girls as fucktoys; you sired her in your favorite.":
		case "You enslaved her personally during the last stages of your slaving career.":
		case "You sired her in yourself after an arcology owner, impressed by your work, rewarded you with a night you'll never forget.":
		case "You conceived her after a male arcology owner, impressed by your work, rewarded you with a night you'll never forget.":
		case "You sired her after a female arcology owner, impressed by your work, rewarded you with a night you'll never forget.":
		case "You received her as a gift from an arcology owner impressed by your work.":
		case "You captured her during your transition to the arcology":
		case "You won her at cards, a memento from your life as one of the idle rich before you became an arcology owner.":
		case "You brought her into the arcology mindbroken, little more than a walking collection of fuckable holes.":
		case "You brought her into the arcology mindbroken, little more than a human onahole.":
		case "She grew up sheltered and submissive, making her an easy target for enslavement.":
		case "She was fresh from the slave markets when you acquired her.":
		case "She chose to be a slave because the romanticized view of it she had turns her on.":
		case "She was previously owned by a creative sadist, who has left a variety of mental scars on her.":
		case "She was taken as a slave by a Sultan, who presented her as a gift to a surveyor.":
		case "She is the winner of a martial arts slave tournament. You won her in a bet.":
		case "She was homeless and willing to do anything for food, which in the end resulted in her becoming a slave.":
		case "She was sold to you by an anonymous person who wanted her to suffer.":
		case "You received her from a surgeon who botched an implant operation on her and needed to get her out of sight.":
		case "She offered herself to you for enslavement to escape having plastic surgery foisted on her.":
		case "You turned her into a slave girl after she fell into debt to you.":
		case "She was raised in a radical slave school that treated her with drugs and surgery from a very young age.":
		case "She was raised in a radical slave school that treated her from a very young age, up to the point that she never experienced male puberty.":
		case "She was a runaway slave captured by a gang outside your arcology. You bought her cheap after she was harshly used by them.":
		case "She was the private slave of a con artist cult leader before he had to abandon her and flee.":
		case "You helped her give birth, leaving her deeply indebted to you.":
		case "You purchased her from a King after she expressed knowledge of the prince's affair with another servant.":
			r = r.replace(/\bherself\b/g, slave.objectReflexive);
			r = r.replace(/\bHerself\b/g, capFirstChar(slave.objectReflexive));
			r = r.replace(/\bshe\b/g, slave.pronoun);
			r = r.replace(/\bShe\b/g, capFirstChar(slave.pronoun));
			r = r.replace(/\bher\b/g, slave.object);
			r = r.replace(/\bHer\b/g, capFirstChar(slave.object));
			return r;
		default:
			r = r.replace(/\bherself\b/g, slave.objectReflexive);
			r = r.replace(/\bHerself\b/g, capFirstChar(slave.objectReflexive));
			r = r.replace(/\bshe\b/g, slave.pronoun);
			r = r.replace(/\bShe\b/g, capFirstChar(slave.pronoun));
			r = r.replace(/\bher\b/g, slave.possessive);
			r = r.replace(/\bHer\b/g, capFirstChar(slave.possessive));
			return r;
	}
};

window.HackingSkillMultiplier = function() {
	const V = State.variables;
	if (V.PC.hacking <= -100) {
		return 1.5;
	} else if (V.PC.hacking <= -75) {
		return 1.35;
	} else if (V.PC.hacking <= -50) {
		return 1.25;
	} else if (V.PC.hacking <= -25) {
		return 1.15;
	} else if (V.PC.hacking < 0) {
		return 1.10;
	} else if (V.PC.hacking === 0) {
		return 1;
	} else if (V.PC.hacking <= 10) {
		return 0.97;
	} else if (V.PC.hacking <= 25) {
		return 0.95;
	} else if (V.PC.hacking <= 50) {
		return 0.90;
	} else if (V.PC.hacking <= 75) {
		return 0.85;
	} else if (V.PC.hacking <= 100) {
		return 0.80;
	} else {
		return 0.75;
	}
};

App.UI.tabbar = function() {
	return {
		openTab: openTab,
		tabButton: tabButton,
		makeTab: makeTab,
		handlePreSelectedTab: handlePreSelectedTab
	};

	function openTab(evt, tabName) {
		const V = State.variables;
		/* var passage = passage().trim().replace(/ /g,"+");*/
		const tabcontent = document.getElementsByClassName("tabcontent");
		for (let i = 0; i < tabcontent.length; i++) {
			tabcontent[i].style.display = "none";
		}
		const tablinks = document.getElementsByClassName("tablinks");
		for (let i = 0; i < tablinks.length; i++) {
			tablinks[i].className = tablinks[i].className.replace(" active", "");
		}
		V.tabChoice[_tabChoiceVarName()] = tabName; /* The regex strips spaces and " ' " from passage names, making "Servants' Quarters" into "ServantsQuarters" and allowing it to be used as a label in this object. */
		document.getElementById(tabName).style.display = "block";
		evt.currentTarget.className += " active";
	}

	/**
	 * @param {string} name
	 * @param {string} text
	 * @returns {string}
	 */
	function tabButton(name, text) {
		return `<button class="tablinks" onclick="App.UI.tabbar.openTab(event, '${name}')" id="tab ${name}">${text}</button>`;
	}

	/**
	 * @param {string} name
	 * @param {string} content
	 * @returns {string}
	 */
	function makeTab(name, content) {
		return `<div id="${name}" class="tabcontent"><div class="content">` + content + '</div></div>';
	}

	function handlePreSelectedTab() {
		let selectedTab = State.variables.tabChoice[_tabChoiceVarName()];
		if (!selectedTab) { selectedTab = "assign"; }
		$(document).one(':passagedisplay', function() {
			let tabBtn = document.getElementById(`tab ${selectedTab}`);
			if (!tabBtn) {
				tabBtn = document.getElementsByClassName('tablinks').item(0);
			}
			if (tabBtn) { tabBtn.click(); }
		});
	}

	function _tabChoiceVarName() {
		return passage().trim().replace(/ |'/g, '');
	}
}();

/**
 * replaces special HTML charachters with their '&xxx' forms
 * @param {string} text
 * @returns {string}
 */
App.Utils.escapeHtml = function(text) {
	const map = {
		'&': '&',
		'<': '<',
		'>': '>',
		'"': '"',
		"'": '''
	};
	return text.replace(/[&<>"']/g, m => map[m]);
};

/**
 * Creates a HTML element with custom SugarCube attributes which works as a passage link
 *
 * The result works in the same way as the wiki markup in the SugarCube
 * @see https://www.motoslave.net/sugarcube/2/docs/#markup-html-attribute
 * @param {string} linkText link text
 * @param {string} passage the passage name to link to
 * @param {string} [setter=''] setter tet (optional)
 * @param {string} [elementType='a'] element type (optional) default is 'a'.
 * Could be any of 'a', 'audio', img', 'source', 'video'
 * @returns {string} element text
 *
 * @example
 * // equal to [[Go to town|Town]]
 * App.UI.passageLink("Go to town", "Town")
 */
App.UI.passageLink = function(linkText, passage, setter, elementType = 'a') {
	let res = `<${elementType} data-passage="${passage}"`;
	if (setter) {
		res += ` data-setter="${App.Utils.escapeHtml(setter)}"`;
	}
	res += `>${linkText}</${elementType}>`;
	return res;
};

/**
 * Replaces contents of the element, identified by the given selector, with wiki'ed new content
 *
 * The function is an analogue to the SugarCube <<replace>> macro (and is a simplified version of it)
 * @param {string} selector
 * @param {string} newContent
 */
App.UI.replace = function(selector, newContent) {
	let ins = jQuery(document.createDocumentFragment());
	ins.wiki(newContent);
	const target = $(selector);
	target.empty();
	target.append(ins);
};

/**
 * A simple macro which allows to create wrapping html elements with dynamic IDs.
 *
 * idea blatantly robbed from the spanMacroJS.tw but expanded to a more generic case, allowing <div>,
 * <button> or whatever you want elements, default is for the div though.
 * In addition, you can pass an object in as the first argument instead of an id, and each of the
 * object's attributes will become attributes of the generate tag.
 *
 * @example
 * htag('test', "red") // <div id="red">test</div>
 * htag('test', {class: red}); // <div class="red">test</div>
 * htag('test', {class: red, id: green}); // <div class="red" id="green">test</div>
 * @param {string} text
 * @param {string|object} attributes
 * @param {string} [tag='div']
 * @returns {string}
 */
App.UI.htag = function(text, attributes, tag = 'div') {
	const payload = text.replace(/(^\n+|\n+$)/, "");

	if ("object" === typeof attributes) {
		attributes = $.map(attributes, (val, key) => `${key}="${val}"`).join(" ");
	} else {
		attributes = `id="${attributes.trim()}"`;
	}

	return `<${tag} ${attributes}>${payload}</${tag}>`;
};

window.SkillIncrease = (function() {
	return {
		Oral: OralSkillIncrease,
		Vaginal: VaginalSkillIncrease,
		Anal: AnalSkillIncrease,
		Whore: WhoreSkillIncrease,
		Entertain: EntertainSkillIncrease
	};

	/* call as SkillIncrease.Oral() */
	/**
	 * @param {App.Entity.SlaveState} slave
	 * @param {number} skillIncrease	// I think
	 * @returns {string}
	 */
	function OralSkillIncrease(slave, skillIncrease = 1) {
		const He = capFirstChar(slave.pronoun);
		const his = slave.possessivePronoun;
		let r = "";

		if (slave.skill.oral <= 10) {
			if (slave.skill.oral + skillIncrease > 10) {
				r = `<span class="green">${He} now has basic knowledge about oral sex,</span> and can at least suck a dick without constant gagging.`;
			}
		} else if (slave.skill.oral <= 30) {
			if (slave.skill.oral + skillIncrease > 30) {
				r = `<span class="green">${He} now has some oral skills,</span> and can reliably bring dicks and pussies to climax with ${his} mouth.`;
			}
		} else if (slave.skill.oral <= 60) {
			if (slave.skill.oral + skillIncrease > 60) {
				r = `<span class="green">${He} is now an oral expert,</span> and has a delightfully experienced tongue.`;
			}
		} else if (slave.skill.oral < 100) {
			if (slave.skill.oral + skillIncrease >= 100) {
				r = `<span class="green">${He} has mastered oral sex,</span> and can learn nothing more about sucking dick or eating pussy.`;
			}
		}
		slave.skill.oral += skillIncrease;
		return r;
	}

	/* call as SkillIncrease.Vaginal() */
	/**
	 * @param {App.Entity.SlaveState} slave
	 * @param {number} skillIncrease	// I think
	 * @returns {number}
	 */
	function VaginalSkillIncrease(slave, skillIncrease = 1) {
		const He = capFirstChar(slave.pronoun);
		let r = "";

		if (slave.skill.vaginal <= 10) {
			if (slave.skill.vaginal + skillIncrease > 10) {
				r = `<span class="green">${He} now has basic knowledge about vaginal sex,</span> and can avoid some of the common pitfalls and turnoffs.`;
			}
		} else if (slave.skill.vaginal <= 30) {
			if (slave.skill.vaginal + skillIncrease > 30) {
				r = `<span class="green">${He} now has some vaginal sex skills,</span> and can do more than just lie there and take it.`;
			}
		} else if (slave.skill.vaginal <= 60) {
			if (slave.skill.vaginal + skillIncrease > 60) {
				r = `<span class="green">${He} is now a vaginal sex expert,</span> and has the muscular control to massage anything that's inside $him.`;
			}
		} else if (slave.skill.vaginal < 100) {
			if (slave.skill.vaginal + skillIncrease >= 100) {
				r = `<span class="green">${He} has mastered vaginal sex,</span> and can learn nothing more about tribbing or taking dick.`;
			}
		}
		slave.skill.vaginal += skillIncrease;
		return r;
	}

	/* call as SkillIncrease.Anal() */
	/**
	 * @param {App.Entity.SlaveState} slave
	 * @param {number} skillIncrease	// I think
	 * @returns {string}
	 */
	function AnalSkillIncrease(slave, skillIncrease = 1) {
		const He = capFirstChar(slave.pronoun);
		const his = slave.possessivePronoun;
		let r = "";

		if (slave.skill.anal <= 10) {
			if (slave.skill.anal + skillIncrease > 10) {
				r = `<span class="green">${He} now has basic knowledge about anal sex,</span> and can accept penetration of ${his} anus without danger.`;
			}
		} else if (slave.skill.anal <= 30) {
			if (slave.skill.anal + skillIncrease > 30) {
				r = `<span class="green">${He} now has some anal sex skills,</span> and needs less preparation before taking rough penetration.`;
			}
		} else if (slave.skill.anal <= 60) {
			if (slave.skill.anal + skillIncrease > 60) {
				r = `<span class="green">${He} is now an anal sex expert,</span> and knows how to use ${his} sphincter to please.`;
			}
		} else if (slave.skill.anal < 100) {
			if (slave.skill.anal + skillIncrease >= 100) {
				r = `<span class="green">${He} has mastered anal sex,</span> and can learn nothing more about taking it up the ass.`;
			}
		}
		slave.skill.anal += skillIncrease;
		return r;
	}

	/* call as SkillIncrease.Whore() */
	/**
	 * @param {App.Entity.SlaveState} slave
	 * @param {number} skillIncrease	// I think
	 * @returns {string}
	 */
	function WhoreSkillIncrease(slave, skillIncrease = 1) {
		const He = capFirstChar(slave.pronoun);
		const his = slave.possessivePronoun;
		let r = "";

		if (slave.skill.whoring <= 10) {
			if (slave.skill.whoring + skillIncrease > 10) {
				r = `<span class="green">${He} now has basic knowledge about how to whore,</span> and can avoid some potentially dangerous situations.`;
			}
		} else if (slave.skill.whoring <= 30) {
			if (slave.skill.whoring + skillIncrease > 30) {
				r = `<span class="green">${He} now has some skill as a whore,</span> and knows how to sell ${his} body at a good price.`;
			}
		} else if (slave.skill.whoring <= 60) {
			if (slave.skill.whoring + skillIncrease > 60) {
				r = `<span class="green">${He} is now an expert whore,</span> and can often make clients forget that $he's a prostitute they're paying for.`;
			}
		} else if (slave.skill.whoring < 100) {
			if (slave.skill.whoring + skillIncrease >= 100) {
				r = `<span class="green">${He} is now a masterful whore,</span> and can learn nothing more about prostitution.`;
			}
		}
		slave.skill.whoring += skillIncrease;
		return r;
	}

	/* call as SkillIncrease.Entertain() */
	/**
	 * @param {App.Entity.SlaveState} slave
	 * @param {number} skillIncrease	// I think
	 * @returns {string}
	 */
	function EntertainSkillIncrease(slave, skillIncrease = 1) {
		const He = capFirstChar(slave.pronoun);
		let r = "";

		if (slave.skill.entertainment <= 10) {
			if (slave.skill.entertainment + skillIncrease > 10) {
				r = `<span class="green">${He} now has basic knowledge about how to be entertaining,</span> and can usually avoid serious faux pas.`;
			}
		} else if (slave.skill.entertainment <= 30) {
			if (slave.skill.entertainment + skillIncrease > 30) {
				r = `<span class="green">${He} now has some skill as an entertainer,</span> and can flirt, dance, and strip acceptably.`;
			}
		} else if (slave.skill.entertainment <= 60) {
			if (slave.skill.entertainment + skillIncrease > 60) {
				r = `<span class="green">${He} is now an expert entertainer,</span> and can flirt engagingly, dance alluringly, and strip arousingly.`;
			}
		} else if (slave.skill.entertainment < 100) {
			if (slave.skill.entertainment + skillIncrease >= 100) {
				r = `<span class="green">${He} is now a masterful entertainer,</span> and can learn nothing more about flirting, dancing, or stripping.`;
			}
		}
		slave.skill.entertainment += skillIncrease;
		return r;
	}
})();

window.upgradeMultiplierArcology = function() {
	const V = State.variables;
	if (V.PC.career === "engineer" || (V.arcologies[0].FSRestartDecoration >= 100 && V.eugenicsFullControl === 0)) {
		return 0.6;
	} else if (V.PC.engineering <= -100) {
		return 1.5;
	} else if (V.PC.engineering <= -75) {
		return 1.35;
	} else if (V.PC.engineering <= -50) {
		return 1.25;
	} else if (V.PC.engineering <= -25) {
		return 1.15;
	} else if (V.PC.engineering < 0) {
		return 1.10;
	} else if (V.PC.engineering === 0) {
		return 1;
	} else if (V.PC.engineering <= 10) {
		return 0.97;
	} else if (V.PC.engineering <= 25) {
		return 0.95;
	} else if (V.PC.engineering <= 50) {
		return 0.90;
	} else if (V.PC.engineering <= 75) {
		return 0.85;
	} else if (V.PC.engineering <= 100) {
		return 0.80;
	} else {
		return 0.75;
	}
};

window.upgradeMultiplierMedicine = function() {
	const V = State.variables;
	if (V.PC.career === "medicine" || (V.arcologies[0].FSRestartDecoration >= 100 && V.eugenicsFullControl === 0)) {
		return 0.6;
	} else if (V.PC.medicine <= -100) {
		return 1.5;
	} else if (V.PC.medicine <= -75) {
		return 1.35;
	} else if (V.PC.medicine <= -50) {
		return 1.25;
	} else if (V.PC.medicine <= -25) {
		return 1.15;
	} else if (V.PC.medicine < 0) {
		return 1.10;
	} else if (V.PC.medicine === 0) {
		return 1;
	} else if (V.PC.medicine <= 10) {
		return 0.97;
	} else if (V.PC.medicine <= 25) {
		return 0.95;
	} else if (V.PC.medicine <= 50) {
		return 0.90;
	} else if (V.PC.medicine <= 75) {
		return 0.85;
	} else if (V.PC.medicine <= 100) {
		return 0.80;
	} else {
		return 0.75;
	}
};

window.upgradeMultiplierTrade = function() {
	const V = State.variables;
	if (V.PC.trading <= -100) {
		return 1.5;
	} else if (V.PC.trading <= -75) {
		return 1.35;
	} else if (V.PC.trading <= -50) {
		return 1.25;
	} else if (V.PC.trading <= -25) {
		return 1.15;
	} else if (V.PC.trading < 0) {
		return 1.10;
	} else if (V.PC.trading === 0) {
		return 1;
	} else if (V.PC.trading <= 10) {
		return 0.97;
	} else if (V.PC.trading <= 25) {
		return 0.95;
	} else if (V.PC.trading <= 50) {
		return 0.90;
	} else if (V.PC.trading <= 75) {
		return 0.85;
	} else if (V.PC.trading <= 100) {
		return 0.80;
	} else {
		return 0.75;
	}
};

window.jsNdef = function(input) {
	if (typeof input === "undefined") {
		return true;
	} else {
		return false;
	}
};

window.jsDef = function(input) {
	if (typeof input !== "undefined") {
		return true;
	} else {
		return false;
	}
};

/**
 * Return a career at random that would be suitable for the given slave.
 * Currently only considers their age
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
window.randomCareer = function(slave) {
	if (slave.actualAge < 16) {
		return setup.veryYoungCareers.random();
	} else if (slave.actualAge <= 24) {
		return setup.youngCareers.random();
	} else if (slave.intelligenceImplant >= 10) {
		return setup.educatedCareers.random();
	} else {
		return setup.uneducatedCareers.random();
	}
};

/**
 * @param {App.Entity.SlaveState} slave
 */
window.resyncSlaveToAge = function(slave) {
	slave.height = Height.random(slave);
	slave.pubertyXX = slave.actualAge < slave.pubertyAgeXX ? 0 : 1;
	slave.pubertyXY = slave.actualAge < slave.pubertyAgeXY ? 0 : 1;
	if (slave.actualAge < 12) {
		slave.vagina = 0;
		slave.trueVirgin = 0;
		slave.preg = -1;
		slave.belly = 0;
		slave.bellyPreg = 0;
		slave.ovaries = 1;
		slave.anus = 0;
		slave.skill.anal = 0;
		slave.skill.oral = 0;
		slave.skill.whoring = 0;
		slave.skill.entertainment = 0;
		slave.skill.combat = 0;
		slave.skill.vaginal = 0;
		slave.attrXY = 50;
		slave.attrXX = 50;
		slave.boobs = 200;
		slave.birthWeek = 0;
		SetBellySize(slave);
		if (slave.dick > 0) {
			slave.dick = 1;
		}
		if (slave.balls > 0) {
			slave.balls = 1;
		}
	} else {
		slave.boobs = Math.max(slave.boobs, 500);
		if (slave.dick > 2) {
			slave.dick = 2;
		}
		if (slave.balls > 2) {
			slave.balls = 2;
		}
	}
	slave.career = randomCareer(slave);
};

/**
 * @param {string} input
 * @param {number} [increase=1]
 * @returns {string}
 */
window.IncreasePCSkills = function(input, increase = 1) {
	const player = State.variables.PC;
	const oldSkill = player[input];
	player[input] += increase;
	if (oldSkill <= 10) {
		if (player[input] >= 10) {
			return `<span class="green"> <br>You have gained basic knowlege in ${input}.</span>`;
		}
	} else if (oldSkill <= 30) {
		if (player[input] >= 30) {
			return `<span class="green"> <br>You have gained some knowlege in ${input}.</span>`;
		}
	} else if (oldSkill <= 60) {
		if (player[input] >= 60) {
			return `<span class="green"> <br>You have become an expert in ${input}.</span>`;
		}
	} else if (oldSkill < 100) {
		if (player[input] >= 100) {
			return `<span class="green"> <br>You have mastered ${input}.</span>`;
		}
	}
};

/**
 * @param {string} raceName
 * @returns {string}
 */
window.randomRaceSkin = function(raceName) {
	let skin;
	switch (raceName) {
		case "asian":
			skin = jsEither(["dark olive", "light olive", "light"]);
			break;
		case "amerindian":
		case "indo-aryan":
		case "malay":
		case "pacific islander":
			skin = jsEither(["dark", "light"]);
			break;
		case "black":
			skin = jsEither(["black", "brown", "dark brown"]);
			break;
		case "latina":
			skin = jsEither(["brown", "dark brown", "dark olive", "light olive", "tan"]);
			break;
		case "middle eastern":
		case "semitic":
		case "southern european":
			skin = jsEither(["fair", "light olive", "light", "tan"]);
			break;
		case "white":
			skin = jsEither(["fair", "light", "pale"]);
			break;
		default:
			skin = jsEither(["dark", "light", "pale"]);
			break;
	}
	return skin;
};

/**
 * @param {string} raceName
 * @returns {string}
 */
window.randomRaceEye = function(raceName) {
	let eye;
	switch (raceName) {
		case "asian":
		case "black":
		case "indo-aryan":
		case "middle eastern":
		case "pacific islander":
			eye = jsEither(["brown", "brown", "brown", "brown", "green"]);
			break;
		case "amerindian":
		case "latina":
			eye = jsEither(["brown", "brown", "brown", "green"]);
			break;
		case "malay":
		case "southern european":
			eye = jsEither(["blue", "brown", "brown", "brown", "brown", "brown", "brown", "green"]);
			break;
		case "semitic":
		case "white":
			eye = jsEither(["blue", "brown", "brown", "brown", "green", "green"]);
			break;
		default:
			eye = jsEither(["blue", "brown", "green"]);
			break;
	}
	return eye;
};

/**
 * @param {string} raceName
 * @returns {string}
 */
window.randomRaceHair = function(raceName) {
	let hair;
	switch (raceName) {
		case "asian":
		case "amerindian":
		case "indo-aryan":
		case "malay":
		case "middle eastern":
		case "pacific islander":
			hair = jsEither(["black", "black", "black", "black", "black", "brown"]);
			break;
		case "black":
		case "latina":
		case "semitic":
		case "southern european":
			hair = jsEither(["black", "black", "brown", "brown"]);
			break;
		case "white":
			hair = jsEither(["black", "black", "blonde", "brown", "brown", "red"]);
			break;
		default:
			hair = jsEither(["black", "black", "black", "black", "blonde", "brown", "brown", "red"]);
			break;
	}
	return hair;
};

/**
 * @param {string} skinTone
 * @returns {number}
 */
window.skinToneLevel = function(skinTone) {
	if (!setup.naturalSkins.includes(skinTone)) {
		return undefined;
	}
	const skinToMelanin = {
		"pure black": 25,
		"ebony": 24,
		"black": 23,
		"dark brown": 22,
		"brown": 21,
		"light brown": 20,
		"dark beige": 19,
		"beige": 18,
		"light beige": 17,
		"dark": 16,
		"dark olive": 15,
		"bronze": 14,
		"olive": 13,
		"tan": 12,
		"light olive": 11,
		"light": 10,
		"fair": 9,
		"very fair": 8,
		"extremely fair": 7,
		"pale": 6,
		"very pale": 5,
		"extremely pale": 4,
		"white": 3,
		"ivory": 2,
		"pure white": 1
	};
	return skinToMelanin[skinTone];
};

/**
 * Increase or decrease skinTone
 * @param {string} skin
 * @param {number} value
 * @returns {string}
 */

window.changeSkinTone = function(skin, value) {
	if (!setup.naturalSkins.includes(skin)) {
		return skin;
	}
	const skinToMelanin = {
		"pure black": 25,
		"ebony": 24,
		"black": 23,
		"dark brown": 22,
		"brown": 21,
		"light brown": 20,
		"dark beige": 19,
		"beige": 18,
		"light beige": 17,
		"dark": 16,
		"dark olive": 15,
		"bronze": 14,
		"olive": 13,
		"tan": 12,
		"light olive": 11,
		"light": 10,
		"fair": 9,
		"very fair": 8,
		"extremely fair": 7,
		"pale": 6,
		"very pale": 5,
		"extremely pale": 4,
		"white": 3,
		"ivory": 2,
		"pure white": 1
	};
	let newSkin = (skinToMelanin[skin] + value);
	if (newSkin > 25) {
		newSkin = 25;
	} else if (newSkin < 1) {
		newSkin = 1;
	}
	let prop;
	for (prop in skinToMelanin) {
		if (!skinToMelanin.hasOwnProperty(prop)) { continue; }
		if (newSkin >= skinToMelanin[prop]) { return prop; }
	}
	return prop;
};

/**
 * Creates a span for an link with tooltip containing the reasons why it is disabled
 * @param {string} link
 * @param {string[]} reasons
 * @returns {string}
 */
App.UI.disabledLink = function(link, reasons) {
	const tooltips = reasons.length === 1 ?
		`<span class="tooltip">${reasons}</span>` :
		`<div class="tooltip"><ul>${reasons.map(e => `<li>${e}</li>`).join('')}</ul></div>`;
	return `<span class="textWithTooltip">${link}${tooltips}</span>`;
};

/**
 * Expression for SugarCube for referencing a slave by index
 * @param {number} i slave array index or -1 for activeSlave
 * @returns {string}
 */
App.Utils.slaveRefString = function(i) {
	return i >= 0 ? `$slaves[${i}]` : '$activeSlave';
};

/**
 * Returns slave by index in the slave array, Accepts -1 for the activeSlave
 * @param {number} i slave array index or -1 for activeSlave
 * @returns {App.Entity.SlaveState}
 */
App.Utils.slaveByIndex = function(i) {
	return i === -1 ? State.variables.activeSlave : State.variables.slaves[i];
};

/**
 * Sets active slave to the slave with the given index. Does nothing if index < 0
 * @param {number} index
 */
App.Utils.setActiveSlaveByIndex = function(index) {
	if (index >= 0) {
		State.variables.activeSlave = State.variables.slaves[index];
	}
};

/**
 * Returns index in the slave array for the given ID
 * @param {number} id slave ID
 * @returns {number}
 */
App.Utils.slaveIndexForId = function(id) {
	return State.variables.slaveIndices[id];
};