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


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: 7,
				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: 7,
				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());

	return node;
};

repo_name: Fanatey/fc

license: mit


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 onscreen.
	 *
	 * @returns {HTMLDivElement}
	 */
	 render() {
		this._div.append(this._assemble());

		return this._div;
	}

	/**
	 * Refreshes the upgrade onscreen.
	 *
	 * @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};
};


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 === "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 === "mindbroken") {
		return "";
	}

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

/**
 * 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 {App.Entity.SlaveState} 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);
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {number}
 */
globalThis.maxHeight = function(slave) {
	let max = Math.clamp(((Height.mean(slave) * 1.25) + slave.heightImplant * 10), 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) + slave.heightImplant * 10), 0, 274);
	}

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

	return max;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */
globalThis.canImproveHeight = function(slave) {
	return slave.height < 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");
};

// TODO: Expand or remove the following unused function.
/**
 * Returns if a slave appears male, female, or androgynous.
 * @param {App.Entity.SlaveState} slave
 * @returns {number}
 */
globalThis.perceivedGender = function(slave) {
	// primary checks are face, hormones, hips, shoulders and shoulder/hip ratio
	// consider androgynous + flat + muscles to be masculine and androgynous + preg to be feminine
	return -1;
};

/**
 * @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) {
		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} 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);
};


code:
/**
 * @returns {App.Entity.PlayerState}
 */
globalThis.basePlayer = function() {
	return new App.Entity.PlayerState();
};

/**
 * Helper function returning PC's title
 * @returns {string}
 */
globalThis.properTitle = function() {
	const PC = V.PC;
	if (PC.customTitle) {
		return PC.customTitle;
	} else if (PC.title !== 0) {
		return "Sir";
	} else {
		return "Ma'am";
	}
};

/**
 * Helper function returning slave's title for PC in situations where getWrittenTitle() is inappropriate
 * @returns {string}
 */
globalThis.properMaster = function() {
	const PC = V.PC;
	if (PC.customTitle) {
		return PC.customTitle;
	} else if (PC.title !== 0) {
		return "Master";
	} else {
		return "Mistress";
	}
};

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

/**
 * @returns {string}
 */
globalThis.PCTitle = function() {
	const titles = [];

	let title = PlayerName();
	if (V.cheater === 1) {
		titles.push(`${title} the Cheater`);
	} else if (V.arcologies[0].FSRestart > 10) {
		titles.push(`${title} of the Societal Elite`);
	} else if (V.rep > 18000) {
		titles.push(`${title} the Great`);
	} else if (V.rep > 17000) {
		titles.push(`the exalted ${title}`);
	} else if (V.rep > 16000) {
		titles.push(`the illustrious ${title}`);
	} else if (V.rep > 15000) {
		titles.push(`the prestigious ${title}`);
	} else if (V.rep > 14000) {
		titles.push(`the renowned ${title}`);
	} else if (V.rep > 13000) {
		titles.push(`the famed ${title}`);
	} else if (V.rep > 12000) {
		titles.push(`the celebrated ${title}`);
	} else if (V.rep > 11000) {
		titles.push(`the honored ${title}`);
	} else if (V.rep > 10000) {
		titles.push(`the acclaimed ${title}`);
	} else if (V.rep > 9000) {
		titles.push(`the eminent ${title}`);
	} else if (V.rep > 8250) {
		titles.push(`the prominent ${title}`);
	} else if (V.rep > 7500) {
		titles.push(`the distinguished ${title}`);
	} else if (V.rep > 6750) {
		titles.push(`the admired ${title}`);
	} else if (V.rep > 6000) {
		titles.push(`the esteemed ${title}`);
	} else if (V.rep > 5250) {
		titles.push(`the respected ${title}`);
	} else if (V.rep > 4500) {
		titles.push(`the known ${title}`);
	} else if (V.rep > 3750) {
		titles.push(`the recognized ${title}`);
	} else if (V.rep > 3000) {
		titles.push(`the rumored ${title}`);
	} else {
		titles.push(title);
	}

	if (V.PC.slaveName === "FC Dev") {
		titles.push("the Creator");
	}

	if (V.plot === 1) {
		if (V.invasionVictory === 3) {
			if (V.PC.title === 1) {
				titles.push("Hero of the City");
			} else {
				titles.push("Heroine of the City");
			}
		} else if (V.invasionVictory === 2) {
			titles.push("Defender of the City");
		}
		if (V.daughtersVictory === 3) {
			titles.push("Destroyer of the Daughters");
		} else if (V.daughtersVictory === 2) {
			if (V.PC.title === 1) {
				titles.push("Victor over the Daughters");
			} else {
				titles.push("Victrix over the Daughters");
			}
		}
	}

	if (V.SF.Toggle && V.SF.FS.Tension > 100) {
		switch (V.SF.FS.BadOutcome) {
			case 'MIGRATION':
				titles.push("The Abandoned");
				break;
			case 'Revolt':
				titles.push("The Betrayed");
				break;
			case 'ANNIHILATION':
				titles.push("The Runner");
				break;
			case 'OCCUPATION':
				titles.push("The Occupied");
				break;
			case 'ASSIMILATION':
				titles.push("The Deceived");
				break;
			case 'ISOLATION':
				titles.push("The Ignored");
				break;
		}
	}

	if (V.mercenaries >= 5) {
		if (V.mercenariesTitle === "Evocati") {
			titles.push(`Princeps of the ${V.mercenariesTitle}`);
		} else if (V.mercenariesTitle === "Knights") {
			if (V.PC.title === 1) {
				titles.push(`Lord Commander of the ${V.mercenariesTitle}`);
			} else {
				titles.push(`Lady Commander of the ${V.mercenariesTitle}`);
			}
		} else if (V.mercenariesTitle === "Immortals") {
			titles.push(`Tyrant of the ${V.mercenariesTitle}`);
		} else if (V.mercenariesTitle === "Black Eagles") {
			titles.push(`Imperial Overlord of the ${V.mercenariesTitle}`);
		} else {
			titles.push(`Commander of the ${V.mercenariesTitle}`);
		}
	} else if (V.mercenaries >= 1) {
		titles.push("Commander of the Mercenaries");
	}

	if (V.dispensary === 1) {
		if (V.PC.title === 1) {
			titles.push("Pharmacologos");
		} else {
			titles.push("Pharmacologes");
		}
	}

	if (V.arcologies[0].FSSupremacist >= V.FSLockinLevel * 0.9) {
		titles.push("Grand Champion of the Blood");
	} else if (V.arcologies[0].FSSupremacist >= V.FSLockinLevel * 0.6) {
		titles.push("Champion of the Blood");
	} else if (V.arcologies[0].FSSupremacist >= V.FSLockinLevel * 0.3) {
		titles.push("Defender of the Blood");
	}

	if (V.arcologies[0].FSSubjugationist >= V.FSLockinLevel * 0.9) {
		titles.push("Grand Overseer of the Inferior Race");
	} else if (V.arcologies[0].FSSubjugationist >= V.FSLockinLevel * 0.6) {
		titles.push("Overseer of the Inferior Race");
	} else if (V.arcologies[0].FSSubjugationist >= V.FSLockinLevel * 0.3) {
		titles.push("Subduer of the Inferior Race");
	}

	if (V.arcologies[0].FSGenderRadicalist >= V.FSLockinLevel * 0.9) {
		titles.push("Buttfucker of All Slaves");
	} else if (V.arcologies[0].FSGenderRadicalist >= V.FSLockinLevel * 0.6) {
		titles.push("Sodomizer of the Traps");
	} else if (V.arcologies[0].FSGenderRadicalist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("Penetrator of the Sissies");
		} else {
			titles.push("Penetratrix of the Sissies");
		}
	}

	if (V.arcologies[0].FSGenderFundamentalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Father to the City");
		} else {
			titles.push("Mother to the City");
		}
	} else if (V.arcologies[0].FSGenderFundamentalist >= V.FSLockinLevel * 0.6) {
		titles.push("Defender of Women");
	} else if (V.arcologies[0].FSGenderFundamentalist >= V.FSLockinLevel * 0.3) {
		titles.push("Restorer of Morals");
	}

	if (V.arcologies[0].FSPaternalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Protector to All Slaves");
		} else {
			titles.push("Protectrix to All Slaves");
		}
	} else if (V.arcologies[0].FSPaternalist >= V.FSLockinLevel * 0.6) {
		if (V.PC.title === 1) {
			titles.push("Benefactor of Slaves");
		} else {
			titles.push("Benefactrix of Slaves");
		}
	} else if (V.arcologies[0].FSPaternalist >= V.FSLockinLevel * 0.3) {
		titles.push("Pursuer of Justice");
	}

	if (V.arcologies[0].FSRepopulationFocus >= V.FSLockinLevel * 0.9) {
		titles.push("the Savior of the Future");
	} else if (V.arcologies[0].FSRepopulationFocus >= V.FSLockinLevel * 0.6) {
		titles.push("the Holder of the Future");
	} else if (V.arcologies[0].FSRepopulationFocus >= V.FSLockinLevel * 0.3) {
		titles.push("the Repopulist");
	}

	if (V.arcologies[0].FSDegradationist >= V.FSLockinLevel * 0.9) {
		titles.push("Holder of the Rod and the Lash");
	} else if (V.arcologies[0].FSDegradationist >= V.FSLockinLevel * 0.6) {
		titles.push("Subduer of Slaves");
	} else if (V.arcologies[0].FSDegradationist >= V.FSLockinLevel * 0.3) {
		titles.push("Pursuer of Justice");
	}

	if (V.arcologies[0].FSIntellectualDependency >= V.FSLockinLevel * 0.9) {
		titles.push("Sovereign of Bimbos");
	} else if (V.arcologies[0].FSIntellectualDependency >= V.FSLockinLevel * 0.6) {
		titles.push("Shepherd of the Slow");
	} else if (V.arcologies[0].FSIntellectualDependency >= V.FSLockinLevel * 0.3) {
		titles.push("Lover of Bimbos");
	}

	if (V.arcologies[0].FSSlaveProfessionalism >= V.FSLockinLevel * 0.9) {
		titles.push("the Mastermind");
	} else if (V.arcologies[0].FSSlaveProfessionalism >= V.FSLockinLevel * 0.6) {
		titles.push("the Genius");
	} else if (V.arcologies[0].FSSlaveProfessionalism >= V.FSLockinLevel * 0.3) {
		titles.push("the Smart");
	}

	if (V.arcologies[0].FSBodyPurist >= V.FSLockinLevel * 0.9) {
		titles.push("the Purifier of the Breasts");
	} else if (V.arcologies[0].FSBodyPurist >= V.FSLockinLevel * 0.6) {
		titles.push("the Discerning");
	} else if (V.arcologies[0].FSBodyPurist >= V.FSLockinLevel * 0.3) {
		titles.push("the Tasteful");
	}

	if (V.arcologies[0].FSAssetExpansionist >= V.FSLockinLevel * 0.9) {
		titles.push("the Expander of the Breasts");
	} else if (V.arcologies[0].FSAssetExpansionist >= V.FSLockinLevel * 0.6) {
		titles.push("the Expander");
	} else if (V.arcologies[0].FSAssetExpansionist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("the Implanter");
		} else {
			titles.push("the Implantrix");
		}
	}

	if (V.arcologies[0].FSStatuesqueGlorification >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("He Who Stands Above All");
		} else {
			titles.push("She Who Stands Above All");
		}
	} else if (V.arcologies[0].FSStatuesqueGlorification >= V.FSLockinLevel * 0.6) {
		titles.push("Agent of Growth");
	} else if (V.arcologies[0].FSStatuesqueGlorification >= V.FSLockinLevel * 0.3) {
		titles.push("height fetishist");
	}

	if (V.arcologies[0].FSPetiteAdmiration >= V.FSLockinLevel * 0.9) {
		titles.push("Supporter of the Small");
	} else if (V.arcologies[0].FSPetiteAdmiration >= V.FSLockinLevel * 0.6) {
		titles.push("the Size Enthusiast");
	} else if (V.arcologies[0].FSPetiteAdmiration >= V.FSLockinLevel * 0.3) {
		titles.push("height fetishist");
	}

	if (V.arcologies[0].FSPastoralist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("the Master of Stock");
		} else {
			titles.push("the Mistress of Stock");
		}
	} else if (V.arcologies[0].FSPastoralist >= V.FSLockinLevel * 0.6) {
		titles.push("the Rancher");
	} else if (V.arcologies[0].FSPastoralist >= V.FSLockinLevel * 0.3) {
		titles.push("the Farmer");
	}

	if (V.arcologies[0].FSPhysicalIdealist >= V.FSLockinLevel * 0.9) {
		titles.push("beloved of Brodin");
	} else if (V.arcologies[0].FSPhysicalIdealist >= V.FSLockinLevel * 0.6) {
		if (V.PC.title === 1) {
			titles.push("he of the godlike Body");
		} else {
			titles.push("she of the godlike Body");
		}
	} else if (V.arcologies[0].FSPhysicalIdealist >= V.FSLockinLevel * 0.3) {
		titles.push("advancer of Gains");
	}

	if (V.arcologies[0].FSHedonisticDecadence >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Master of Softness");
		} else {
			titles.push("Lady of Softness");
		}
	} else if (V.arcologies[0].FSHedonisticDecadence >= V.FSLockinLevel * 0.6) {
		titles.push("Thickness Enthusiast");
	} else if (V.arcologies[0].FSHedonisticDecadence >= V.FSLockinLevel * 0.3) {
		titles.push("the Feeder");
	}

	if (V.arcologies[0].FSMaturityPreferentialist >= V.FSLockinLevel * 0.9) {
		titles.push("Grand Preserver of MILFS");
	} else if (V.arcologies[0].FSMaturityPreferentialist >= V.FSLockinLevel * 0.6) {
		titles.push("Fucker of MILFS");
	} else if (V.arcologies[0].FSMaturityPreferentialist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("he of the notorious MILF preference");
		} else {
			titles.push("she of the notorious MILF preference");
		}
	}

	if (V.arcologies[0].FSYouthPreferentialist >= V.FSLockinLevel * 0.9) {
		titles.push("Keeper of the Magnificent Young Harem");
	} else if (V.arcologies[0].FSYouthPreferentialist >= V.FSLockinLevel * 0.6) {
		titles.push("Keeper of Virgins");
	} else if (V.arcologies[0].FSYouthPreferentialist >= V.FSLockinLevel * 0.3) {
		titles.push("the Virginbreaker");
	}

	if (V.arcologies[0].FSChattelReligionistLaw === 1) {
		titles.push("the Prophet");
	} else if (V.arcologies[0].FSChattelReligionist >= V.FSLockinLevel * 0.9) {
		titles.push("Keeper of the Blade and Chalice");
	} else if (V.arcologies[0].FSChattelReligionist >= V.FSLockinLevel * 0.6) {
		titles.push("Champion of the Faith");
	} else if (V.arcologies[0].FSChattelReligionist >= V.FSLockinLevel * 0.3) {
		titles.push("the Holy");
	}

	if (V.arcologies[0].FSRomanRevivalist >= V.FSLockinLevel * 0.9) {
		titles.push("First Consul");
	} else if (V.arcologies[0].FSRomanRevivalist >= V.FSLockinLevel * 0.6) {
		titles.push("Aedile");
	} else if (V.arcologies[0].FSRomanRevivalist >= V.FSLockinLevel * 0.3) {
		titles.push("Quaestor");
	}

	if (V.arcologies[0].FSNeoImperialist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Immortal Emperor");
		} else {
			titles.push("Immortal Empress");
		}
	} else if (V.arcologies[0].FSNeoImperialist >= V.FSLockinLevel * 0.6) {
		if (V.PC.title === 1) {
			titles.push("His Highness");
		} else {
			titles.push("Her Highness");
		}
	} else if (V.arcologies[0].FSNeoImperialist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("Lord and Master");
		} else {
			titles.push("Lord and Mistress");
		}
	}

	if (V.arcologies[0].FSAztecRevivalist >= V.FSLockinLevel * 0.9) {
		titles.push("Tlatcani");
	} else if (V.arcologies[0].FSAztecRevivalist >= V.FSLockinLevel * 0.6) {
		titles.push("Cihuacoatl");
	} else if (V.arcologies[0].FSAztecRevivalist >= V.FSLockinLevel * 0.3) {
		titles.push("Tlatoani");
	}

	if (V.arcologies[0].FSEgyptianRevivalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("the Living God");
		} else {
			titles.push("the Living Goddess");
		}
	} else if (V.arcologies[0].FSEgyptianRevivalist >= V.FSLockinLevel * 0.6) {
		titles.push("Pharaoh");
	} else if (V.arcologies[0].FSEgyptianRevivalist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("Prince of the Nile");
		} else {
			titles.push("Princess of the Nile");
		}
	}

	if (V.arcologies[0].FSEdoRevivalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Emperor and Descendant of Amaterasu");
		} else {
			titles.push("Amaterasu Reborn");
		}
	} else if (V.arcologies[0].FSEdoRevivalist >= V.FSLockinLevel * 0.6) {
		titles.push("Shogun");
	} else if (V.arcologies[0].FSEdoRevivalist >= V.FSLockinLevel * 0.3) {
		titles.push("Daimyo");
	}

	if (V.arcologies[0].FSArabianRevivalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Caliph");
		} else {
			titles.push("Handmaiden of Allah");
		}
	} else if (V.arcologies[0].FSArabianRevivalist >= V.FSLockinLevel * 0.6) {
		if (V.PC.title === 1) {
			titles.push("Sultan");
		} else {
			titles.push("Sultana");
		}
	} else if (V.arcologies[0].FSArabianRevivalist >= V.FSLockinLevel * 0.3) {
		titles.push("Beloved of Allah");
	}

	if (V.arcologies[0].FSChineseRevivalist >= V.FSLockinLevel * 0.9) {
		if (V.PC.title === 1) {
			titles.push("Emperor and Holder of the Mandate of Heaven");
		} else {
			titles.push("Empress and Holder of the Mandate of Heaven");
		}
	} else if (V.arcologies[0].FSChineseRevivalist >= V.FSLockinLevel * 0.6) {
		if (V.PC.title === 1) {
			titles.push("Emperor");
		} else {
			titles.push("Empress");
		}
	} else if (V.arcologies[0].FSChineseRevivalist >= V.FSLockinLevel * 0.3) {
		if (V.PC.title === 1) {
			titles.push("Governor of the Province");
		} else {
			titles.push("Governess of the Province");
		}
	}

	const facilities = App.Entity.facilities;
	if (facilities.brothel.employeesIDs().size >= 15) {
		if (V.PC.title === 1) {
			titles.push("Procurator of the Brothel");
		} else {
			titles.push("Procuratrix of the Brothel");
		}
	}

	if (facilities.club.employeesIDs().size >= 15) {
		titles.push("First on the Club");
	}
	if (facilities.dairy.employeesIDs().size >= 15) {
		titles.push("Keeper of the Cattle");
	}
	if (cumSlaves().length >= 15) {
		if (V.PC.title === 1) {
			titles.push("Extractor of the Ejaculate");
		} else {
			titles.push("Extractrix of the Ejaculate");
		}
	}
	if (facilities.servantsQuarters.employeesIDs().size >= 15) {
		if (V.PC.title === 1) {
			titles.push("Director of the Servants");
		} else {
			titles.push("Directrix of the Servants");
		}
	}
	if (facilities.schoolroom.employeesIDs().size >= 10) {
		if (V.PC.title === 1) {
			titles.push("Educator of the Slaves");
		} else {
			titles.push("Educatrix of the Slaves");
		}
	}
	if (facilities.spa.employeesIDs().size >= 10) {
		titles.push("Order of the Bath");
	}
	if (facilities.arcade.employeesIDs().size >= 15) {
		titles.push("Comptroller of the Arcade");
	}
	if (V.nurseryChildren >= 10) {
		titles.push("Caretaker of the Youth");
	}

	const schoolsPresent = [];
	const schoolsPerfected = [];
	for (const [school, obj] of App.Data.misc.schools) {
		if (V[school].schoolProsperity >= 10) {
			schoolsPerfected.push(obj.title);
		} else if (V[school].schoolPresent === 1) {
			schoolsPresent.push(obj.title);
		}
	}

	if (schoolsPerfected.length > 0) {
		titles.push(`${V.PC.title === 1 ? 'Benefactor' : 'Benefactrix'} of ${toSentence(schoolsPerfected)}`);
	}
	if (schoolsPresent.length > 0) {
		titles.push(`Supporter of ${toSentence(schoolsPresent)}`);
	}

	if (V.TFS.schoolProsperity >= 10) {
		titles.push("Honorary Sibling of the Futanari Sisters");
	} else if (V.TFS.schoolPresent === 1) {
		if (V.PC.title === 1) {
			titles.push("Protector of the Futanari Sisters");
		} else {
			titles.push("Protectrix of the Futanari Sisters");
		}
	}

	if (V.slaves.length > 50) {
		if (V.PC.title === 1) {
			titles.push("Master of Slaves");
		} else {
			titles.push("Mistress of Slaves");
		}
	} else if (V.slaves.length > 40) {
		titles.push("Holder of Slaves");
	} else if (V.slaves.length > 30) {
		titles.push("Keeper of Slaves");
	} else if (V.slaves.length > 20) {
		titles.push("Manager of Slaves");
	} else if (V.slaves.length > 10) {
		titles.push("owner of slaves");
	}
	const corpValue = App.Corporate.calculateValue();
	if (corpValue > 500000) {
		titles.push("Corporate Titan");
	} else if (corpValue > 250000) {
		titles.push("corporate innovator");
	} else if (corpValue > 100000) {
		titles.push("corporate founder");
	} else if (corpValue > 0) {
		if (V.PC.title === 1) {
			titles.push("noted corporate businessman");
		} else {
			titles.push("noted corporate businesswoman");
		}
	}

	if (V.rep > 18000) {
		if (V.PC.title === 1) {
			titles.push("unquestioned Master of the Arcology");
		} else {
			titles.push("unquestioned Mistress of the Arcology");
		}
	} else if (V.rep > 15000) {
		if (V.PC.title === 1) {
			titles.push("Lord of the Arcology");
		} else {
			titles.push("Lady of the Arcology");
		}
	} else if (V.rep > 12000) {
		titles.push("Ruler of the Arcology");
	} else if (V.rep > 9000) {
		titles.push("chief officer of the arcology");
	} else {
		titles.push("owner of the arcology");
	}

	return toSentence(titles);
};

/**
 * @param {string|Array<string>} input
 * @param {number} [increase=1]
 * @returns {string}
 */
globalThis.IncreasePCSkills = function(input, increase = 1) {
	if (Array.isArray(input)) {
		return input.reduce((r, inputArray) => r + IncreasePCSkills(inputArray, increase), '');
	}
	const player = V.PC;
	const oldSkill = player.skill[input];
	player.skill[input] = Math.clamp(player.skill[input] + increase, -100, 100);
	let t = ``;

	if (oldSkill < 10 && player.skill[input] >= 10) {
		t += ` <span class="green">You have gained basic knowledge in ${input}.</span>`;
	} else if (oldSkill < 30 && player.skill[input] >= 30) {
		t += ` <span class="green">You have gained some knowledge in ${input}.</span>`;
	} else if (oldSkill < 60 && player.skill[input] >= 60) {
		t += ` <span class="green">You have become an expert in ${input}.</span>`;
	} else if (oldSkill < 100 && player.skill[input] >= 100) {
		t += ` <span class="green">You have mastered ${input}.</span>`;
	}
	return t;
};

/** Returns if the player is on mandatory bedrest.
 * @param {App.Entity.PlayerState} actor
 * @returns {boolean}
 */
globalThis.onBedRest = function(actor) {
	// consider player health and injury in the future!
	if (!actor) {
		return null;
	} else if (actor.health.shortDamage > 10) {
		return true;
	} else if (!canMove(actor)) {
		return true;
	} else if (actor.preg > actor.pregData.normalBirth / 1.05) { // consider birth delayers and how they play into this
		return true;
	} else if (actor.womb.find((ft) => ft.genetics.geneticQuirks.polyhydramnios === 2 && ft.age >= 20)) {
		return true;
	} else if (actor.bellyPreg >= actor.pregAdaptation * 2200) {
		return true;
	} else if (isInduced(actor)) {
		return true;
	}
	return false;
};

/** Returns if the player is able to move via wheelchair or scooter.
 * Consider moving this to encompass slaves in the future.
 * @param {App.Entity.PlayerState} actor
 * @returns {boolean}
 */
globalThis.isMovable = function(actor) {
	return false;
};

/** Returns if the player has grown so large they no longer can leave their bedroom.
 * @param {App.Entity.PlayerState} actor
 * @returns {boolean}
 */
globalThis.isTrapped = function(actor) {
	return false;
};

/** Returns if the player can eat solid food.
 * Consider moving this to encompass slaves in the future.
 * @param {App.Entity.PlayerState} actor
 * @returns {boolean}
 */
globalThis.canEatFood = function(actor) {
	if (!actor) {
		return null;
	} else if (actor.digestiveSystem === "atrophied") {
		return false;
	} else if (actor.mpreg > 0) {
		return false;
	} else if (actor.cervixImplant >= 2) {
		return false;
	}
	return true;
};

/**
 * Param takes "classic" PC careers, and then function checks data to see if PC has that classic career or a 4.0 equivalent.
 * @param {"wealth"|"escort"|"servant"|"gang"|"BlackHat"|"capitalist"|"mercenary"|"engineer"|"medicine"|"slaver"|"celebrity"|"arcology owner"} category
 * @returns {boolean}
 */
globalThis.isPCCareerInCategory = function(category) {
	try {
		return Object.values(App.Data.player.career.get(category)).includes(V.PC.career);
	} catch {
		console.log(`"${category}" not found in App.Data.player.career`);
		return false;
	}
};


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

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

App.UI.link = function() {
	let counter = 0;

	// reset all handlers for each passage
	$(document).on(':passageinit', function() {
		State.temporary.linkHandlers = {};
		counter = 0;
	});

	return makeLink;

	/**
	 * 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} [tooltip]
	 * @returns {string} link in SC markup
	 */
	function makeLink(linkText, handler, args = [], passage = '', tooltip = '') {
		// pack handler and data
		State.temporary.linkHandlers[counter] = {
			f: handler,
			args: Array.isArray(args) ? args : [args]
		};

		// can't say _linkHandlers here because SC does not recognize its own notation in "..._varName"
		let SCHandlerText =
			`State.temporary.linkHandlers[${counter}].f(...State.temporary.linkHandlers[${counter}].args);`;
		++counter;

		if (passage) {
			return App.UI.passageLink(linkText, passage, SCHandlerText, tooltip);
		} else {
			if (tooltip) {
				throw Error("Tooltips are not supported by the <<link>> markup.");
			}
			// data-passage scheme does not work with empty passage name
			return `<<link "${linkText}">><<run ${SCHandlerText}>><</link>>`;
		}
	}
}();

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

App.UI.tabBar = function() {
	return {
		openTab: openTab,
		tabButton: tabButton,
		makeTab: makeTab,
		handlePreSelectedTab: handlePreSelectedTab,
		tabChoiceVarName: tabChoiceVarName,
		openLeftTab: openLeft,
		openRightTab: openRight
	};

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

	/**
	 * @param {string} name
	 * @param {string} text
	 * @param {boolean} [plainLink]
	 * @returns {HTMLButtonElement|HTMLAnchorElement}
	 */
	function tabButton(name, text, plainLink = false) {
		if (plainLink) {
			const link = document.createElement("a");
			link.classList.add("tab-links", "pure");
			link.id = `tab ${name}`;
			link.textContent = text;
			link.addEventListener('click', event => {
				openTab(event, name);
			});
			return link;
		} else {
			const button = document.createElement("button");
			button.classList.add("tab-links");
			button.id = `tab ${name}`;
			button.textContent = text;
			button.addEventListener('click', event => {
				openTab(event, name);
			});
			return button;
		}
	}

	/**
	 * @param {string} name
	 * @param {Node} content
	 * @returns {HTMLDivElement}
	 */
	function makeTab(name, content) {
		const outerDiv = document.createElement("div");
		outerDiv.id = name;
		outerDiv.classList.add("tab-content");
		const innerDiv = document.createElement("div");
		innerDiv.classList.add("content");
		innerDiv.append(content);
		outerDiv.append(innerDiv);
		return outerDiv;
	}

	function handlePreSelectedTab(defaultTab = "assign", immidiate = false) {
		let selectedTab = V.tabChoice[tabChoiceVarName()];
		if (!selectedTab) {
			selectedTab = defaultTab;
		}

		function selectTab() {
			let tabBtn = document.getElementById(`tab ${selectedTab}`);
			if (!tabBtn) {
				tabBtn = /** @type {HTMLElement} */(document.getElementsByClassName('tab-links').item(0));
			}
			if (tabBtn) {
				tabBtn.click();
			}
		}
		if (immidiate) {
			selectTab();
		} else {
			$(document).one(':passageend', selectTab);
		}
	}

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

	function openLeft() {
		const tabLinks = /** @type {HTMLCollection<HTMLButtonElement>} */ document.getElementsByClassName("tab-links");
		const index = currentIndex(tabLinks);
		if (index - 1 >= 0) {
			tabLinks[index - 1].click();
		}
	}

	function openRight() {
		const tabLinks = /** @type {HTMLCollection<HTMLButtonElement>} */ document.getElementsByClassName("tab-links");
		const index = currentIndex(tabLinks);
		if (index > -1 && index + 1 < tabLinks.length) {
			tabLinks[index + 1].click();
		}
	}

	function currentIndex(collection) {
		// get current tab button
		for (let i = 0; i < collection.length; i++) {
			if (collection[i].classList.contains("active")) {
				return i;
			}
		}
		return -1;
	}
}();

/**
 * Generates a link which shows a slave description dialog for a specified slave.
 * Do not call from within another dialog.
 * @param {App.Entity.SlaveState} slave
 * @param {string} [text] link text to use instead of slave name
 * @param {FC.Desc.LongSlaveOptions} options
 * @returns {HTMLElement} link
 */
App.UI.DOM.slaveDescriptionDialog = function(slave, text, options = {descType: DescType.EVENT, noArt: true}) {
	return App.UI.DOM.link(text ? text : SlaveFullName(slave), () => {
		Dialog.setup(SlaveFullName(slave));
		App.UI.DOM.drawOneSlaveRight(Dialog.body(), slave);
		Dialog.append(App.Desc.longSlave(slave, options));
		Dialog.open();
	});
};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {string} text
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.referenceSlaveWithPreview = function(slave, text) {
	const res = App.UI.DOM.makeElement("span", text, "has-tooltip");
	tippy(res, {
		content: App.UI.DOM.generateLinksStrip([
			App.UI.DOM.slaveDescriptionDialog(slave, "Pop-up", {noArt: true}),
			App.UI.SlaveList.SlaveInteract.stdInteract(slave, "Go to")
		]),
		interactive: true
	});
	return res;
};

/**
 * Reloads the passage and stays at the same height.
 */
App.UI.reload = function() {
	const position = window.pageYOffset;
	Engine.play(passage());
	window.scrollTo(0, position);
};

/**
 * Renders passage into a document fragment
 * NOTE: This is a greatly simplified version of the SC Passage.render() private function
 * This function does not trigger :passagestart and :passagedisplay events
 * @param {string} passageTitle
 * @returns {DocumentFragment} document fragment with passage contents
 */
App.UI.DOM.renderPassage = function(passageTitle) {
	const res = document.createDocumentFragment();
	if (ProfileInclude.IsEnabled) {
		ProfileInclude.IncludeBegins(passageTitle);
	}
	$(res).wiki(jsInclude(passageTitle));
	if (ProfileInclude.IsEnabled) {
		ProfileInclude.IncludeEnds();
	}
	return res;
};

/**
 * Render passage and append the rendered content to the container
 * @param {Node} container
 * @param {string} passageTitle
 */
App.UI.DOM.includePassage = function(container, passageTitle) {
	return $(container).append(this.renderPassage(passageTitle));
};