:: UtilJS [script]

/*
 * 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 for the given combination,
 *     with Gaussian distribution (mean = 1, standard deviation = 0.05) around the mean height
 * 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".
 */
window.Height = (function(){
	'use strict';
	
	// Global configuration (for different game modes/options/types)
	var minMult = -3.0;
	var maxMult = 3.0;
	var skew = 0.0;
	var spread = 0.05;
	var minHeight = 0;
	var maxHeight = 999;
	
	// Configuration method for the above values
	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 = {
		"American.white": 165, "American.black": 163.6, "American.latina": 158.9, "American.asian": 158.4, "American": 161.8,
		"Afghan": 163.8, "Algerian": 162, "Argentinian": 159.6, "Armenian": 158.1, "Australian": 161.8, "Austrian": 166,
		"Bangladeshi": 150.6, "Belarusian": 166.8, "Belgian": 168.1, "Bolivian": 142.2, "Brazilian": 158.8,
		"British": 161.9, "Burmese": undefined, "Canadian": 162.3, "Chilean": 157.2, "Chinese": 155.8, "Colombian": 158.7,
		"Congolese": 157.7, "Cuban": 156, "Czech": 167.22, "Danish": 168.7, "Dominican": 156.4, "Dutch": 169, "Egyptian": 158.9,
		"Emirati": 158.9, "Estonian": 165.5, "Ethiopian": 157.6, "Filipina": 151.8, "Finnish": 165.3, "French": 162.5,
		"German": 162.8, "Ghanan": 158.5, "Greek": 165, "Guatemalan": 147.3, "Haitian": 158.6, "Hungarian": 164,
		"Icelandic": 168, "Indian": 151.9, "Indonesian": 147, "Iranian": 157.2, "Iraqi": 155.8, "Irish": 163, "Israeli": 166,
		"Italian": 162.5, "Jamaican": 160.8, "Japanese": 158, "Jordanian": 158.2, "Kazakh": 159.8, "Kenyan": 159.4,
		"Korean": 156.15, "Lebanese": 165, "Libyan": 160.5, "Lithuanian": 167.5, "Malaysian": 154.7, "Malian": 160.4,
		"Mexican": 154, "Moroccan": 158.5, "Nepalese": 150.8, "Nigerian": 163.8, "Norwegian": 157.8, "Omani": undefined,
		"Pakistani": 151.9, "Peruvian": 151, "Polish": 165.1, "Portuguese": 165.1, "Puerto Rican": 158.9, "Romanian": 157,
		"Russian": 164.1, "Saudi": 156.3, "Scottish": 163, "Serbian": 166.8, "Slovak": 165.6, "South African": 159,
		"Spanish": 162.6, "Sudanese": 180.0, "Swedish": 166.8, "Swiss": 162.5, "Tanzanian": 156.6, "Thai": 159,
		"Tunisian": 160, "Turkish": 161.9, "Ugandan": 159.2, "Ukrainian": 164.8, "Uzbek": 159.9, "Venezuelan": 159,
		"Vietnamese": 155.2, "Yemeni": undefined, "a New Zealander": 164, "Zimbabwean": 160.3, "Sammarinese": undefined,
		"Marshallese": undefined, "Syrian": 156.3, "Bermudian": undefined, "Uruguayan": 158.0, "Monégasque": undefined,
		"Montenegrin": 168.4, "Cambodian": 152.4, "Cameroonian": 161.3, "Gabonese": 158.4, "Djiboutian": undefined,
		"Greenlandic": undefined, "Tuvaluan": undefined, "Zambian": 158.5, "Albanian": 161.8, "Bruneian": undefined,
		"Singaporean": 160.0, "Croatian": 166.3, "Bulgarian": 163.2, "Laotian": undefined, "Mongolian": 157.7, "Taiwanese": 160.4,
		"Belizean": undefined, "Grenadian": undefined, "I-Kiribati": undefined, "Malagasy": 154.3, "Maldivian": undefined,
		"Bosnian": 171.1, "Kosovan": undefined, "Macedonian": undefined, "Honduran": 152.0, "Maltese": 159.9,
		"Nauruan": undefined, "Micronesian": undefined, "Costa Rican": undefined, "Salvadoran": 160.3, "Nicaraguan": 153.7,
		"Panamanian": 152.0, "Nigerien": 157.8, "Andorran": undefined, "Bulgarian": 163.2, "Luxembourgian": 164.8,
		"Moldovan": 161.2, "Antiguan": 156.8, "Aruban": 158, "Azerbaijani": 162.4, "Bahamian": 157.1, "Bahraini": 165.2,
		"Barbadian": 158.5, "Bhutanese": 153.4, "Burundian": 157, "a Cook Islander": 162.3, "Cypriot": 163.4,
		"Dominiquais": 157.2, "East Timorese": 149.1, "Ecuadorian": 153.4, "Fijian": 161.8, "French Guianan": 157,
		"Georgian": 160.5, "Guyanese": 157.9, "Kuwaiti": 156.1, "Kyrgyz": 158.6, "Latvian": 165.7, "a Liechtensteiner": 164.3, 
		"Niuean": 160.4, "Palauan": 161.4, "Palestinian": 158.2, "Papua New Guinean": 150.7, "Paraguayan": 158.3, "Qatari": 155,
		"Kittitian": 156.9, "Saint Lucian": 157.1, "Vincentian": 156.2, "Samoan": 161, "Seychellois": 155.8,
		"Slovene": 167.4, "a Solomon Islander": 151.8, "Sri Lankan": 151.4, "Surinamese": 155.7, "Tajik": 161.2, "Tongan": 159.5,
		"Trinidadian": 157.4, "Turkmen": 158.2, "Ni-Vanuatu": 149.8, "Vatican": 162.5,
		"": 162.5 // default
	};
	const xyMeanHeight = {
		"American.white": 178.2, "American.black": 177.4, "American.latina": 172.5, "American.asian": 172.5, "American": 176.4, 
		"Afghan": undefined, "Algerian": 172.2, "Argentinian": 174.46, "Armenian": undefined, "Australian": 175.6,
		"Austrian": 179, "Bangladeshi": 150.8, "Belarusian": 176.9, "Belgian": 178.7, "Bolivian": 160, "Brazilian": 170.7,
		"British": 175.3, "Burmese": 168.0, "Canadian": 175.1, "Chilean": 169.6, "Chinese": 167.1, "Colombian": 170.6,
		"Congolese": 158.9, "Cuban": 168, "Czech": 180.31, "Danish": 180.4, "Dominican": 168.4, "Dutch": 181, "Egyptian": 170.3,
		"Emirati": 170.3, "Estonian": 179.1, "Ethiopian": undefined, "Filipina": 163.5, "Finnish": 178.9, "French": 175.6,
		"German": 175.4, "Ghanan": 169.5, "Greek": 177, "Guatemalan": 157.5, "Haitian": undefined, "Hungarian": 176,
		"Icelandic": 181, "Indian": 164.7, "Indonesian": 158, "Iranian": 170.3, "Iraqi": 165.4, "Irish": 177, "Israeli": 177,
		"Italian": 176.5, "Jamaican": 171.8, "Japanese": 172, "Jordanian": undefined, "Kazakh": 169, "Kenyan": 170.0,
		"Korean": 168.15, "Lebanese": 176, "Libyan": 171.3, "Lithuanian": 177.2, "Malaysian": 166.3, "Malian": 171.3,
		"Mexican": 167, "Moroccan": 172.7, "Nepalese": 163, "Nigerian": 163.8, "Norwegian": 179.63, "Omani": undefined,
		"Pakistani": 164.7, "Peruvian": 164, "Polish": 178.7, "Portuguese": 173.9, "Puerto Rican": 172.5, "Romanian": 172,
		"Russian": 177.2, "Saudi": 168.9, "Scottish": 177.6, "Serbian": 182, "Slovak": 179.4, "South African": 168,
		"Spanish": 173.1, "Sudanese": 190.0, "Swedish": 181.5, "Swiss": 178.2, "Tanzanian": undefined, "Thai": 170.3,
		"Tunisian": 172.3, "Turkish": 173.6, "Ugandan": undefined, "Ukrainian": 176.5, "Uzbek": 175.4, "Venezuelan": 169,
		"Vietnamese": 165.7, "Yemeni": 159.9, "a New Zealander": 177, "Zimbabwean": undefined, "Sammarinese": undefined,
		"Marshallese": undefined, "Syrian": 173.0, "Bermudian": undefined, "Uruguayan": 170.0, "Monégasque": undefined,
		"Montenegrin": 183.2, "Cambodian": 162.5, "Cameroonian": 170.6, "Gabonese": undefined, "Djiboutian": undefined,
		"Greenlandic": undefined, "Tuvaluan": undefined, "Zambian": undefined, "Albanian": 174.0, "Bruneian": undefined,
		"Singaporean": 170.6, "Andorran": undefined, "Croatian": 180.5, "Bulgarian": 175.2, "Laotian": 160.5,
		"Mongolian": 168.4, "Taiwanese": 171.4, "Belizean": undefined, "Grenadian": undefined, "I-Kiribati": undefined,
		"Malagasy": 161.5, "Maldivian": undefined, "Bosnian": 183.9, "Kosovan": undefined, "Macedonian": undefined,
		"Honduran": undefined, "Maltese": 169.9, "Nauruan": undefined, "Micronesian": undefined, "Costa Rican": undefined,
		"Salvadoran": undefined, "Nicaraguan": undefined, "Panamanian": 165.0, "Nigerien": 163.8, "Luxembourgian": 179.9,
		"Moldovan": undefined, ".white": 177.6, "Antiguan": 164.8, "Aruban": 165.1, "Azerbaijani": 171.8, "Bahamian": 167.2, "Bahraini": 165.1,
		"Barbadian": 169.3, "Bhutanese": 167.2, "Burundian": 164.1, "a Cook Islander": 173.4, "Cypriot": 177.2,
		"Dominiquais": 168.1, "East Timorese": 161.2, "Ecuadorian": 167.5, "Fijian": 170.4, "French Guianan": 168,
		"Georgian": 172.7, "Guyanese": 168.6, "Kuwaiti": 169.2, "Kyrgyz": 170.4, "Latvian": 174.2, "a Liechtensteiner": 175.4, 
		"Niuean": 169.7, "Palauan": 174.6, "Palestinian": 169.7, "Papua New Guinean": 163.5, "Paraguayan": 168.8, "Qatari": 166.2,
		"Kittitian": 164.4, "Saint Lucian": 168.3, "Vincentian": 165.4, "Samoan": 173, "Seychellois": 168.5,
		"Slovene": 180.3, "a Solomon Islander": 163.1, "Sri Lankan": 163.6, "Surinamese": 165.2, "Tajik": 175.2, "Tongan": 168.2,
		"Trinidadian": 170.8, "Turkmen": 171.4, "Ni-Vanuatu": 160.5, "Vatican": 176.5,
		"": 172.5 // defaults
	};
	
	// Helper method - table lookup for nationality/race combinations
	const nationalityMeanHeight = function(table, nationality, race, def) {
		return table[nationality + "." + race] || table[nationality] || table["." + race] || table[""] || def;
	};
	
	// Helper method - generate two independent Gaussian numbers using Box-Muller transform
	const 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)];
	};
	
	// Helper method: Generate a skewed normal random variable with the skew s
	// Reference: http://azzalini.stat.unipd.it/SN/faq-r.html
	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
	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.
	const heightGenerator = function(mean) {
		let result = mean * (1 + multGenerator() * spread);
		while(result < minHeight || result > maxHeight) {
			result = mean * (1 + multGenerator() * spread);
		}
		return result;
	};

	// Helper method - apply age and genes to the adult height
	const applyAge = function(height, age, genes) {
		if(!_.isFinite(age) || age < 2 || age >= 20) {
			return height;
		}
		let minHeight = 0, midHeight = 0, 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': // Kinefelter 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);
		}
	};
	
	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': // Kinefelter 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':
				console.log("Height.mean(): non-viable genes " + genes);
				break;
			default:
				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);
	};
	
	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);
	};
	
	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,
	};
})();

if(!Array.prototype.findIndex) {
	Array.prototype.findIndex = function(predicate) {
		if (this == null) {
			throw new TypeError('Array.prototype.find called on null or undefined');
		}
		if (typeof predicate !== 'function') {
			throw new TypeError('predicate must be a function');
		}
		var list = Object(this);
		var length = list.length >>> 0;
		var thisArg = arguments[1];
		var value;

		for (var i = 0; i < length; i++) {
			value = list[i];
			if (predicate.call(thisArg, value, i, list)) {
				return i;
			}
		}
		return -1;
	};
};

/*
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>>
	@@.red;weak@@
<<elseif _Slave.muscles > -96>>
	@@.red;weak+@@
<<else>>
	@@.red;weak++@@
<</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, '@@.red;weak@@'], [-95, '@@.red;weak+@@'], [-Infinity, '@@.red;weak++@@'])>>
<</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) {
	var result = def;
	if(typeof val === 'number' && !isNaN(val)) {
		var 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;
};

/*
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');