code:
/* config.history.tracking = false;*/
// State.expired.disable;

globalThis.peacekeepersCanBeEstablished = function() {
	return V.invasionVictory > 0 && App.Events.effectiveWeek() > 48;
};

/**
 * @param {number} x
 * @param {number} minValue
 * @param {number} maxValue
 * @param {number} [defaultValue=0]
 * @returns {number}
 */
globalThis.variableAsNumber = function(x, minValue, maxValue, defaultValue = 0) {
	x = Number(x);
	if (isNaN(x)) {
		return defaultValue;
	}
	if (x < minValue) { // Works even if minValue is undefined.
		return minValue;
	}
	if (x > maxValue) { // Works even if maxValue is undefined.
		return maxValue;
	}
	return x;
};

if (typeof interpolate === "undefined") {
	globalThis.interpolate = function(x0, y0, x1, y1, x) {
		if (x <= x0) {
			return y0;
		} else if (x >= x1) {
			return y1;
		} else {
			return (x - x0) * ((y1 - y0) / (x1 - x0)) + y0;
		}
	};
}

/**
 * @param {any[]} arr
 * @param {any} val
 * @returns {any[]}
 */
globalThis.removeFromArray = function(arr, val) {
	for (let i = 0; i < arr.length; i++) {
		if (val === arr[i]) {
			return arr.splice(i, 1);
		}
	}
	return null;
};

/**
 * @param {any[]} arr
 * @param {any} callback
 * @param {any} thisArg
 * @returns {Array}
 */
globalThis.filterInPlace = function(arr, callback, thisArg) {
	let j = 0;

	arr.forEach(function(e, i) {
		if (callback.call(thisArg, e, i, arr)) {
			arr[j++] = e;
		}
	});

	arr.length = j;
	return arr;
};

/** pregmod: are slave2's sperm compatible with slave1's eggs?
 * @param {FC.HumanState} slave1
 * @param {FC.HumanState|FC.AnimalState} slave2
 * @returns {boolean}
 */
globalThis.canBreed = function(slave1, slave2) {
	if (!slave1 || !slave2) {
		return null;
	}
	return (slave1.eggType === slave2.ballType);
};

/** assuming slave1 is fertile, could slave2 impregnate slave1?
 * slave2 must have dick and balls with compatible sperm;
 * both slaves must not be in chastity; slave2 need not achieve erection
 * @param {FC.HumanState} slave1
 * @param {FC.HumanState} slave2
 * @returns {boolean}
 */
globalThis.canImpreg = function(slave1, slave2) {
	if (!slave1 || !slave2) {
		return null;
	} else if (slave2.dick < 1) {
		return false;
	} else if (slave2.balls < 1) {
		return false;
	} else if (slave2.chastityPenis === 1) {
		return false;
	} else if (slave2.pubertyXY === 0) {
		/* pregmod start */
		return false;
	} else if (slave2.vasectomy === 1) {
		return false;
	} else if (!canBreed(slave1, slave2)) {
		return false; /* pregmod end */
	} else if (!canGetPregnant(slave1)) {
		/* includes chastity checks */
		return false;
	} else {
		return true;
	}
};

/** assuming slave1 is fertile, could slave2 impregnate slave1?
 * slave2 must have balls with compatible sperm;
 * a special function intended for tribbing scenes;
 * @param {FC.HumanState} slave1
 * @param {FC.HumanState} slave2
 * @returns {boolean}
 */
globalThis.canFemImpreg = function(slave1, slave2) {
	if (!slave1 || !slave2) {
		return null;
	} else if (slave2.balls < 1) {
		return false;
	} else if (slave2.chastityVagina === 1) {
		return false;
	} else if (slave2.pubertyXY === 0) {
		/* pregmod start */
		return false;
	} else if (slave2.vasectomy === 1) {
		return false;
	} else if (!canBreed(slave1, slave2)) {
		return false; /* pregmod end */
	} else if (!canGetPregnant(slave1)) {
		/* includes chastity checks */
		return false;
	} else {
		return true;
	}
};

/**
 * The amount of milk a slave produces in liters/week
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.milkAmount = function(slave) {
	let milk;
	let calcs = App.Medicine.fleshSize(slave, 'boobs');
	if (calcs > 40000) {
		milk = (158 + ((calcs - 40000) / 600));
	} else if (calcs > 25000) {
		milk = (128 + ((calcs - 25000) / 500));
	} else if (calcs > 10000) {
		milk = (78 + ((calcs - 10000) / 300));
	} else if (calcs > 5000) {
		milk = (53 + ((calcs - 5000) / 200));
	} else if (calcs > 2000) {
		milk = (29 + ((calcs - 2000) / 125));
	} else if (calcs > 800) {
		milk = (16 + ((calcs - 800) / 80));
	} else {
		milk = (8 + ((calcs - 400) / 50));
	}
	if (slave.boobsImplant > 0) {
		milk *= Math.max(0.25, (1 - (slave.boobsImplant / slave.boobs)));
	}
	if (slave.lactation === 2) {
		milk *= 1.2;
	}
	milk += (milk * ((slave.devotion - 50) / 200));
	calcs = (slave.hormoneBalance / 50);
	if (slave.balls !== 0 && calcs > -2) {
		calcs -= 1;
	} else if (slave.ovaries !== 1 && calcs < 2) {
		calcs += 1;
	}
	milk *= (1 + (calcs * 0.1));
	milk *= (1 + (slave.preg / 100));
	milk *= (1 + (slave.health.condition / 50));
	milk *= (1 + (slave.weight / 500));
	milk *= (1 + (slave.lactationAdaptation / 500));
	if (slave.geneMods.livestock === 1) {
		milk *= 1.1;
	}
	milk += (slave.boobsMilk / 100);
	milk *= healthPenalty(slave);
	milk = Math.trunc(milk);
	milk = Math.clamp(milk, 1, 1000000000000000000);
	return milk;
};

/**
 * The amount of milk a slave produces in liters in a single milking
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.milkLoad = function(slave) {
	return Math.trunc(milkAmount(slave) / 14);
};

/**
 * The amount of cum a slave produces in deciliters/week
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.cumAmount = function(slave) {
	let cum = 1;
	if (slave.vasectomy !== 1) {
		if (isVirile(slave)) {
			if (slave.drugs === "testicle enhancement") {
				cum += (slave.balls * 3.5);
			} else if (slave.drugs === "hyper testicle enhancement") {
				cum += (slave.balls * 5);
			} else {
				cum += (slave.balls * 2.5);
			}
			if (slave.scrotum === 0) {
				cum *= 0.8;
			}
			if (slave.geneMods.livestock === 1) {
				cum *= 1.5;
			}
			if (slave.diet === "cum production") {
				cum *= 1.2;
			}
		}
		if (slave.ballType === "sterile") {
			cum *= 0.8;
		} else if (slave.geneMods.aggressiveSperm === 1 && isVirile(slave)) {
			cum *= 0.5;
		}
	}

	const calcs = (slave.hormoneBalance / 50);
	cum *= (1 - (calcs * 0.1));

	if (slave.prostate === 0) {
		cum *= 0.2; // being generous here
	} else if (slave.prostate === 2) {
		cum *= 1.2;
	} else if (slave.prostate === 3) {
		cum *= 1.5;
	}

	if (slave.devotion > 50) {
		cum += (cum * (slave.devotion / 100));
	} else if (slave.devotion < -50) {
		cum += (cum * (slave.devotion / 100));
	}
	if (slave.health.condition > 50) {
		cum += (cum * (slave.health.condition / 50));
	} else if (slave.health.condition < -50) {
		cum += (cum * (slave.health.condition / 50));
	}
	cum *= healthPenalty(slave);
	cum = Math.trunc(cum);
	cum = Math.clamp(cum, 1, 1_000_000_000_000_000);
	return cum;
};

/**
 * The amount of cum a slave produces in liters in a single ejaculation
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.cumLoad = function(slave) {
	return Math.trunc(cumAmount(slave) / 70);
};

/**
 * The amount of fem cum a slave produces in deciliters
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.girlCumAmount = function(slave) {
	let fluid = ((slave.prostate * (slave.energy / 5)) + 1);
	if (slave.vagina >= 0) {
		if (slave.vaginaLube === 2) {
			fluid *= 1.3;
		} else if (slave.vaginaLube === 1) {
			fluid *= 1.1;
		}
	}
	if (slave.energy > 10) {
		if (slave.health.condition > 50) {
			fluid *= (slave.health.condition / 50);
		} else if (slave.health.condition < -50) {
			fluid *= (1 + (slave.health.condition / 50));
		}
	}
	if (slave.geneMods.livestock === 1) {
		fluid *= 1.2;
	}
	fluid = Math.trunc(fluid);
	if (fluid < 1) {
		fluid = 1;
	}
	return fluid;
};

/**
 * @param {string} text
 * @returns {string}
 */
globalThis.lispReplace = function(text) {
	text = text.replace(/Sh/g, "Th");
	text = text.replace(/SS/g, "Th");
	text = text.replace(/Ss/g, "Th");
	text = text.replace(/sS/g, "Th");
	text = text.replace(/S/g, "Th");
	text = text.replace(/aX/g, "aKTH");
	text = text.replace(/eX/g, "eKTH");
	text = text.replace(/iX/g, "iKTH");
	text = text.replace(/oX/g, "oKTH");
	text = text.replace(/uX/g, "uKTH");
	text = text.replace(/yX/g, "yKTH");
	text = text.replace(/AX/g, "AKTH");
	text = text.replace(/EX/g, "EKTH");
	text = text.replace(/IX/g, "IKTH");
	text = text.replace(/OX/g, "OKTH");
	text = text.replace(/UX/g, "UKTH");
	text = text.replace(/YX/g, "YKTH");
	text = text.replace(/Xa/g, "Tha");
	text = text.replace(/Xe/g, "The");
	text = text.replace(/Xi/g, "Thi");
	text = text.replace(/Xo/g, "Tho");
	text = text.replace(/Xu/g, "Thu");
	text = text.replace(/Xy/g, "Thy");
	text = text.replace(/XA/g, "THA");
	text = text.replace(/XE/g, "THE");
	text = text.replace(/XI/g, "THI");
	text = text.replace(/XO/g, "THO");
	text = text.replace(/XU/g, "THU");
	text = text.replace(/XY/g, "THY");
	text = text.replace(/X/g, "EKTH");
	text = text.replace(/zZ/g, "Th");
	text = text.replace(/Zz/g, "Th");
	text = text.replace(/ZZ/g, "TH");
	text = text.replace(/Z/g, "Th");
	text = text.replace(/Cia/g, "Tha");
	text = text.replace(/Ci/g, "Thi");
	text = text.replace(/Ce/g, "The");
	text = text.replace(/Cy/g, "Thy");
	text = text.replace(/CIA/g, "THA");
	text = text.replace(/CI/g, "THI");
	text = text.replace(/CE/g, "THE");
	text = text.replace(/CY/g, "THY");
	text = text.replace(/ss/g, "th");
	text = text.replace(/sh/g, "th");
	text = text.replace(/s/g, "th");
	text = text.replace(/zz/g, "th");
	text = text.replace(/z/g, "th");
	text = text.replace(/ax/g, "akth");
	text = text.replace(/ex/g, "ekth");
	text = text.replace(/ix/g, "ikth");
	text = text.replace(/ox/g, "okth");
	text = text.replace(/ux/g, "ukth");
	text = text.replace(/yx/g, "ykth");
	text = text.replace(/Ax/g, "Akth");
	text = text.replace(/Ex/g, "Ekth");
	text = text.replace(/Ix/g, "Ikth");
	text = text.replace(/Ox/g, "Okth");
	text = text.replace(/Ux/g, "Ukth");
	text = text.replace(/Yx/g, "Ykth");
	text = text.replace(/xa/g, "tha");
	text = text.replace(/xe/g, "the");
	text = text.replace(/xi/g, "thi");
	text = text.replace(/xo/g, "tho");
	text = text.replace(/xu/g, "thu");
	text = text.replace(/xy/g, "thy");
	text = text.replace(/xA/g, "thA");
	text = text.replace(/xE/g, "thE");
	text = text.replace(/xI/g, "thI");
	text = text.replace(/xO/g, "thO");
	text = text.replace(/xU/g, "thU");
	text = text.replace(/xY/g, "thY");
	text = text.replace(/x/g, "ekth");
	text = text.replace(/cia/g, "tha");
	text = text.replace(/ci/g, "thi");
	text = text.replace(/ce/g, "the");
	text = text.replace(/cy/g, "thy");
	text = text.replace(/cI/g, "thI");
	text = text.replace(/cE/g, "thE");
	text = text.replace(/cY/g, "thY");
	return text;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {object} arcology
 * @returns {number}
 */
globalThis.repGainSacrifice = function(slave, arcology) {
	if (!slave || !arcology || !FutureSocieties.isActive('FSAztecRevivalist', arcology) || arcology.FSAztecRevivalist <= 0) {
		return 0;
	}
	return Math.ceil(
		(Math.min(100, Math.pow(1.0926, V.week - slave.weekAcquired)) + slave.prestige * 30) * arcology.FSAztecRevivalist / 100 / ((V.slavesSacrificedThisWeek || 0) + 1));
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
globalThis.bodyguardSuccessorEligible = function(slave) {
	if (!slave) {
		return false;
	}
	return (slave.devotion > 50 && slave.muscles >= 0 && slave.weight < 130 && slave.boobs < 8000 && slave.butt < 10 && slave.belly < 5000 && slave.balls < 10 && slave.dick < 10 && slave.preg < 20 && slave.fuckdoll === 0 && slave.fetish !== Fetish.MINDBROKEN && canWalk(slave) && canHold(slave) && canSee(slave) && canHear(slave));
};

/**
 * @param {any} obj
 * @returns {string}
 */
globalThis.toJson = function(obj) {
	let jsontext = JSON.stringify(obj);
	jsontext = jsontext.replace(/^{/, "");
	jsontext = jsontext.replace(/}$/, "");
	return jsontext;
};

/**
 * @param {FC.HumanState} slave
 * @returns {string}
 */
globalThis.nippleColor = function(slave) {
	if (skinToneLevel(slave.skin) < 8) {
		if (slave.preg > slave.pregData.normalBirth / 4 || (slave.counter.birthsTotal > 0 && slave.lactation > 0)) {
			return "brown";
		} else {
			return "pink";
		}
	} else if (skinToneLevel(slave.skin) < 14) {
		if (slave.preg > slave.pregData.normalBirth / 4 || (slave.counter.birthsTotal > 0 && slave.lactation > 0)) {
			return "dark brown";
		} else {
			return "pink";
		}
	} else if (skinToneLevel(slave.skin) > 20) {
		if (slave.preg > slave.pregData.normalBirth / 4 || (slave.counter.birthsTotal > 0 && slave.lactation > 0)) {
			return "black";
		} else {
			return "dark brown";
		}
	} else {
		if (slave.preg > slave.pregData.normalBirth / 4 || (slave.counter.birthsTotal > 0 && slave.lactation > 0)) {
			return "dark brown";
		} else {
			return "brown";
		}
	}
};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {App.Entity.PlayerState} PC
 * @returns {number}
 */
globalThis.overpowerCheck = function(slave, PC) {
	let strength = 0;
	strength += (PC.muscles - slave.muscles);
	strength += (PC.height - slave.height);
	strength += ((PC.weight / 5) - (slave.weight / 5));
	strength -= (PC.bellyPreg / 1000);
	strength += (PC.skill.combat - slave.skill.combat);
	// strength -= (PC.health.shortDamage);
	strength -= (PC.health.longDamage);
	strength -= (PC.health.illness * 10);
	if (!canMove(V.PC)) {
		strength -= 100;
	} else if (!canWalk(V.PC)) {
		strength -= 50;
	}

	return strength;
};

/**
 * returns the degree than the intimidatee is intimidated by the intimidater.
 * @param {FC.HumanState} intimidatee
 * @param {FC.HumanState} intimidater
 * @returns {number}
 */
globalThis.intimidationDegree = function(intimidatee, intimidater) { // TODO: this goes in saDevotion. MAKE SURE YOU PUT IT THERE.
	let degree = 0;

	if (intimidater.ID === -1) {
		if (isPCCareerInCategory("servant") || isPCCareerInCategory("escort")) {
			degree -= 1;
		} else if (isPCCareerInCategory("slaver") || isPCCareerInCategory("gang") || isPCCareerInCategory("mercenary")) {
			degree += 1;
		}
		if (intimidater.rumor === "force") {
			degree += 1;
		}
	}
	if (!canWalk(intimidater) && canWalk(intimidatee)) {
		degree -= 2;
	} else if (canWalk(intimidater) && !canWalk(intimidatee)) {
		degree += 2;
	}
	if (intimidater.visualAge < intimidatee.visualAge && intimidater.visualAge < 18) {
		degree--;
		if (intimidater.visualAge < 13) {
			degree -= 3;
		}
	}
	if (intimidater.muscles >= intimidatee.muscles + 10 && intimidater.muscles >= 0) {
		degree++;
	} else if (intimidatee.muscles >= intimidater.muscles + 10) {
		degree--;
	}
	if (intimidater.teeth === "pointy") {
		degree++;
	}
	if (degree > 0 && intimidater.height > intimidatee.height) {
		degree++;
	} else if (degree < 0 && intimidater.height < intimidatee.height) {
		degree--;
	}
	if (getLeftArmID(intimidater) >= 5 || getRightArmID(intimidater) >= 5) {
		degree += 10;
	}
	if (intimidater.bellyPreg >= 5000) {
		degree -= Math.min(Math.trunc(intimidater.bellyPreg / 5000), 5);
	}

	return degree;
};

/**
 * returns if arg2 can pick up and carry arg1
 * @param {FC.HumanState} liftee
 * @param {FC.HumanState} lifter
 * @returns {boolean}
 */
globalThis.canLift = function(liftee, lifter) {
	let sumWeight;

	if (lifter.belly >= 10000) {
		return false;
	}
	if (lifter.boobs >= 15000 || lifter.boobsImplants >= 10000) {
		return false;
	}
	sumWeight = liftee.height - lifter.height;
	sumWeight += liftee.weight / 2;
	sumWeight += liftee.boobs / 500;
	sumWeight += liftee.belly / 1000;
	sumWeight += liftee.butt / 2;
	if (liftee.hips > 0) {
		sumWeight += Math.pow(liftee.hips, 2);
	}
	if (liftee.dick >= 20) {
		sumWeight += Math.trunc(liftee.dick / 10);
	}
	if (liftee.balls >= 10) {
		sumWeight += Math.pow(Math.trunc(liftee.balls / 10), 3);
	}

	return 20 + lifter.muscles >= sumWeight && hasBothArms(lifter);
};

/**
 * returns array of IDs of all characters who impregnated slave
 * @param {FC.HumanState} slave
 * @param {boolean} [genepool=false]
 * @returns {number[]}
 */
globalThis.impregnatedBy = function(slave, genepool = false) {
	const IDArray = [];
	if (!Array.isArray(slave.womb)) {
		if (genepool) {
			slave.womb = [];
		} else {
			WombInit(slave);
		}
	}
	for (let i = 0; i < slave.womb.length; i++) {
		IDArray.push(slave.womb[i].fatherID);
	}
	return IDArray;
};

/**
 * returns true if mother was impregnated by father
 * @param {FC.HumanState} mother
 * @param {FC.HumanState} father
 * @param {boolean} [genepool=false]
 * @returns {boolean}
 */
globalThis.isImpregnatedBy = function(mother, father, genepool = false) {
	return impregnatedBy(mother, genepool).includes(father.ID);
};

/**
 * @param {App.Entity.SlaveState} slave
 */
globalThis.SoftenBehavioralFlaw = function(slave) {
	switch (slave.behavioralFlaw) {
		case "arrogant":
			slave.behavioralQuirk = "confident";
			break;
		case "bitchy":
			slave.behavioralQuirk = "cutting";
			break;
		case "odd":
			slave.behavioralQuirk = "funny";
			break;
		case "hates men":
			slave.behavioralQuirk = "adores women";
			break;
		case "hates women":
			slave.behavioralQuirk = "adores men";
			break;
		case "gluttonous":
			slave.behavioralQuirk = "fitness";
			break;
		case "anorexic":
			slave.behavioralQuirk = "insecure";
			break;
		case "devout":
			slave.behavioralQuirk = "sinful";
			break;
		case "liberated":
			slave.behavioralQuirk = "advocate";
			break;
	}
	slave.behavioralFlaw = "none";
};

/**
 * @param {App.Entity.SlaveState} slave
 */
globalThis.SoftenSexualFlaw = function(slave) {
	switch (slave.sexualFlaw) {
		case "hates oral":
			slave.sexualQuirk = "gagfuck queen";
			break;
		case "hates anal":
			slave.sexualQuirk = "painal queen";
			break;
		case "hates penetration":
			slave.sexualQuirk = "strugglefuck queen";
			break;
		case "shamefast":
			slave.sexualQuirk = "tease";
			break;
		case "idealistic":
			slave.sexualQuirk = "romantic";
			break;
		case "repressed":
			slave.sexualQuirk = "perverted";
			break;
		case "apathetic":
			slave.sexualQuirk = "caring";
			break;
		case "crude":
			slave.sexualQuirk = "unflinching";
			break;
		case "judgemental":
			slave.sexualQuirk = "size queen";
			break;
	}
	slave.sexualFlaw = "none";
};

/**
 * @param {App.Entity.PlayerState} PC
 */
globalThis.generatePlayerPronouns = function(PC) {
	if (PC.title === 0) {
		PC.pronoun = App.Data.Pronouns.Kind.female;
	} else {
		PC.pronoun = App.Data.Pronouns.Kind.male;
	}
};

/**
 * @param {number} nmbr1
 * @param {number} nmbr2
 */
globalThis.pregNumberName = function(nmbr1, nmbr2) {
	let pt = "";
	let p1 = nmbr1 % 10;
	let p2 = ((nmbr1 % 100) - (nmbr1 % 10)) / 10;
	let p3 = ((nmbr1 % 1000) - (nmbr1 % 100)) / 100;
	switch (nmbr1) {
		case 1:
			pt += "bab";
			break;
		case 2:
			pt += "twin";
			break;
		default:
			switch (nmbr1) {
				case 3:
					pt += "tri";
					break;
				case 4:
					pt += "quadru";
					break;
				case 5:
					pt += "quintu";
					break;
				case 6:
					pt += "sextu";
					break;
				case 7:
					pt += "septu";
					break;
				case 8:
					pt += "octu";
					break;
				case 9:
					pt += "nonu";
					break;
				default:
					switch (p1) {
						case 1:
							pt += "un";
							break;
						case 2:
							pt += "duo";
							break;
						case 3:
							pt += "tre";
							break;
						case 4:
							pt += "quattuor";
							break;
						case 5:
							pt += "quin";
							break;
						case 6:
							pt += "sex";
							break;
						case 7:
							pt += "septen";
							break;
						case 8:
							pt += "octo";
							break;
						case 9:
							pt += "novem";
							break;
					}
					switch (p2) {
						case 1:
							pt += "dec";
							break;
						case 2:
							pt += "vigint";
							break;
						case 3:
							pt += "trigint";
							break;
						case 4:
							pt += "quadragint";
							break;
						case 5:
							pt += "quinquagint";
							break;
						case 6:
							pt += "sexagint";
							break;
						case 7:
							pt += "septuagint";
							break;
						case 8:
							pt += "octogint";
							break;
						case 9:
							pt += "nonagint";
							break;
					}
					if (nmbr1 >= 100) {
						if (p2 !== 0) {
							pt += "i";
						}
						switch (p3) {
							case 1:
								pt += "centu";
								break;
							case 2:
								pt += "bicentu";
								break;
							case 3:
								pt += "tricentu";
								break;
							case 4:
								pt += "quadricentu";
								break;
							case 5:
								pt += "quincentu";
								break;
							case 6:
								pt += "sexcentu";
								break;
							case 7:
								pt += "septemcentu";
								break;
							case 8:
								pt += "octocentu";
								break;
							case 9:
								pt += "novemcentu";
								break;
						}
					} else {
						pt += "u";
					}
			}
			pt += "plet";
	}
	if (nmbr2 === 2) {
		if (nmbr1 === 1) {
			pt += "ie";
		}
		pt += "s";
	} else {
		if (nmbr1 === 1) {
			pt += "y";
		}
	}
	return pt;
};

/** Returns the description of the part of body a slave/PC uses to penetrate, if they can, or a description for "dildo"
 * @param {FC.HumanState} slave
 * @param {boolean} includeClit
 * @returns {string}
 */
globalThis.penetrationTool = function(slave, includeClit = true) {
	if (!slave || !slave.hasOwnProperty("dick")) {
		return "ERROR";
	}
	if (slave.dick > 0 && canPenetrate(slave)) {
		return dickDesc(slave);
	}
	if (slave.clit >= 3 && includeClit) {
		return clitDesc(slave);
	}
	let adj = ["big", "fat", "huge", "large", "long", "realistic"];
	let use = (slave.vagina === 0) ? ["strap-on", "", ""] : ["double-sided", "strap-on double", "double-headed", "double-ended", "strapless double"];
	let noun = ["dildo", "dildo", "dildo", "dildo", "fake cock", "vibrator", "vibrator"];
	return `${jsEither(adj)} ${jsEither(use)} ${jsEither(noun)}`;
};

/** Is the dick too big (1), too small(-1), or just right(0)?
 * @param {FC.HumanState} catcher
 * @param {FC.HumanState} pitcher
 * @param {string} hole
 * @returns {number}
 */
globalThis.relativeDickSize = function(catcher, pitcher, hole) {
	let size = 0;
	if (pitcher.dick === 1 || (pitcher.dick < 3 && catcher.physicalAge > 12) || (catcher[hole] >= pitcher.dick + 3)) {
		size = -1;
	}
	if (pitcher.dick > 5 || (pitcher.dick - catcher[hole] > 3) || (catcher.physicalAge <= 12 && pitcher.dick > 2)) {
		size = 1;
	}
	if (catcher.sexualQuirk === SexualQuirk.SIZEQUEEN) {
		if (pitcher.dick < 4) {
			size = -1;
		} else if (size !== -1) {
			size = 0;
		}
	}
	return size;
};


code:
/**
 * @param {App.Entity.SlaveState} slave
 */
globalThis.SlaveStatClamp = function(slave) {
	slave.energy = Math.clamp(slave.energy, 0, 100);

	if (slave.devotion > 100) {
		if (slave.trust < -95) {
			slave.trust = -100;
		} else if (slave.trust < 100 && slave.trust > 20) {
			slave.trust += Math.trunc((slave.devotion - 100) * 5) / 10;
		} else {
			repX(4 * (slave.devotion - 100), "slavesViewOfPC", slave);
		}
		slave.devotion = 100;
	} else if (slave.devotion < -95) {
		slave.devotion = -100;
	}
	if (slave.trust > 100) {
		if (slave.devotion < -95) {
			slave.devotion = -100;
		} else if (slave.devotion < 100 && slave.devotion > 20) {
			slave.devotion += Math.trunc(slave.trust - 100);
		} else {
			repX(4 * (slave.trust - 100), "slavesViewOfPC", slave);
		}
		slave.trust = 100;
	} else if (slave.trust < -95) {
		slave.trust = -100;
	}
	if (slave.trust < -100) {
		slave.trust = -100;
	}
	if (slave.devotion < -100) {
		slave.devotion = -100;
	}
	if (isNaN(slave.counter.oral)) {
		slave.counter.oral = 0;
		alert("Oral count has broken, report what you just did!");
	}
	if (isNaN(slave.counter.anal)) {
		slave.counter.anal = 0;
		alert("Anal count has broken, report what you just did!");
	}
	if (isNaN(slave.counter.vaginal)) {
		slave.counter.vaginal = 0;
		alert("Vaginal count has broken, report what you just did!");
	}
	if (isNaN(slave.counter.mammary)) {
		slave.counter.mammary = 0;
		alert("Titfuck count has broken, report what you just did!");
	}
	if (isNaN(slave.counter.penetrative)) {
		slave.counter.penetrative = 0;
		alert("Penetration count has broken, report what you just did!");
	}
	if (slave.foreskin === undefined) {
		slave.foreskin = 0;
	}
	if (!hasAnyNaturalLegs(slave) && slave.heels === 1) {
		slave.heels = 0;
	}
	if (slave.vagina < 0 && slave.mpreg === 0 && slave.preg === -1) {
		slave.preg = 0;
		WombFlush(slave);
	}
	if ((slave.rules.lactation === "induce" && slave.lactation !== 0) || (slave.rules.lactation === "maintain" && slave.lactation !== 1)) {
		slave.rules.lactation = "none";
	}
};

repo_name: Arkerthan/fc

license: mit


code:
App.Underperformers = {};

/** Select only slaves which are not reasonably expected to produce any income (brand new slaves, servants, fucktoys, etc)
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
App.Underperformers.expectIncome = function(slave) {
	const productionJobs = [Job.ARCADE, Job.MILKED, Job.WHORE, Job.GLORYHOLE, Job.BROTHEL, Job.DAIRY, Job.CHOICE];
	return productionJobs.includes(slave.assignment) && ((slave.weekAcquired + 1) < V.week) && _.isFinite(slave.lastWeeksCashIncome);
};

App.Underperformers.highSale = function() {
	const description = App.UI.DOM.makeElement("div", "Take the rough value of a slave and divide it by how much they made overall last week. This will tell you how many weeks it might take them to earn the same amount you'd get for selling them right now.", "note");

	const frag = App.UI.SlaveList.render(
		getBestSlavesIDs(
			{
				part: (slave) => {
					const ratio = slaveCost(slave) / (slave.lastWeeksCashIncome - getSlaveCost(slave));
					return ratio > 0 ? ratio : 100000000 + ratio;
				},
				count: V.underperformersCount,
				filter: App.Underperformers.expectIncome
			}
		),
		[],
		App.UI.SlaveList.SlaveInteract.stdInteract,
		(slave) => $(document.createDocumentFragment()).append(
			`Worth ${cashFormatColor(slaveCost(slave))} / Nets ${cashFormatColor(slave.lastWeeksCashIncome - getSlaveCost(slave))} a week = ${(Math.trunc(slaveCost(slave) / (slave.lastWeeksCashIncome - getSlaveCost(slave)))) > 0 ? (Math.trunc(slaveCost(slave) / (slave.lastWeeksCashIncome - getSlaveCost(slave)))) : "infinite"} weeks`
		).get(0)
	);

	frag.prepend(description);
	return frag;
};

App.Underperformers.expensive = function() {
	const description = App.UI.DOM.makeElement("div", "This list looks for moochers by weighing their weekly income against the weekly cost of providing for them.", "note");

	const frag = App.UI.SlaveList.render(
		getBestSlavesIDs(
			{
				part: (slave) => (slave.lastWeeksCashIncome - getSlaveCost(slave)),
				largest: false,
				count: V.underperformersCount,
				filter: App.Underperformers.expectIncome
			}
		),
		[],
		App.UI.SlaveList.SlaveInteract.stdInteract,
		(slave) => $(document.createDocumentFragment()).append(
			`${cashFormatColor(Math.trunc(slave.lastWeeksCashIncome - getSlaveCost(slave)))} net last week`
		).get(0)
	);

	frag.prepend(description);
	return frag;
};

App.Underperformers.passage = function() {
	const node = new DocumentFragment();
	// App.UI.DOM.appendNewElement("h1", node, `Underperforming Slaves`);
	const r = [];
	r.push(App.UI.DOM.makeElement("div", `${properMaster()}, while many of your slaves work hard to earn ¤ each week, some succeed more than others. As a trader in slaves, you may appreciate the opportunity that comes when a particularly valuable slave didn't earn very much last week. Or perhaps you just want the chance to tweak these problem slaves and train them to be better? The choice is yours.`));
	if (V.slaveCostFactor > 1.1) {
		r.push(`Since there is a bull market for slaves, <span class="green">this is a great time to sell.</span>`);
	} else if (V.slaveCostFactor > 1) {
		r.push(`Since the slave market is bullish; <span class="green">this is a pretty good time to sell.</span>`);
	} else if (V.slaveCostFactor < 0.9) {
		r.push(`Since there is a bear market for slaves, <span class="red">this is a poor time to sell.</span>`);
	} else if (V.slaveCostFactor < 1) {
		r.push(`Since the slave market is bearish; <span class="red">this is a terrible time to sell.</span>`);
	} else {
		r.push(`Since the slave market is stable; <span class="yellow">prices are average.</span>`);
	}
	App.Events.addParagraph(node, r);

	const tabBar = new App.UI.Tabs.TabBar("underperformingSlaves");
	tabBar.addTab("Worth much but earning little", "little", App.Underperformers.highSale());
	tabBar.addTab("Costing vs earning", "earning", App.Underperformers.expensive());
	node.append(tabBar.render());

	const g = new App.UI.OptionsGroup();
	g.addOption("Maximum number of slaves", "underperformersCount")
		.addValue("Default", 7).showTextBox();
	node.append(g.render());

	return node;
};


code:
/** @implements {FC.IUpgrade} */
App.Upgrade = class Upgrade {
	/**
	 * @param {string} property The variable name of the property.
	 * @param {FC.IUpgradeTier[]} tiers A list of tiers available for the upgrade.
	 * @param {object} [object] Any object to attach the upgrade to, if not the default `V`.
	 */
	constructor(property, tiers, object = V) {
		/** @private */
		this._property = property;

		/** @private */
		this._div = document.createElement("div");
		/** @private @type {Object} */
		this._object = object || V;
		/** @private @type {FC.IUpgradeTier[]} */
		this._tiers = tiers;
	}

	/**
	 * Puts the different sections together into one passage.
	 *
	 * @private
	 * @returns {DocumentFragment}
	 */
	 _assemble() {
		const frag = new DocumentFragment();

		this.tiers.forEach(tier => {
			const {
				value, link, text, upgraded, handler, notes, prereqs, nodes,
			} = tier;

			const cost = Math.trunc(tier.cost) || 0;

			if ((!prereqs || prereqs.every(prereq => prereq === true)) &&
				_.isEqual(value, this._object[this._property])) {
				App.UI.DOM.appendNewElement("div", frag, text);

				if (link) {
					App.UI.DOM.appendNewElement("div", frag, makePurchase(link, cost, "capEx", {
						notes,
						handler: () => {
							this._object[this._property] = upgraded;

							if (handler) {
								handler();
							}
						},
					}));
				}

				if (nodes) {
					App.Events.addNode(frag, nodes);
				}
			}
		});

		return frag;
	}

	/**
	 * Renders the upgrade on-screen.
	 *
	 * @returns {HTMLDivElement}
	 */
	 render() {
		this._div.append(this._assemble());

		return this._div;
	}

	/**
	 * Refreshes the upgrade on-screen.
	 *
	 * @returns {void}
	 */
	 refresh() {
		App.UI.DOM.replace(this._div, this._assemble());
	}

	/**
	 * Adds new tiers to the upgrade.
	 *
	 * @param {FC.IUpgradeTier[]} tiers
	 * @returns {this}
	 */
	addTiers(...tiers) {
		this._tiers.push(...tiers);

		return this;
	}

	/**
	 * The variable name of the property.
	 *
	 * @returns {string}
	 */
	get property() {
		return this._property;
	}

	/**
	 * All tiers that are available.
	 *
	 * @returns {FC.IUpgradeTier[]}
	 */
	get tiers() {
		return this._tiers;
	}

	/**
	 * The object the upgrade is attached to.
	 *
	 * @returns {object}
	 */
	get object() {
		return this._object;
	}
};


code:
/** Returns the revivalist nationality associated with the player's arcology, or 0 if none
 * @returns {string|0}
 */
globalThis.getRevivalistNationality = function() {
	for (const fsKey of Object.keys(App.Data.FutureSociety.records)) {
		if (V.arcologies[0][fsKey] > 90) {
			const nationality = App.Data.FutureSociety.records[fsKey].nationality;
			if (nationality) {
				return nationality;
			}
		}
	}
	return 0;
};

/** Calculate and return economic uncertainty multiplier for a given arcology
 * @param {number} arcologyID
 * @returns {number}
 */
App.Utils.economicUncertainty = function(arcologyID) {
	let uncertainty = arcologyID === 0 ? 5 : 10;
	if (assistant.power === 1) {
		uncertainty -= Math.max(Math.trunc(uncertainty / 2), 0);
	} else if (assistant.power > 1) {
		uncertainty = 0;
	}
	return jsRandom(100 - uncertainty, 100 + uncertainty) / 100;
};

/**
 * @returns {number}
 */
App.Utils.schoolCounter = function() {
	return Array.from(App.Data.misc.schools.keys()).filter(s => V[s].schoolPresent).length;
};

/**
 * @typedef {object} menialObject
 * @property {string} text
 * @property {number} value
 */

/**
 * @returns {menialObject}
 */
globalThis.menialPopCap = function() {
	let r = "";

	let popCap = 500 * (1 + V.building.findCells(cell => cell instanceof App.Arcology.Cell.Manufacturing && cell.type === "Pens").length);

	let overMenialCap = V.menials + V.fuckdolls + V.menialBioreactors - popCap;
	if (overMenialCap > 0) {
		const price = menialSlaveCost(-overMenialCap);
		if (V.menials > 0) {
			if (V.menials > overMenialCap) {
				cashX((overMenialCap * price), "menialTrades");
				V.menialDemandFactor -= overMenialCap;
				V.menials -= overMenialCap;
				overMenialCap = 0;
				r += "You don't have enough room for all your menials and are obliged to sell some.";
			} else {
				cashX((V.menials * price), "menialTrades");
				V.menialDemandFactor -= V.menials;
				overMenialCap -= V.menials;
				V.menials = 0;
				r += "You don't have enough room for your menials and are obliged to sell them.";
			}
		}
		if (overMenialCap > 0 && V.fuckdolls > 0) {
			if (V.fuckdolls > overMenialCap) {
				cashX(overMenialCap * (price * 2), "menialTrades");
				V.menialDemandFactor -= overMenialCap;
				V.fuckdolls -= overMenialCap;
				overMenialCap = 0;
				r += "You don't have enough room for all your Fuckdolls and are obliged to sell some.";
			} else {
				cashX(V.fuckdolls * (price * 2), "menialTrades");
				V.menialDemandFactor -= V.fuckdolls;
				overMenialCap -= V.fuckdolls;
				V.fuckdolls = 0;
				r += "You don't have enough room for your Fuckdolls and are obliged to sell them.";
			}
		}
		if (overMenialCap > 0 && V.menialBioreactors > 0) {
			cashX(overMenialCap * (price - 100), "menialTrades");
			V.menialDemandFactor -= overMenialCap;
			V.menialBioreactors -= overMenialCap;
			r += "You don't have enough room for all your menial bioreactors and are obliged to sell some.";
		}
	}
	return {text: r, value: popCap};
};

/**
 * Returns the percentage (0-100) of the social acceptance of the slaves penetrating citizens in YOUR arcology. Accepts a slave as argument.
 *
 * @param {FC.SlaveState} slave
 * @returns {number}
 */
globalThis.penetrativeSocialUse = function(slave = null) {
	let total = 10;
	if (V.policies.sexualOpenness) {
		total += 40 + 20 * (Math.clamp(V.rep - 5000, 0, 12000) / 12000); // penetrative campaign, related to reputation, (40 to 60)
	}
	total += V.LDE.schoolPresent * 3 + V.TGA.schoolPresent * 3 + V.TFS.schoolPresent * 10; // slave schools focused on penises
	total += FutureSocieties.isActive("FSGenderRadicalist") ? 25 : 0;
	total += [0, 15, 20, 0, 5][V.arcologies[0].FSGenderRadicalistLawFuta]; // 15 futas, 20 big dicks & balls, 5 small dicks
	total += FutureSocieties.isActive("FSNull") ? 15 : 0;
	total += FutureSocieties.isActive("FSHedonisticDecadence") ? 15 : 0;
	total += FutureSocieties.isActive("FSRepopulationFocus") ? 5 : 0;
	total += V.arcologies[0].FSRepopulationFocusLaw * 10;

	total -= FutureSocieties.isActive("FSGenderFundamentalist") ? 25 : 0;
	total -= V.arcologies[0].FSGenderFundamentalistSMR ? 30 : 0;
	total -= FutureSocieties.isActive("FSEdoRevivalist") ? 10 : 0;
	total -= FutureSocieties.isActive("FSArabianRevivalist") ? 10 : 0;
	total -= FutureSocieties.isActive("FSChineseRevivalist") ? 10 : 0;
	total -= V.arcologies[0].FSRestartSMR ? 30 : 0;
	total -= FutureSocieties.isActive("FSAztecRevivalist") ? 10 : 0;
	total -= FutureSocieties.isActive("FSNeoImperialist") ? 10 : 0;
	total -= FutureSocieties.isActive("FSAntebellumRevivalist") ? 10 : 0;

	total = Math.clamp(total, 0, 100);

	if (slave && asSlave(slave)) {
		total += slave.toyHole === "dick" ? 5 : 0;
		total += Math.trunc(slave.skill.penetrative / 20); // 10 changed to 20 to avoid unskilled giving 1 at 10. This may be better as a quadratic function to make higher values more impactful.
		if (FutureSocieties.isActive("FSSupremacist")) {
			total += V.arcologies[0].FSSupremacistRace === slave.race ? 10 : -20;
		}
		if (FutureSocieties.isActive("FSSubjugationist")) {
			total += V.arcologies[0].FSSubjugationistRace === slave.race ? 20 : 0;
		}
		if (FutureSocieties.isActive("FSRestart")) {
			total -= isVirile(slave) ? 40 : 0;
		}
		total += slave.prestige * 5;
		total += Math.pow(slave.porn.prestige, 3);
		// in the future, consider some sort of penetrative pornstar bonus here
	}

	if (V.seeDicks === 0 && total > 0) {
		total *= .6;
	} else if (V.seeDicks > 25) {
		total += V.seeDicks < 75 ? V.seeDicks * .10 : V.seeDicks * .08;
	}

	if (total >= 40) { // Once dicks are trendy, availability starts to increase interest
		let haveDick = V.slaves.filter(s => s.dick > 0).length;
		if (haveDick) {
			let withDick = (haveDick / V.slaves.length); // percentage of slaves with dicks
			total += withDick > .2 ? 15 * withDick : 0;
			let toyHole = V.slaves.filter(s => s.toyHole === "dick").length;
			total += 10 * (toyHole / haveDick);
			let penetrativeAverage = 0;
			V.slaves.filter(s => s.dick > 0).forEach(s1 => {
				penetrativeAverage += s1.skill.penetrative;
			});
			total += (penetrativeAverage / haveDick) * .1;
		}
	}

	total = Math.clamp(Math.floor(total), 0, 100);
	return total;
};

App.Utils.Arcology = class {
	/**
	 * @param {FC.ArcologyState} [arcology]
	 */
	constructor(arcology) {
		this._arcology = arcology ?? V.arcologies[0];
	}

	/**
	 * @param {FC.FutureSociety} fs
	 * @returns {boolean}
	 */
	fsActive(fs) {
		return FutureSocieties.isActive(fs, this._arcology);
	}

	/**
	 * @param {FC.FutureSociety[]} fses
	 * @returns {boolean}
	 */
	fsActiveSome(...fses) {
		return fses.some((fs) => this.fsActive(fs));
	}

	/**
	 * @param {FC.FutureSociety[]} fses
	 * @returns {boolean}
	 */
	fsActiveEvery(...fses) {
		return fses.every((fs) => this.fsActive(fs));
	}

	/**
	 * @param {FC.FutureSociety[]} fses
	 * @returns {number}
	 */
	countActive(...fses) {
		return fses.reduce((count, fs) => this.fsActive(fs) ? count + 1 : count, 0);
	}

	/**
	 * @returns {keyof FC.RevivalSocietyFreeze | null}
	 */
	revivalSociety() {
		console.assert(this.countActive('FSAztecRevivalist', 'FSEgyptianRevivalist', 'FSEdoRevivalist', 'FSArabianRevivalist', 'FSChineseRevivalist', 'FSNeoImperialist', 'FSRomanRevivalist', 'FSAntebellumRevivalist') <= 1);

		if (this.fsActive('FSAntebellumRevivalist')) {
			return RevivalSociety.ANTEBELLUM;
		} else if (this.fsActive('FSArabianRevivalist')) {
			return RevivalSociety.ARABIAN;
		} else if (this.fsActive('FSAztecRevivalist')) {
			return RevivalSociety.AZTEC;
		} else if (this.fsActive('FSChineseRevivalist')) {
			return RevivalSociety.CHINESE;
		} else if (this.fsActive('FSEdoRevivalist')) {
			return RevivalSociety.EDO;
		} else if (this.fsActive('FSEgyptianRevivalist')) {
			return RevivalSociety.EGYPTIAN;
		} else if (this.fsActive('FSNeoImperialist')) {
			return RevivalSociety.NEO_IMPERIAL;
		} else if (this.fsActive('FSRomanRevivalist')) {
			return RevivalSociety.ROMAN;
		} else {
			return null;
		}
	}
};


code:
/*
*
* This file focuses on slave related functions that assess qualities about slaves. Are they/can they X?
*
*/

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */
globalThis.getSlaveDevotionClass = function(slave) {
	if ((!slave) || (!State)) {
		return undefined;
	}
	if (slave.fetish === 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}
 */
globalThis.getSlaveTrustClass = function(slave) {
	if ((!slave) || (!State)) {
		return undefined;
	}

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

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

/**
 * Returns a "disobedience factor" between 0 (perfectly obedient) and 100 (completely defiant)
 * @param {App.Entity.SlaveState} slave
 * @returns {number}
 */
globalThis.disobedience = function(slave) {
	const devotionBaseline = 20; // with devotion above this number slaves will obey completely
	const trustBaseline = -20; // with trust below this number slaves will obey completely

	if (slave.devotion > devotionBaseline || slave.trust < trustBaseline) {
		return 0; // no chance of disobedience
	}

	// factors are between 0 (right on the boundary of perfectly obedient) and 10 (completely disobedient)
	let devotionFactor = 10 - ((10 * (slave.devotion + 100)) / (devotionBaseline + 100));
	let trustFactor = (10 * (slave.trust - trustBaseline)) / (100 - trustBaseline);
	return Math.round(devotionFactor * trustFactor);
};

/**
 * Returns how exposing a slave's outfit is, after taking into consideration a topless outfit is more revealing for beboobed slaves or female ones.
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2|3|4}
 */
globalThis.getExposure = function(slave) {
	const clothes = App.Data.clothes.get(slave.clothes);
	return (!clothes || (clothes.topless && clothes.exposure < 3 && (slave.boobs > 299 || (slave.genes === 'XX' && slave.vagina >= 0)))) ? 3 : clothes.exposure;
};

/**
 * @param {FC.HumanState} slave
 * @returns {boolean}
 */
globalThis.canImproveIntelligence = function(slave) {
	let origIntel = V.genePool.find(function(s) { return s.ID === slave.ID; }).intelligence;
	return (slave.intelligence < origIntel + 15) && (slave.intelligence < 100);
};

/**
 * This function returns the max *growable* height; remember to consider implants at call sites.
 * @param {FC.HumanState} slave
 * @returns {number}
 */
globalThis.maxHeight = function(slave) {
	let max = Math.clamp(slave.natural.height * 1.25, 0, 274); /* max achievable height is expected height plus 25% */

	if (slave.geneticQuirks.neoteny === 2 && slave.physicalAge > 12) { /* Limit neoteny slaves to 12 year old max height */
		max = Math.clamp(Height.mean(slave.nationality, slave.race, slave.genes, 12) * 1.25, 0, 274);
	}

	if (slave.geneticQuirks.dwarfism === 2 && slave.geneticQuirks.gigantism !== 2) {
		max = Math.min(max, 160);
	} else if (slave.geneticQuirks.gigantism === 2 && slave.geneticQuirks.dwarfism !== 2) {
		max = Math.min(Math.trunc(max * 1.1), 274);
	}

	max = Math.round(max);

	return max;
};

/**
 * @param {FC.HumanState} slave
 * @returns {boolean}
 */
globalThis.canImproveHeight = function(slave) {
	return slave.height - slave.heightImplant * 10 < maxHeight(slave);
};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {FC.HumanState} target
 * @returns {boolean}
 */
globalThis.haveRelationshipP = function(slave, target) {
	return slave.relationshipTarget === target.ID;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {App.Entity.SlaveState} target
 * @returns {boolean}
 */
globalThis.isRivalP = function(slave, target) {
	return slave.rivalryTarget === target.ID;
};

/**
 * @param {FC.HumanState} slave
 * @returns {boolean}
 */
globalThis.supremeRaceP = function(slave) {
	return V.arcologies[0].FSSupremacistRace === slave.race;
};

/**
 * @param {FC.HumanState} slave
 * @returns {boolean}
 */
globalThis.inferiorRaceP = function(slave) {
	return V.arcologies[0].FSSubjugationistRace === slave.race;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
globalThis.isLeaderP = function(slave) {
	const leaders = [S.HeadGirl, S.Bodyguard, S.Recruiter, S.Concubine, S.Nurse, S.Attendant, S.Matron, S.Madam, S.DJ, S.Milkmaid, S.Farmer, S.Stewardess, S.Schoolteacher, S.Wardeness];

	return leaders.some(leader => leader && leader.ID === slave.ID);
};

/** Get the written variant of a slave's title for the player, without messing with global state.
 * @param {App.Entity.SlaveState} [slave]
 * @returns {string}
 */
globalThis.getWrittenTitle = function(slave) {
	if (slave && slave.custom.title !== undefined && slave.custom.title !== "" && slave.rudeTitle === 0) {
		return slave.custom.title;
	}
	if (V.PC.customTitle !== undefined) {
		return V.PC.customTitle;
	} else if (V.PC.title !== 0) {
		return "Master";
	} else {
		return "Mistress";
	}
};

/**
 * @param {FC.HumanState} slave
 * @returns {string}
 */
globalThis.SlaveFullName = function(slave) {
	const pair = slave.slaveSurname ? [slave.slaveName, slave.slaveSurname] : [slave.slaveName];
	if ((V.surnameOrder !== 1 && ["Cambodian", "Chinese", "Ancient Chinese Revivalist", "Hungarian", "Japanese", "Edo Revivalist", "Korean", "Mongolian", "Taiwanese", "Vietnamese"].includes(slave.nationality)) || (V.surnameOrder === 2)) {
		pair.reverse();
	}
	return pair.join(" ");
};

/** Is the slave a shelter slave?
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
globalThis.isShelterSlave = function(slave) {
	return slave.origin.includes("Slave Shelter");
};

/**
 * Returns if a person appears male, female, or androgynous.
 * @param {FC.HumanState} slave
 * @returns {number} - negative means male, positive means female, more androgynous is closer to zero
 */
globalThis.perceivedGender = function(slave) {
	let degree = 0;
	if (["cute", "sensual"].includes(slave.faceShape)) {
		degree++;
	} else if (["masculine"].includes(slave.faceShape)) {
		degree--;
	}
	if (slave.shoulders > slave.hips) {
		degree += slave.hips - slave.shoulders;
	} else if (slave.shoulders < slave.hips) {
		degree += slave.shoulders - slave.hips;
	}
	if ((slave.shoulders > 1 && slave.boobs < 650) || (slave.shoulders > 0 && slave.boobs < 500)) {
		// wide shoulders muddle breast size
	} else if (slave.boobs >= 500 || (slave.boobs >= 400 && slave.weight <= 30) || (slave.boobs >= 300 && slave.weight < -10)) {
		degree++;
	}
	let weightAffectsWaist = 0;
	if (slave.hips > 2) {
		if (slave.weight > 0) {
			weightAffectsWaist = Math.round(slave.weight / 6);
		}
		degree++;
	} else if (slave.hips > 1) {
		if (slave.weight > 0) {
			weightAffectsWaist = Math.round(slave.weight / 4);
		}
		if (slave.butt <= 3) {
			degree--;
		} else {
			degree++;
		}
	} else if (slave.hips > 0) {
		if (slave.weight > 0) {
			weightAffectsWaist = Math.round(slave.weight / 2);
		}
		if (slave.butt <= 2) {
			degree--;
		} else {
			degree++;
		}
	} else if (slave.hips > -1) {
		if (slave.weight > 0) {
			weightAffectsWaist = slave.weight;
		}
		if (slave.butt <= 1) {
			degree--;
		} else {
			degree++;
		}
	} else if (slave.butt > 2) {
		degree++;
	} else {
		degree--;
	}
	if (slave.weight > 0) {
		if (slave.hips === -1) {
			weightAffectsWaist = Math.round(slave.weight * 2);
		} else if (slave.hips === -2) {
			weightAffectsWaist = Math.round(slave.weight * 4);
		}
	}
	if (slave.waist < -10 - weightAffectsWaist) {
		degree++;
	} else if (slave.waist > 10 - weightAffectsWaist) {
		degree--;
	}
	if (slave.muscles > 50) {
		degree--;
	}
	if (slave.belly >= 5000) {
		degree++;
	}
	if (slave.dick > 2 || slave.balls > 3) {
		degree--;
	}
	degree += Math.trunc(slave.hormoneBalance / 200);
	const PC = asPlayer(slave);
	if (PC) {
		if (PC.title === 1) {
			degree--;
			if (degree > 0) {
				degree *= .85;
			} else {
				degree *= 1.15;
			}
		} else {
			degree++;
			if (degree > 0) {
				degree *= 1.15;
			} else {
				degree *= .85;
			}
		}
	}
	if (slave.genes === GenderGenes.MALE) {
		if (degree > 0) {
			degree *= .75;
		} else {
			degree *= 1.25;
		}
	} else if (slave.genes === GenderGenes.FEMALE) {
		if (degree > 0) {
			degree *= 1.25;
		} else {
			degree *= .75;
		}
	}
	return degree;
};

/**
 * @param {App.Entity.SlaveState} A
 * @param {App.Entity.SlaveState} B
 * @returns {boolean}
 */
globalThis.sameAssignmentP = function(A, B) {
	return A.assignment === B.assignment;
};

/** Determine whether a given penthouse slave can move into a private room or not.
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
globalThis.canMoveToRoom = function(slave) {
	const partner = slave.relationship >= 4 ? getSlave(slave.relationshipTarget) : null;
	const partnerHasRoom = partner && assignmentVisible(partner) && partner.rules.living === "luxurious";
	return partnerHasRoom || V.rooms - V.roomsPopulation >= 1;
};

/**
 * @param {FC.HumanState} slave
 * @returns {0|1|2|3} 0: No heel boost at all. 1: Pumps, slight boost. 2: High heels. 3: Painfully/extreme high heels
 */
globalThis.shoeHeelCategory = function(slave) {
	const height = App.Data.shoes.get(slave.shoes) ? App.Data.shoes.get(slave.shoes).heelHeight : 0; // Height is in cm
	if (height > 20) {
		return 3;
	} else if (height > 5) {
		return 2;
	} else if (height > 0) {
		return 1;
	} else {
		return 0;
	}
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {number} shoe height in cm (heel + platform height)
 */
globalThis.shoeHeight = function(slave) {
	const heelHeight = App.Data.shoes.get(slave.shoes) ? App.Data.shoes.get(slave.shoes).heelHeight : 0;
	const platformHeight = App.Data.shoes.get(slave.shoes) ? App.Data.shoes.get(slave.shoes).platformHeight : 0;
	return heelHeight + platformHeight;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2|3}
 */
globalThis.plugWidth = function(slave) {
	const plug = App.Data.buttplug.get(slave.buttplug) || V.customItem.buttplug.get(slave.buttplug);
	return plug.width || 0;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2|3}
 */
globalThis.plugLength = function(slave) {
	const plug = App.Data.buttplug.get(slave.buttplug) || V.customItem.buttplug.get(slave.buttplug);
	return plug.length || 0;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2|3}
 */
globalThis.dildoWidth = function(slave) {
	const dildo = App.Data.vaginalAccessory.get(slave.vaginalAccessory) || V.customItem.vaginalAccessory.get(slave.vaginalAccessory);
	if (dildo === undefined) {
		console.log("missing dildo: ", slave.vaginalAccessory);
		return 0;
	}
	return dildo.width || 0;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2}
 */
globalThis.dildoLength = function(slave) {
	const dildo = App.Data.vaginalAccessory.get(slave.vaginalAccessory) || V.customItem.vaginalAccessory.get(slave.vaginalAccessory);
	return dildo.length || 0;
};

/**
 * Returns the best vibe mode available between the dildo itself, and any attachment that may be present.
 * @param {App.Entity.SlaveState} slave
 * @returns {0|1|2}
 */
globalThis.dildoVibeLevel = function(slave) {
	// Vaginal accessory/dildo
	const dildo = App.Data.vaginalAccessory.get(slave.vaginalAccessory) || V.customItem.vaginalAccessory.get(slave.vaginalAccessory);
	const dildoVibrationLevel = ((dildo) ? dildo.vibrates : 0) || 0;

	// Attachment, if present
	const vaginalAttachment = App.Data.slaveWear.vaginalAttachment.get(slave.vaginalAttachment) || 0;
	const vaginalAttachmentVibrationLevel = ((vaginalAttachment) ? vaginalAttachment.vibrates : 0) || 0;
	return Math.max(dildoVibrationLevel, vaginalAttachmentVibrationLevel);
};


code:
/**
 * @callback passageLinkHandler
 * @returns {void}
 */
/**
 * 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
 * @template {keyof PassageLinkMap} K
 * @param {string} linkText link text
 * @param {string} passage the passage name to link to
 * @param {passageLinkHandler} [handler] setter text (optional)
 * @param {string} [tooltip] tooltip text (optional)
 * @param {K} [elementType] element type (optional) default is 'a'.
 * Could be any of 'a', 'audio', img', 'source', 'video'
 * @returns {PassageLinkMap[K]} element text
 *
 * @example
 * // equal to [[Go to town|Town]]
 * App.UI.passageLink("Go to town", "Town")
 */
App.UI.DOM.passageLink = function(linkText, passage, handler, tooltip = '', elementType = 'a') {
	let res = document.createElement(elementType);
	res.setAttribute("data-passage", passage);
	res.onclick = (ev) => {
		ev.preventDefault();
		if (handler) {
			handler();
		}
		Engine.play(passage);
	};

	if (tooltip) {
		res.title = tooltip;
	}
	res.textContent = linkText;
	return res;
};

/**
 * Returns link element for an assignment
 * @param {App.Entity.SlaveState} slave
 * @param {FC.Assignment} assignment
 * @param {string} [passage] passage to go to
 * @param {assignmentCallback} [action] action that changes slave state. The default one is a call to assignJob()
 * @param {string} [linkText]
 * @returns {HTMLAnchorElement}
 */
App.UI.DOM.assignmentLink = function(slave, assignment, passage, action, linkText) {
	let res = document.createElement("a");
	res.textContent = linkText;
	res.onclick = (e) => {
		e.preventDefault();
		if (action) {
			action(slave, assignment);
		} else {
			assignJob(slave, assignment);
		}
		if (passage !== '') {
			SugarCube.Engine.play(passage);
		}
	};
	return res;
};

/**
 * Creates a markup for a SugarCube link which executes given function with given arguments
 *
 * @template {function(...any):void} F
 * @param {string} linkText link text
 * @param {F} handler callable object
 * @param {Parameters<F>} [args] arguments
 * @param {string} [passage] the passage name to link to
 * @param {string|HTMLElement|DocumentFragment} [tooltip]
 * @returns {HTMLAnchorElement} link in SC markup
 */
App.UI.DOM.link = function(linkText, handler, args = [], passage = "", tooltip = "") {
	const hArgs = Array.isArray(args) ? args : [args];
	const link = document.createElement("a");
	link.textContent = linkText;
	link.onclick = () => {
		handler(...hArgs);
		if (passage !== '') {
			SugarCube.Engine.play(passage);
		}
	};

	if (tooltip) {
		link.classList.add("has-tooltip");
		tippy(link, {
			content: tooltip,
		});
	}

	return link;
};

/**
 * Creates a span for an link with tooltip containing the reasons why it is disabled
 * @param {string} link
 * @param {(string|DocumentFragment|HTMLElement)[]} reasons
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.disabledLink = function(link, reasons) {
	/** @type {HTMLElement} */
	let tooltip;
	if (reasons.length === 1) {
		tooltip = document.createElement("span");
		tooltip.append(reasons[0]);
	} else {
		tooltip = document.createElement("ul");
		for (const li of reasons.map(r => {
			const li = document.createElement("li");
			li.append(r);
			return li;
		})) {
			tooltip.appendChild(li);
		}
	}
	return App.UI.DOM.spanWithTooltip(link, tooltip);
};

/**
 * @param {string} text
 * @param {string|HTMLElement} tooltip
 * @param {string[]} [classNames]
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.spanWithTooltip = function(text, tooltip, classNames) {
	/** @type {HTMLSpanElement} */
	const span = App.UI.DOM.makeElement("span", text, ["has-tooltip"]);
	if (classNames) {
		if (Array.isArray(classNames)) {
			span.classList.add(...classNames);
		} else {
			span.classList.add(classNames);
		}
	}
	// Make the span focusable, allows showing the tooltip on mobile
	span.tabIndex = 0;
	tippy(span, {content: tooltip});
	return span;
};

// /**
//  * @template {keyof HTMLElementTagNameMap} K
//  * @param {K} tag - valid HTML tag
//  * @param {string|Node} [content]
//  * @param {string[]} [classNames]
//  * @returns {HTMLElementTagNameMap[K]}
//  */

// This is a workaround to mark one of the overloads as deprecated.
// When done, remove declarations in devTools/types/FC/workaround.d.ts
/** @type {App.UI.DOM.makeElementTS} */
App.UI.DOM.makeElement = function(tag, content, classNames) {
	const element = document.createElement(tag);
	if (classNames) {
		if (Array.isArray(classNames)) {
			element.classList.add(...classNames);
		} else {
			element.classList.add(classNames);
		}
	}
	if (content !== undefined && content !== null) {
		element.append(content);
	}
	return element;
};

// /**
//  * @template {keyof HTMLElementTagNameMap} K
//  * @param {K} tag - valid HTML tag
//  * @param {ParentNode} parent
//  * @param {string|Node} [content]
//  * @param {string[]} [classNames]
//  * @returns {HTMLElementTagNameMap[K]}
//  */

// This is a workaround to mark one of the overloads as deprecated.
// When done, remove declarations in devTools/types/FC/workaround.d.ts
/** @type {App.UI.DOM.appendNewElementTS} */
App.UI.DOM.appendNewElement = function(tag, parent, content, classNames) {
	const element = App.UI.DOM.makeElement(tag, content, classNames);
	parent.append(element);
	return element;
};

/**
 * @param {string} linkText
 * @param {string|Node} newContent
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.linkReplace = function(linkText, newContent) {
	const span = document.createElement("span");
	span.append(App.UI.DOM.link(linkText, () => {
		span.innerHTML = "";
		span.append(newContent);
	}));
	return span;
};

/**
 * Replaces a given element with the given text or node.
 *
 * @param {HTMLElement|DocumentFragment|JQuery.Selector} el The element to be replaced.
 * @param {string|HTMLElement|DocumentFragment|function():HTMLElement} newContent The text or node to replace with. Can be a function returning a node.
 *
 * @example
 *  const div = document.createElement("div");
 *  const text = `Any text, including template literals`;
 *  div.append(App.UI.DOM.link("Link text", () => { // this link will be replaced with the given text
 *      App.UI.DOM.replace(div, text);
 *  }));
 *
 *  function example() {
 *      const div = document.createElement("div");
 * 		let condition = false;
 * 		if (condition) {
 *      	div.append(App.UI.DOM.link("Link text", () => {	// this link will then be replaced with second link
 * 				let text = `Some text`;
 *          	App.UI.DOM.replace(div, example);
 *      	}));
 * 		} else {
 *			div.append(App.UI.DOM.link("Different link text", () => {	// this link will be replaced with first link
	 			let text = `Some other text`;
 *          	App.UI.DOM.replace(div, example);
 *      	}));
 * 		}
 *      return div;
 *  }
 *
 * @see For more examples, see killSlave.js and pit.js
 */
App.UI.DOM.replace = function(el, newContent) {
	$(el).empty().append(newContent);
};

/**
 * @param {string} passage
 * @returns {Element}
 */
App.Utils.passageElement = function(passage) {
	return document.querySelector(`tw-passagedata[name="${passage}"]`);
};

/**
 * @param {(Node|string)[]} content
 * @returns {DocumentFragment}
 */
App.UI.DOM.combineNodes = function(...content) {
	let fragment = document.createDocumentFragment();
	fragment.append(...content);
	return fragment;
};

/**
 * @template {string|number} T
 * @template {WidenLiterals<T>} Value
 * @param {T} defaultValue
 * @param {function(Value):void} onEnter - accepts number if numberOnly = true, otherwise string
 * @param {boolean} [numberOnly]
 * @returns {HTMLInputElement}
 */
App.UI.DOM.makeTextBox = function(defaultValue, onEnter, numberOnly = false) {
	const input = document.createElement("input");
	input.type = "text";
	input.value = defaultValue.toString();

	/** @type {Parameters<typeof input.addEventListener>[1]} */
	let updateValue;
	if (numberOnly) {
		/*
		We could use input.type = "number", but at least in firefox submitting an invalid value will set input.value
		to 0 and trigger a change event we can't distinguish from setting the value to 0 explicitly.
		The workaround is resetting the value to the last known valid value and not triggering onEnter.
		*/
		input.classList.add("number");
		let oldValue = defaultValue;
		updateValue = event => {
			const newValue = Number(event.target.value);
			if (!Number.isNaN(newValue)) {
				onEnter(newValue);
				oldValue = newValue;
			} else {
				// reset the value to the last known valid value
				event.target.value = oldValue;
			}
		};
	} else {
		updateValue = e => {
			onEnter(e.target.value);
		};
	}
	input.addEventListener('change', updateValue);

	return input;
};

/**
 * @param {string|number} defaultValue
 * @param {function(string):void} onEnter
 * @returns {HTMLInputElement}
 */
App.UI.DOM.colorInput = function(defaultValue, onEnter) {
	const input = document.createElement("input");
	input.type = "color";
	input.value = String(defaultValue);

	input.addEventListener("change", e => { onEnter(e.target.value); });

	return input;
};

/**
 * Concats an array of DOM nodes or strings into a human readable list.
 *
 * @param {Iterable<Node|string>} content
 * @param {Node|string} [delimiter]
 * @param {Node|string} [lastDelimiter]
 * @returns {DocumentFragment}
 */
App.UI.DOM.toSentence = function(content, delimiter = ", ", lastDelimiter = " and ") {
	const itr = content[Symbol.iterator]();
	let output = document.createDocumentFragment();
	let result = itr.next();
	if (!result.done) {
		// output first element
		output.append(result.value);
		result = itr.next();
		if (!result.done) {
			// output elements (1...n-1)
			let previous = result.value;
			result = itr.next();
			while (!result.done) {
				output.append(delimiter, previous);
				previous = result.value;
				result = itr.next();
			}
			// output final element
			output.append(lastDelimiter, previous);
		}
	}
	return output;
};

/**
 * @param {string} text
 * @returns {HTMLElement}
 */
App.Utils.htmlToElement = function(text) {
	const template = document.createElement("template");
	text = text.trim(); // Never return a text node of whitespace as the result
	template.innerHTML = text;
	// @ts-ignore
	return template.content.firstChild;
};

/**
 * Show a list of links (or disabled links) as a delimited strip
 * @param {Array<Node|string>} links
 * @returns {HTMLUListElement}
 */
App.UI.DOM.generateLinksStrip = function(links) {
	const strip = document.createElement('ul');
	strip.className = "choices-strip";

	links.reduce((/** @type {HTMLUListElement} */ list, lnk) => {
		const li = document.createElement("li");
		li.append(lnk);
		list.appendChild(li);
		return list;
	}, strip);
	return strip;
};

/**
 * @param {Node|string} head
 * @param {HTMLDivElement} [content]
 * @param {boolean} [hidden]
 * @returns {DocumentFragment}
 */
App.UI.DOM.accordion = function(head, content, hidden = true) {
	const fragment = document.createDocumentFragment();
	const button = App.UI.DOM.appendNewElement("button", fragment, head, ["accordion"]);

	if (content) {
		App.UI.DOM.elementToggle(button, [content], hidden);
		fragment.append(content);
	} else {
		button.classList.add("empty");
	}

	return fragment;
};

/**
 * @param {HTMLElement} toggleElement
 * @param {Array<HTMLElement>} content
 * @param {boolean} [startHidden=true]
 */
App.UI.DOM.elementToggle = function(toggleElement, content, startHidden = true) {
	toggleElement.classList.add("accordion");
	for (let htmlElement of content) {
		htmlElement.classList.add("accordion-content");
	}
	const toggle = () => {
		toggleElement.classList.toggle("closed");
		for (let htmlElement of content) {
			htmlElement.classList.toggle("hidden");
		}
	};
	toggleElement.onclick = toggle;
	if (startHidden) {
		toggle();
	}
};

/*
	<<includeDOM element>>
	Simply inserts a given DOM element.
 */
Macro.add("includeDOM", {
	handler() {
		// Basically the same as <<set>>, we just use whatever Scripting.evalJavaScript(this.args.full) returns instead
		// of discarding it.
		try {
			this.output.append(Scripting.evalJavaScript(this.args.full));
		} catch (ex) {
			// @ts-ignore
			return this.error(`bad evaluation: ${typeof ex === 'object' ? `${ex.name}: ${ex.message}` : ex}`, null,
				ex.stack);
		}
	}
});

/**
 * Formats the given number as currency.
 *
 * Positive values returns in green, negative values return in red, unless the invert parameter is set.
 * @param {number} s The number to format.
 * @param {boolean} [invert] Whether or not to invert the numbers (i.e. display positive numbers in red, and negative numbers in green).
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.cashFormat = function(s, invert) {
	const span = document.createElement("span");
	if ((invert && s > 0) || s < 0) {
		// Display in red (WITHOUT a negative sign) if the value is negative, unless invert is true
		span.classList.add("cash", "dec");
	} else if (s !== 0) {
		// Yellow for positive
		span.classList.add("cash", "inc");
	}
	span.textContent = cashFormat(Math.trunc(s));
	return span;
};

/**
 * Renders an exception to DOM.
 * Tries it's best to not fail itself.
 *
 * @param {any} ex Exception - normally of type Error, but Javascript literally lets you throw anything
 * @param {boolean} recursion set if formatException() called itself, never set from outside
 * @returns {DocumentFragment|HTMLParagraphElement}
 */
App.UI.DOM.formatException = function formatException(ex, recursion = false) {
	/**
	 * In case the normal rendering failed attempts to provide some information on it.
	 * In the worst case gives back a default error message.
	 *
	 * @param {Error} exception
	 * @returns {DocumentFragment|HTMLParagraphElement}
	 */
	function failSafe(exception) {
		if (!recursion) {
			return formatException(exception, true);
		} else {
			// Not using custom functions here, as they may be the cause of the error
			const p = document.createElement("p");
			p.classList.add("bold", "error");
			p.append("Unrecoverable error in App.UI.DOM.formatException()! Please report this.");
			return p;
		}
	}

	/**
	 * Renders the exception to DOM
	 *
	 * @returns {DocumentFragment}
	 */
	function render() {
		const fragment = document.createDocumentFragment();

		const header = document.createElement("p");
		header.classList.add("error");
		App.UI.DOM.appendNewElement("div", header, `\`\`\``);
		App.UI.DOM.appendNewElement("div", header, `Apologies! An error has occurred. Please report this.`, ["bold"]);
		App.UI.DOM.appendNewElement("div", header, `Please provide a screenshot of the error message, a save file and any other relevant information.`);
		fragment.append(header);

		const eventLabel = () => {
			if (ex instanceof Error && "event" in ex) {
				const eventErr = /** @type {Error & {event: string}} @see App.Events.runPassageEvent */(ex);
				if (typeof eventErr.event === "string") {
					return ` Event: ${eventErr.event};`;
				}
			}
			return ``;
		};

		App.UI.DOM.appendNewElement("p", fragment, `Passage: ${passage()};${eventLabel()} Version: ${Config.saves.version}`, ["bold"]);

		const error = document.createElement("p");
		if (ex instanceof Error) {
			App.UI.DOM.appendNewElement("div", error, `${ex.name}: ${ex.message}`, ["bold"]);
			const body = document.createElement("div");
			if (ex.stack) {
				const lines = ex.stack.split("\n");
				for (const ll of lines) {
					const div = document.createElement("div");
					// remove file path from error message
					div.append(ll.replace(/file:.*\//, "<path>/"));
					body.append(div);
				}
			} else {
				body.append("No stack available.");
			}
			error.append(body);
		} else {
			App.UI.DOM.appendNewElement("div", error, ex.toString(), ["bold"]);
		}

		fragment.append(error, `\`\`\``);

		return fragment;
	}

	try {
		if (!ex) {
			// Create a new Error so we have access to a stacktrace.
			return failSafe(new Error("No exception provided!"));
		}
		return render();
	} catch (ex) {
		return failSafe(ex);
	}
};

/**
 * Adds a row to a table. Each param after the table itself will be appended as a cell.
 * @param {HTMLTableElement|HTMLTableSectionElement} table
 * @param {...string} items
 */
App.UI.DOM.makeRow = function(table, ...items) {
	const row = table.insertRow();
	for (const item of items) {
		const cell = row.insertCell();
		cell.innerHTML = item;
	}
};

/**
 * Creates a checkbox.
 * @param {string} arg - item to be checked. Currently only designed to with items that have values of 0 - 1.
 */
App.UI.DOM.makeCheckbox = function(arg) {
	const checkbox = document.createElement("input");
	checkbox.setAttribute("type", "checkbox");
	checkbox.checked = (V[arg] === 1);

	checkbox.onchange = () => {
		V[arg] = checkbox.checked ? 1 : 0;
	};
	return checkbox;
};

/**
 * Draw a single medium-sized slave image, floating to the right of a block of text.
 * If you're rendering simple scene-wide art where the relationship between text and image location isn't important, @see App.Events.drawEventArt instead.
 * @param {ParentNode} node
 * @param {App.Entity.SlaveState} slave
 * @param {App.Art.SlaveArtBatch} [batchRenderer] an initialized batch renderer with the preamble already output; if omitted, the entire art block will be output inline
 * @returns {HTMLDivElement|DocumentFragment}
 */
App.UI.DOM.drawOneSlaveRight = function(node, slave, batchRenderer) {
	if (!V.seeImages || (V.seeCustomImagesOnly && !slave.custom.image) || !slave) {
		return new DocumentFragment();
	}
	const artElement = batchRenderer ? batchRenderer.render(slave) : App.Art.SlaveArtElement(slave, 2, 0);
	return App.UI.DOM.appendNewElement("div", node, artElement, ["imageRef", "medImg"]);
};


code:
/**
 * 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 Plain if
 * let r = "";
 * if (slave.muscles > 95) {
 *     r = "Musc++";
 * } else if (slave.muscles > 30) {
 *     r = "Musc+";
 * } else if (slave.muscles > 5) {
 *     r = "Toned";
 * } else if (slave.muscles > -6) {
 * } elseif (slave.muscles > -31) {
 *     r = "weak";
 * } else if (slave.muscles > -96) {
 *     r = "weak+";
 * } else {
 * 	   r = "weak++";
 * }
 *
 * @example As a categorizer
 * // Can be defined globally.
 * const muscleCat = new Categorizer([96, "Musc++"], [31, "Musc+"], [6, "Toned"], [-5, ""], [-30, "weak"], [-95, "weak+"], [-Infinity, "weak++"])
 *
 * r = muscleCat.cat(slave.muscles);
 */
globalThis.Categorizer = class {
	/**
	 * @param  {...Array} pairs
	 */
	constructor(...pairs) {
		this.cats = Array.prototype.slice.call(pairs)
			.filter(function(e, i, a) {
				return Array.isArray(e) && 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 */
			});
	}

	cat(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 (typeof result === "object" && !isNaN(result)) {
			result.value = val;
		}
		return result;
	}
};

/**
 * Converts an Iterable of strings into a sentence parted by commas.
 * For an empty Iterable will return an empty string.
 * @param {Iterable<string>} iterable
 * @param {string} [delimiter=", "]
 * @param {string} [lastDelimiter=" and "]
 * @returns {string}
 *
 * @example array
 * toSentence(["apple", "banana", "carrot"]);
 * // returns "apple, banana and carrot"
 *
 * @example Set
 * toSentence(new Set(["apple", "banana"]), ", ", " or ");
 * // returns "apple or banana"
 */
globalThis.toSentence = function(iterable, delimiter = ", ", lastDelimiter = " and ") {
	const itr = iterable[Symbol.iterator]();
	let output = ``;
	let result = itr.next();
	if (!result.done) {
		// output first element
		output += result.value;
		result = itr.next();
		if (!result.done) {
			// output elements (1...n-1)
			let previous = result.value;
			result = itr.next();
			while (!result.done) {
				output += delimiter + previous;
				previous = result.value;
				result = itr.next();
			}
			// output final element
			output += lastDelimiter + previous;
		}
	}
	return output;
};

App.Utils.alphabetizeIterable = function(iterable) {
	const compare = function(a, b) {
		let aTitle = a.toLowerCase();
		let bTitle = b.toLowerCase();

		aTitle = App.Utils.removeArticles(aTitle);
		bTitle = App.Utils.removeArticles(bTitle);

		if (aTitle > bTitle) {
			return 1;
		}
		if (aTitle < bTitle) {
			return -1;
		}
		return 0;
	};

	const clonedArray = (Array.from(iterable));
	return clonedArray.sort(compare);
};

/**
 * @param {string} str
 * @returns {string}
 */
App.Utils.removeArticles = function(str) {
	const words = str.split(" ");
	if (words.length <= 1) {
		return str;
	}
	if (words[0] === "a" || words[0] === "the" || words[0] === "an") {
		return words.splice(1).join(" ");
	}
	return str;
};

/**
 * @param {FC.Zeroable<FC.Race>} badRace
 * @returns {Array<FC.Race>}
 */
App.Utils.getRaceArrayWithoutParamRace = function(badRace) {
	return Array.from(App.Data.misc.filterRaces.keys()).filter(race => race !== badRace);
};

/** Narrows the type of a HumanState object representing the player into a PlayerState object, or returns null if it's not the player
 * @param {FC.HumanState} human
 * @returns {App.Entity.PlayerState}
 */
globalThis.asPlayer = function(human) {
	if (!human || human.ID !== -1) {
		return null;
	}
	return /** @type {App.Entity.PlayerState} */(human);
};

/** Narrows the type of a HumanState object representing a slave into a SlaveState object, or returns null if it's not a slave
 * @param {FC.HumanState} human
 * @returns {App.Entity.SlaveState}
 */
globalThis.asSlave = function(human) {
	if (!human || human.ID === -1) {
		return null;
	}
	return /** @type {App.Entity.SlaveState} */(human);
};

/**
 * @param {string} englishWord
 * @returns {string}
 */
App.Utils.translate = function(englishWord) {
	return App.Data.dictionary[englishWord] && App.Data.dictionary[englishWord].hasOwnProperty(V.language)
		? App.Data.dictionary[englishWord][V.language]
		: englishWord;
};

/**
 * Calculates the player's net worth.
 *
 * @returns {number}
 */
App.Utils.totalNetWorth = function() {
	const arcology = V.arcologies[0];
	const assistant = V.assistant.power;
	const menialPrice = menialSlaveCost();
	let total = 0;

	total += V.cash;

	for (const slave of V.slaves) {
		total += slaveCost(slave);
	}

	total += V.menials * menialPrice;
	total += V.menialBioreactors * menialPrice;
	total += V.bioreactorsXY * menialPrice;
	total += V.bioreactorsXX * menialPrice;
	total += V.bioreactorsHerm * menialPrice;

	total += V.building.findCells(cell => !(cell instanceof App.Arcology.Cell.Filler) && cell.owner === 1).length * 1000 * Math.trunc(arcology.prosperity * (1 + (arcology.demandFactor / 100)));

	for (let i = 1; i < V.arcologies.length; i++) {
		const arcology = V.arcologies[i];

		total += (500 * Math.trunc(arcology.prosperity * (1 + (arcology.demandFactor / 100))) / 100) * arcology.PCminority;
	}

	if (assistant > 0) {
		if (assistant > 1) {
			total += 20000;
		}
		if (assistant > 2) {
			total += 35000;
		}
		if (assistant > 3) {
			total += 50000;
		}
	}

	if (V.arcologyUpgrade.drones) {
		total += 5000;
	}
	if (V.arcologyUpgrade.hydro) {
		total += 10000;
	}
	if (V.arcologyUpgrade.apron) {
		total += 20000;
	}
	if (V.arcologyUpgrade.grid) {
		total += 50000;
	}

	if (V.weatherCladding === 1) {
		total += 50000;
	} else if (V.weatherCladding === 2) {
		total += 3500000;
	}

	total += Object.values(App.Entity.facilities)
		.filter(f => f.established)
		.reduce((acc, cur) => acc + cur.value, 0);

	total += Math.trunc(V.mods.food.amount * V.mods.food.cost);

	total += App.Mods.SF.totalNetWorth();
	total -= App.Mods.SecExp.upkeep.cost();

	if (V.loans.length > 0) {
		V.loans.forEach(loan => total -= loan.full);
	}

	return total;
};

/** Calculate the target number of slaves to recruit when the recruiter is set to fill facilities */
App.Utils.recruiterFacilitySpace = function() {
	/* Note on target sum: HG and Recruiter are the initial 2 counted (no facility req'd), while HGSuite counts the HG's girl, other facilities count associated leader */
	let idleTarget = (2 + V.brothel + V.club + V.arcade + V.dairy + V.servantsQuarters + V.masterSuite);
	if (V.HGSuite) {
		idleTarget++;
	}
	if (V.dojo) {
		idleTarget++;
	}
	if (V.brothel) {
		idleTarget++;
	}
	if (V.club) {
		idleTarget++;
	}
	if (V.dairy && V.dairyRestraintsSetting < 2) {
		idleTarget++;
	}
	if (V.farmyard) {
		idleTarget++;
	}
	if (V.servantsQuarters) {
		idleTarget++;
	}
	if (V.masterSuite) {
		idleTarget++;
	}
	if (V.schoolroom) {
		idleTarget++;
	}
	if (V.spa) {
		idleTarget++;
	}
	if (V.nursery) {
		idleTarget++;
	}
	if (V.clinic) {
		idleTarget++;
	}
	if (V.cellblock) {
		idleTarget++;
	}
	return Math.max(idleTarget, 20);
};

globalThis.generalRefreshment = function name() {
	if (V.PC.refreshmentType === 1) {
		return `glass of ${V.PC.refreshment}`;
	} else if (V.PC.refreshmentType === 2) {
		return `plate of ${V.PC.refreshment}`;
	} else if (V.PC.refreshmentType === 3) {
		return `line of ${V.PC.refreshment}`;
	} else if (V.PC.refreshmentType === 4) {
		return `syringe of ${V.PC.refreshment}`;
	} else if (V.PC.refreshmentType === 5) {
		return `pill of ${V.PC.refreshment}`;
	} else if (V.PC.refreshmentType === 6) {
		return `tab of ${V.PC.refreshment}`;
	} else { // 0 and default
		return `${V.PC.refreshment}`;
	}
};

/**
 * @param {string} name
 * @param {FC.TrinketData} [object]
 */
globalThis.addTrinket = function(name, object) {
	if (object) {
		if (!V.trinkets.get(name)) {
			V.trinkets.set(name, []);
		}
		V.trinkets.get(name).push(object);
	} else {
		if (!V.trinkets.get(name)) {
			V.trinkets.set(name, 0);
		}
		V.trinkets.set(name, V.trinkets.get(name) + 1);
	}
};

/**
 * Creates range object
 * @param {number} minValue
 * @param {number} maxValue
 * @returns {FC.NumericRange}
 */
App.Utils.makeRange = function(minValue, maxValue) {
	return {
		min: minValue, max: maxValue
	};
};

/**
 * Compares value to a range
 * @param {number} value
 * @param {FC.NumericRange} [range]
 * @returns {number} The value which when added to `value` brings it within the range [min:max]
 * `positive` when value is less than range min, `0` when the value is whithin the range or
 * the range is `null`, and `negative` when value is greater than range max
 */
App.Utils.distanceToRange = function(value, range) {
	if (!range) {
		return 0;
	}
	return value < range.min ? range.min - value : (value > range.max ? range.max - value : 0);
};

/**
 * @typedef {object} weightedObject
 * @property {number} weight
 */

/**
 * Gives back a random object from a given array, based on the weights of the objects.
 * Negative weights are not allowed and will break.
 *
 * @template {weightedObject} T
 * @param {Array<T>} values
 * @returns {T}
 */
globalThis.weightedRandom = function(values) {
	const sum = values.reduce((acc, cur) => acc + cur.weight, 0);
	let r = Math.random() * sum;
	for (const item of values) {
		if (r < item.weight) {
			return item;
		}
		r -= item.weight;
	}
	// Array was empty or all weights were 0
	return null;
};