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 {FC.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 the given link with a 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} [collapsed]
 * @param {object} [extras]
 * @param {string} [extras.closeButtonText="Close Report"] if an empty string is provided then the close button will be disabled
 * @param {Function} [extras.callback] this function will be called each time the accordion is collased or expanded. It will be passed false for expanded and true for collapsed
 * @returns {DocumentFragment}
 */
App.UI.DOM.accordion = function(
	head,
	content,
	collapsed = true,
	extras = {},
) {
	extras = extras ?? {};
	App.Utils.assignMissingDefaults(extras, {
		closeButtonText: "Close Report",
		callback: undefined,
	});
	const fragment = document.createDocumentFragment();
	const button = App.UI.DOM.appendNewElement("button", fragment, head, ["accordion"]);

	const closeButton = document.createElement("button");
	closeButton.classList.add("assignment-report-btn");
	closeButton.textContent = extras.closeButtonText;
	closeButton.onclick = () => {
		button.classList.add("closed");
		for (let htmlElement of [content]) {
			htmlElement.classList.add("hidden");
		}
		App.UI.DOM.updateToggleAllButtonText();
	};

	if (content) {
		if (extras.closeButtonText !== "") {
			content.append(closeButton);
		}
		App.UI.DOM.elementToggle(button, [content], collapsed, extras.callback);
		fragment.append(content);
	} else {
		button.classList.add("empty");
	}
	return fragment;
};

/**
 * @param {HTMLElement} toggleElement
 * @param {Array<HTMLElement>} content
 * @param {boolean} [startHidden=true]
 * @param {Function} [callback=undefined] this function will be called each time the element is toggled. It will be passed false for open and true for closed
 */
App.UI.DOM.elementToggle = function(toggleElement, content, startHidden = true, callback = undefined) {
	toggleElement.classList.add("accordion");
	for (let htmlElement of content) {
		htmlElement.classList.add("accordion-content");
	}
	const toggle = () => {
		toggleElement.classList.toggle("closed");
		if (callback) {
			if (toggleElement.classList.contains("closed")) {
				callback(true);
			} else {
				callback(false);
			}
		}
		for (let htmlElement of content) {
			htmlElement.classList.toggle("hidden");
		}
		App.UI.DOM.updateToggleAllButtonText();
	};
	toggleElement.onclick = toggle;
	if (startHidden) {
		toggle();
	}
};

/**
 * Update text for open all/close all report buttons on assignment report page
 */
App.UI.DOM.updateToggleAllButtonText = function() {
	const toggleAllButtons = document.querySelectorAll("button.toggle-all-btn");
	const accordionElements = Array.from(document.querySelectorAll("button.accordion"));
	const anyOpenReports = accordionElements
		.some((element) => !(element.classList.contains("closed") || element.classList.contains("empty")));

	if (anyOpenReports) {
		toggleAllButtons.forEach((button) => {
			button.textContent = "Close All Reports";
		});
	} else {
		toggleAllButtons.forEach((button) => {
			button.textContent = "Open All Reports";
		});
	}
};

/*
	<<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, `Please provide a text copy (or screenshot) of this error message starting with \`\`\` and ending with 'End of error message' below, a save file, and any other relevant information.`);
		fragment.append(App.UI.DOM.makeElement("br"));
		App.UI.DOM.appendNewElement("div", header, `\`\`\``);
		fragment.append(App.UI.DOM.makeElement("br"));
		App.UI.DOM.appendNewElement("div", header, `Apologies! An error has occurred. Please report this.`, ["bold"]);
		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 line of lines) {
					const div = document.createElement("div");
					// remove file path from error message
					div.append(line.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);

		fragment.append(App.UI.DOM.makeElement("br"));

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

		fragment.append(App.UI.DOM.makeElement("br"));

		fragment.append(App.UI.DOM.makeElement("div", `End of error message`));

		fragment.append(App.UI.DOM.makeElement("br"));

		const reportDiv = App.UI.DOM.makeElement("div", `Please report this error `);

		const reportLink = App.UI.DOM.makeElement("a", "here");

		reportLink.classList.add("link-external");
		reportLink.href = "https://gitgud.io/pregmodfan/fc-pregmod/issues/";
		reportLink.target = "_blank";

		reportDiv.append(reportLink);

		fragment.append(reportDiv);

		fragment.append(App.UI.DOM.makeElement("br"));

		fragment.append(
			App.UI.DOM.generateLinksStrip([
				App.UI.DOM.passageLink(
					"Run BC from here and return to here", "Backwards Compatibility",
					() => { V.nextLink = passage(); }
				),
				App.UI.DOM.passageLink(
					"Run BC from here and continue", "Backwards Compatibility"
				),
				App.UI.DOM.passageLink(
					"Run BC from here and go to the main menu", "Backwards Compatibility",
					() => { V.nextLink = "Main"; }
				)
			])
		);

		fragment.append(App.UI.DOM.makeElement("br"));

		fragment.append(App.UI.DOM.makeElement(
			"span",
			`These may fix things or they may make things worse. Please create a save and report this error before trying these options. We suggest trying these in order; reloading from a save between each attempt.`,
			["note"]
		));

		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 {FC.HumanState} 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"]);
};

/**
 * @param {string} text
 * @param {string} url
 * @returns {HTMLAnchorElement}
 */
App.UI.DOM.createExternalLink = (text, url) => {
	const a = document.createElement("a");
	a.classList.add("link-external");
	a.target = "_blank";
	a.textContent = text;
	a.href = url;
	return a;
};

/**
 * Creates a high definition canvas of a given width and height.
 * Stolen/borrowed from https://stackoverflow.com/a/65124939
 * Note that the plotted points should use the prescaled width and height.
 * @param {number} width
 * @param {number} height
 * @returns {HTMLCanvasElement}
 */
App.UI.DOM.createCanvas = (width, height) => {
	const ratio = window.devicePixelRatio;
	const canvas = document.createElement("canvas");

	canvas.width = width * ratio;
	canvas.height = height * ratio;
	canvas.style.width = width + "px";
	canvas.style.height = height + "px";
	canvas.getContext("2d").scale(ratio, ratio);
	// turn off anti-aliasing for the canvas
	canvas.style.imageRendering = "pixilated";
	return canvas;
};

/**
 * @returns {HTMLHRElement}
 */
App.UI.sectionBreak = function() {
	const hr = document.createElement("hr");
	hr.style.margin = "0";
	return hr;
};


code:
// cSpell:ignore Musc

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

/**
 * Capitalizes all the words in a string
 * @param {string} str string to capitalize
 * @returns {string} string with all words capitalized
 *
 * @example "apple, banana, carrot"
 * // returns "Apple, Banana, Carrot"
 */
App.Utils.capitalizeAll = function(str) {
	return str.split(" ").map((word) => {
		return word.charAt(0).toUpperCase() + word.slice(1);
	}).join(" ");
};

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

/**
 * Sets obj[key] to value.
 * @param {object} obj
 * @param {string} key
 * @param {*} value
 */
globalThis.setProp = (obj, key, value) => {
	obj[key] = value;
};

/**
 * Gets the value of obj[key].
 * returns fallback if obj[key] is undefined or doesn't exist.
 * @param {object} obj
 * @param {string} key
 * @param  {*} [fallback]
 */
globalThis.getProp = (obj, key, fallback) => {
	if (!obj) { throw new Error("getProp was given a non existant object!"); }
	if (!(key in obj) || obj[key] === undefined) { return fallback; }
	return obj[key];
};

/**
 * Deletes one or more properties.
 * @param {object} obj
 * @param {...string} keys
 */
globalThis.deleteProps = (obj, ...keys) => {
	const props = [...keys];
	props.forEach((prop) => {
		delete obj[prop];
	});
};

/**
 * Returns true if the given object has all the properites need to be a PlayerState object.
 * Returns false if the object doesn't exist, is a number, or is missing required properties
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {boolean}
 */
globalThis.isPlayer = (human, log=false) => {
	// TODO:@franklygeorge this needs to do a recursive check instead of the current flat check
	const propsObj = new App.Entity.PlayerState();
	const props = Object.keys(propsObj).filter((key) => {
		return propsObj[key] !== undefined;
	});
	if (!human || typeof human === "number") {
		if (log) { console.warn(`isPlayer: human is a number or undefined`); }
		return false;
	}
	const missing = App.Utils.listMissingProps(human, props);
	if (log && missing) { console.warn(`isPlayer: human with ID ${human.ID} was missing these properties: ${JSON.stringify(missing)}`); }
	return !missing;
};

/**
 * Returns the object unmodified, but as type FC.PlayerState.
 * Returns null if isPlayer(human) returns false.
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {FC.PlayerState}
 */
globalThis.asPlayer = (human, log=false) => {
	if (!isPlayer(human, log)) { return null; }
	return /** @type {FC.PlayerState} */(human);
};

/**
 * Returns true if the given object has all the properites need to be a SlaveState object.
 * Returns false if the object doesn't exist, is a number, or is missing required properties
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {boolean}
 */
globalThis.isSlave = (human, log=false) => {
	// TODO:@franklygeorge this needs to do a recursive check instead of the current flat check
	const propsObj = new App.Entity.SlaveState();
	const props = Object.keys(propsObj).filter((key) => {
		return propsObj[key] !== undefined;
	});
	if (!human || typeof human === "number") {
		if (log) { console.warn(`isSlave: human is a number or undefined`); }
		return false;
	}
	const missing = App.Utils.listMissingProps(human, props);
	if (log && missing) { console.warn(`isSlave: human with ID ${human.ID} was missing these properties: ${JSON.stringify(missing)}`); }
	return !missing;
};

/**
 * Returns the object unmodified, but as type FC.SlaveState.
 * Returns null if isSlave(human) returns false.
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {FC.SlaveState}
 */
globalThis.asSlave = (human, log=false) => {
	if (!isSlave(human, log)) { return null; }
	return /** @type {FC.SlaveState} */(human);
};

/**
 * Returns true if the given object has all the properites need to be a ChildState object.
 * Returns false if the object doesn't exist, is a number, or is missing required properties
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {boolean}
 */
globalThis.isChild = (human, log=false) => {
	// TODO:@franklygeorge this needs to do a recursive check instead of the current flat check
	const propsObj = new App.Entity.ChildState();
	const props = Object.keys(propsObj).filter((key) => {
		return propsObj[key] !== undefined;
	});
	if (!human || typeof human === "number") {
		if (log) { console.warn(`isChild: human is a number or undefined`); }
		return false;
	}
	const missing = App.Utils.listMissingProps(human, props);
	if (log && missing) { console.warn(`isChild: human with ID ${human.ID} was missing these properties: ${JSON.stringify(missing)}`); }
	return !missing;
};

/**
 * Returns the object unmodified, but as type FC.ChildState.
 * Returns null if isChild(human) returns false.
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {FC.ChildState}
 */
globalThis.asChild = (human, log=false) => {
	if (!isChild(human, log)) { return null; }
	return /** @type {FC.ChildState} */(human);
};

/**
 * Returns true if the given object has all the properites need to be a InfantState object.
 * Returns false if the object doesn't exist, is a number, or is missing required properties
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {boolean}
 */
globalThis.isInfant = (human, log=false) => {
	// TODO:@franklygeorge this needs to do a recursive check instead of the current flat check
	const propsObj = new App.Entity.InfantState();
	const props = Object.keys(propsObj).filter((key) => {
		return propsObj[key] !== undefined;
	});
	if (!human || typeof human === "number") {
		if (log) { console.warn(`isInfant: human is a number or undefined`); }
		return false;
	}
	const missing = App.Utils.listMissingProps(human, props);
	if (log && missing) { console.warn(`isInfant: human with ID ${human.ID} was missing these properties: ${JSON.stringify(missing)}`); }
	return !missing;
};

/**
 * Returns the object unmodified, but as type FC.InfantState.
 * Returns null if isInfant(human) returns false.
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {FC.InfantState}
 */
globalThis.asInfant = (human, log=false) => {
	if (!isInfant(human, log)) { return null; }
	return /** @type {FC.InfantState} */(human);
};

/**
 * Returns true if the given object has all the properites need to be a TankSlaveState object.
 * Returns false if the object doesn't exist, is a number, or is missing required properties
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {boolean}
 */
globalThis.isTankSlave = (human, log=false) => {
	// TODO:@franklygeorge this needs to do a recursive check instead of the current flat check
	const propsObj = new App.Entity.TankSlaveState();
	const props = Object.keys(propsObj).filter((key) => {
		return propsObj[key] !== undefined;
	});
	if (!human || typeof human === "number") {
		if (log) { console.warn(`isTankSlave: human is a number or undefined`); }
		return false;
	}
	const missing = App.Utils.listMissingProps(human, props);
	if (log && missing) { console.warn(`isTankSlave: human with ID ${human.ID} was missing these properties: ${JSON.stringify(missing)}`); }
	return !missing;
};

/**
 * Returns the object unmodified, but as type FC.TankSlaveState.
 * Returns null if isTankSlave(human) returns false.
 * @param {FC.Zeroable<FC.HumanState>} human
 * @param {boolean} [log=false] [default=false] if true then we may call console.warn with info
 * @returns {FC.TankSlaveState}
 */
globalThis.asTankSlave = (human, log=false) => {
	if (!isTankSlave(human, log)) { return null; }
	return /** @type {FC.TankSlaveState} */(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;
};

/**
 * Returns the name of the last passage.
 * `lastPassage() === "Slave Interact"` would be the equivalent of `lastVisited("Slave Interact") === 1`.
 * @returns {string} the name of the last passage.
 */
globalThis.lastPassage = () => {
	return V?.passageHistory?.last() ?? "Main";
};

/**
 * Checks if the current passage equals the last passage on the stack and if so it removes it from the stack.
 * If `passage() === "Main"` then we erase the history stack.
 * This is called for all passages in `src\zz1-last\setupEventHandlers.js`
 */
App.Utils.passageHistoryCleanup = () => {
	if (!V?.passageHistory) { return; }
	if (passage() === "Main") {
		V.passageHistory = [];
	} else {
		while (V.passageHistory.last() === passage()) {
			V.passageHistory.pop();
		}
	}
};

/**
 * Adds the current passage to the passage history stack.
 * @param {string} [currentPassage] (optional) The current passage name.
 * This is called for all passages in `src\zz1-last\setupEventHandlers.js`
 */
App.Utils.passageHistoryAdd = (currentPassage = passage()) => {
	if (!V?.passageHistory) { return; }
	if (currentPassage) {
		if (currentPassage !== V.passageHistory.last()) {
			V.passageHistory.push(currentPassage);
		}
	} else {
		throw new Error("currentPassage must be a string");
	}
};

/**
 * 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 getSlaves().values()) {
		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(App.Facilities.Farmyard.foodSellValue(App.Facilities.Farmyard.foodAvailable()));

	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 + App.Entity.facilities.servantsQuarters.capacity + 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 (App.Entity.facilities.servantsQuarters.established) {
		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.SlaveTrinketData} [object]
 */
globalThis.addTrinket = (name, object) => {
	const trinket = V.trinkets.get(name);
	if (object) {
		if (!trinket) {
			V.trinkets.set(name, {
				slaveTrinket: true,
				data: new Set([object]),
			});
		} else if (trinket.slaveTrinket) {
			trinket.data = trinket.data ?? new Set();
			trinket.data.add(object);
		} else {
			throw new Error(`Attempted to add SlaveTrinketData to "${name}" which is not a slave trinket`);
		}
	} else {
		if (!trinket) {
			V.trinkets.set(name, {
				slaveTrinket: false,
				count: 1,
			});
		} else if (!trinket.slaveTrinket) {
			trinket.count = (trinket?.count ?? 0) + 1;
		} else {
			throw new Error(`SlaveTrinketData object was not provided when trying to add "${name}" which is a slave trinket`);
		}
	}
};

/**
 * returns false if the trinket doesn't exist or it is a slave trinket with no FC.IDs associated with it
 * returns the amount of trinkets the player has under the given name if `count = true`
 * returns a set of FC.IDs if the trinket is a slave trinket
 * returns true if none of the above is applicable
 * @param {string} name
 * @param {boolean} [count=false]
 * @returns {boolean | Set<FC.HumanID> | number}
 */
globalThis.hasTrinket = (name, count = false) => {
	const trinket = V.trinkets.get(name);
	if (!trinket) { return false; }
	if (count) { return trinket?.count ?? trinket?.data?.size ?? 0; }
	if (trinket.slaveTrinket) {
		/** @type {Set<FC.HumanID>} */
		const slaveIDs = new Set([]);
		trinket?.data?.forEach((data) => {
			slaveIDs.add(data.id);
		});
		slaveIDs.delete(undefined);
		return slaveIDs.size === 0 ? false : slaveIDs;
	}
	return true;
};

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

/**
 * @typedef {object} geneToGenderOptions
 * @property {boolean} keepKaryotype if true then we will keep the karyotype. `XX` = `Female (XX)
 * @property {boolean} lowercase if true then we will make the output lower case. the karyotype is exempt from this.
 */

/**
 * Takes a karyotype (XX, XY, X, etc) and converts it to a gender (Female, Male, Turner Syndrome Female, etc)
 * @param {FC.GenderGenes} karyotype the karyotype to convert
 * @param {geneToGenderOptions} [options] {keepKaryotype: false, lowercase: true}
 * @returns {string} the gender that matches the karyotype
 */
globalThis.geneToGender = (karyotype, options = {
	keepKaryotype: false,
	lowercase: true,
}) => {
	/** @type {string} */
	let gender = {
		XX: 'Female',
		XY: 'Male',
		X: 'Turner Syndrome Female',
		X0: 'Turner Syndrome Female',
		XYY: 'XYY Syndrome Male',
		XXY: 'Klinefelter Syndrome Male',
		XXX: 'triple X Syndrome Female'
	}[String(karyotype).toUpperCase()] || `Unknown Gender: ${String(karyotype)}`;
	if (options.lowercase === true) {
		gender = gender.toLowerCase();
	}
	if (options.keepKaryotype === true && !gender.toLowerCase().startsWith("unknown gender")) {
		gender = `${gender} (${String(karyotype).toUpperCase()})`;
	}
	return gender;
};

/**
 * Makes sure a given options object conforms to a given specification
 * @param {any} options The options to be sanitized. Will throw an error if it is not null, undefined, or an object
 * @param {any} defaultOptions The default options. Will throw an error if this is not an object
 * @param {boolean} [errorOnUnknownKeys=true] If true we will throw an error if a key in options is not in defaultOptions
 * @returns {any} The sanitized options
 */
globalThis.sanitizeOptions = (options, defaultOptions, errorOnUnknownKeys = true) => {
	options = options ?? {}; // if options is undefined or null make it an object
	if (typeof options !== 'object') { throw new Error(`options is not an object. Got type "${typeof options}" instead`); }
	if (typeof defaultOptions !== 'object') { throw new Error(`defaultOptions is not an object. Got type "${typeof defaultOptions}" instead`); }

	for (const key of Object.getOwnPropertyNames(defaultOptions)) {
		options[key] = options[key] ?? clone(defaultOptions[key]);
	} // Add missing properties to options

	if (errorOnUnknownKeys) {
		for (const key of Object.getOwnPropertyNames(options)) {
			if (!defaultOptions.hasOwnProperty(key)) {
				throw new Error(`option "${key}" doesn't exist in defaultOptions`);
			}
		} // If the key doesn't exist in defaultOptions, throw an error
	}

	return options; // return the filled out options
};

/**
 * @returns {string} the current date and time
 */
App.Utils.getDatestamp = () => {
	const now = new Date();
	let MM = now.getMonth() + 1;
	let DD = now.getDate();
	let hh = now.getHours();
	let mm = now.getMinutes();
	let ss = now.getSeconds();

	// @ts-ignore
	if (MM < 10) { MM = `0${MM}`; }
	// @ts-ignore
	if (DD < 10) { DD = `0${DD}`; }
	// @ts-ignore
	if (hh < 10) { hh = `0${hh}`; }
	// @ts-ignore
	if (mm < 10) { mm = `0${mm}`; }
	// @ts-ignore
	if (ss < 10) { ss = `0${ss}`; }

	return `${now.getFullYear()}${MM}${DD}-${hh}${mm}${ss}`;
};

/**
 * @param {string} [baseName] the very first part of the filename; defaults to "free-cities".
 * @param {string} [extension] the file extension; defaults to "save".
 * @param {App.Entity.HumanState} [s] an optional state for character export
 * @returns {string} a string that is useful for filenames.
 */
App.Utils.getSaveFilename = (baseName='free-cities', extension='save', s = null) => {
	const arcologyName = V.arcologies === undefined || V.arcologies.length === 0 ? "New_Game_Setup" : V.arcologies[0].name.replaceAll(' ', '_').substring(0, 20);
	const week         = String(V.week).padStart(5, '0');
	const slave        = !s
		? String(slaveCount()).padStart(5, '0')
		: `${s.ID > 0 ? 'ID' + String(s.ID).padStart(5, '0'): 'PC'}-${((s.slaveSurname ? s.slaveSurname : '' ) + s.slaveName).replaceAll(' ', '').substring(0, 30)}`;
	const release      = String(V.releaseID).padStart(5, '0');
	const date         = App.Utils.getDatestamp();
	extension = extension ? `.${extension}` : '';

	return `${baseName}-${arcologyName}-W${week}-S${slave}-R${release}-${date}${extension}`;
};

/**
 * Resets the transistion object to it's default value
 */
App.Utils.resetTransitionObject = () => {
	V.temp = {};
};

repo_name: Nithhogg/fc

license: mit


code:
// cSpell:ignore Educatrix, Tlatoani, Cihuacoatl, Implantrix, Repopulist, Pharmacologes, Pharmacologos

/**
 * @returns {FC.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 (App.Mods.SF.fsIntegration.crisis()[1]) {
			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");
		}
	}

	if (V.arcologies[0].FSAntebellumRevivalist >= V.FSLockinLevel * 0.9) {
		titles.push(`Reclaimer of the Cause and ${V.PC.title === 1 ? 'Lord' : 'Lady'} of ${V.arcologies[0].name}`);
	} else if (V.arcologies[0].FSAntebellumRevivalist >= V.FSLockinLevel * 0.6) {
		titles.push(`${V.PC.title === 1 ? 'Lord' : 'Lady'} of ${V.arcologies[0].name}`);
	} else if (V.arcologies[0].FSAntebellumRevivalist >= V.FSLockinLevel * 0.3) {
		titles.push(`${V.PC.title === 1 ? 'Son' : 'Daughter'} of the South`);
	}

	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 (slaveCount() > 50) {
		if (V.PC.title === 1) {
			titles.push("Master of Slaves");
		} else {
			titles.push("Mistress of Slaves");
		}
	} else if (slaveCount() > 40) {
		titles.push("Holder of Slaves");
	} else if (slaveCount() > 30) {
		titles.push("Keeper of Slaves");
	} else if (slaveCount() > 20) {
		titles.push("Manager of Slaves");
	} else if (slaveCount() > 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.
 * If player still conducts business or is completely disabled is controlled by 'workingFromBed'
 * @param {FC.PlayerState} actor
 * @param {boolean} workingFromBed
 * @returns {boolean}
 */
globalThis.onBedRest = function(actor, workingFromBed = false) {
	// consider player injuries in the future!
	if (!actor) {
		return null;
	} else if (actor.majorInjury > 0) {
		return true;
	} else if (actor.health.condition < -90) {
		return true;
	} else if (!isMovable(actor) && !workingFromBed) {
		return true;
	} else if (isTrapped(actor) && !workingFromBed) {
		return true;
	} else if (actor.preg > actor.pregData.normalBirth / 1.05 && actor.pregControl !== "labor suppressors") { // consider if the player should be able to ignore contractions
		return true;
	} else if (actor.womb.find((ft) => ft.genetics.geneticQuirks.polyhydramnios === 2 && ft.age >= 20) && !workingFromBed) {
		return true;
	} else if (actor.bellyPreg >= actor.pregAdaptation * 2200 && actor.geneMods.progenitor !== 1 && !workingFromBed) {
		return true;
	} else if (isInduced(actor)) {
		return true;
	} else if (actor.geneMods.rapidCellGrowth !== 1 && actor.bellyPreg >= 100000 && actor.belly > (actor.pregAdaptation * 3200) && (actor.bellyPreg >= 500000 || actor.wombImplant !== "restraint")) {
		return true;
	}
	return false;
};

/** Returns if the player is able to move via wheelchair.
 * Consider moving this to encompass slaves in the future.
 * Ridiculously far future proofing.
 * @param {FC.PlayerState} actor
 * @returns {boolean}
 */
globalThis.isMovable = function(actor) {
	if (!actor) {
		return null;
	} else if (canMove(actor)) {
		return true;
	} else if (actor.boobs >= 1500000) {
		return false;
	} else if (actor.balls >= 160) {
		return false;
	} else if (actor.belly >= 1100000) {
		return false;
	} else if (actor.weight >= 300) {
		return false;
	}
	return true;
};

/** Returns if the player has grown so large they no longer can leave their bedroom.
 * Slaves can force their way. (add health drop for this!)
 * @param {FC.PlayerState} actor
 * @returns {boolean}
 */
globalThis.isTrapped = function(actor) {
	if (!actor) {
		return null;
	} else if (V.buttAccessibility === 1 || V.pregAccessibility === 1 || V.ballsAccessibility === 1 || V.boobAccessibility === 1) {
		return false;
	} else if (actor.boobsImplant >= 600000 || actor.boobs >= 1000000) {
		return true;
	} else if (actor.belly >= 800000) {
		return true;
	} else if (actor.dick >= 100) {
		return true;
	} else if (actor.balls >= 100) {
		return true;
	} else if (actor.buttImplant >= 30) {
		return true;
	}
	return false;
};

/** How badly hindered is the player? Stacks from various sources up until a 75% reduction in your ability to do business.
 * You have to seriously try to fuck yourself up that badly; just stacking basic hindrances will only get you to 50%.
 * This probably still needs more adjustments.
 * @param {FC.HumanState} actor
 * @param {boolean} sexyTime
 * @returns {number}
 */
globalThis.isHinderedDegree = function(actor, sexyTime = false) {
	let mult = 1;
	const averageHeight = Height.mean(actor);
	const estimatedSackSag = ballsToCM(actor.balls) * .8 * (1 + ((actor.scrotum - actor.balls) * .5));
	// player exclusives
	const pc = asPlayer(actor);
	if (!pc) {
		return mult;
	}
	// PC on bedrest plans to work from home.
	if (onBedRest(pc) && !sexyTime) { // And since the player only takes a limited number of clients while whoring, this shouldn't affect it.
		return .5;
	} else if (pc.physicalImpairment !== 0) {
		mult -= .3;
	// } else if (actor.energy > 95 || actor.need > 0) {
	//	return true;
	}
	// not really worrying about this for now
	if (!isQuadrupedal(pc) && hasAnyQuadrupedLegs(pc)) {
		mult -= .2;
	} else if (hasBothQuadrupedLegs(pc) && !hasBothQuadrupedArms(pc)) {
		mult -= .1;
	} else if (!hasBothQuadrupedLegs(pc) && hasAnyQuadrupedLegs(pc) && !hasBothQuadrupedArms(pc) && hasAnyQuadrupedArms(pc)) {
		mult -= .3;
	}
	// Not having to walk drastically affects how much assets are a hindrance
	if (!canWalk(pc)) {
		mult -= .1; // Of course, not being able to walk is also a hindrance
		if (pc.belly >= 60000 || pc.belly >= 60000 / (1 + Math.pow(Math.E, -0.4 * (pc.physicalAge - 14))) || pc.belly >= Math.max(10000, ((12500 / 19) * pc.height) - (1172500 / 19))) {
			mult -= .1;
		}
		if (V.pregAccessibility === 1) {
			if (pc.belly >= 1000000) {
				mult -= .1;
			} else if (pc.belly >= 500000) {
				mult -= .05;
			}
		} else {
			if (pc.belly >= 1000000) {
				mult -= .25;
			} else if (pc.belly >= 750000) {
				mult -= .2;
			} else if (pc.belly >= 500000) {
				mult -= .15;
			} else if (pc.belly >= 250000) {
				mult -= .1;
			} else if (pc.belly >= 100000) {
				mult -= .05;
			}
		}
		// Assets do not hinder prostitutes
		if (!sexyTime) {
			if (pc.muscles > 95 && pc.height <= (averageHeight + 10)) {
				mult -= .05;
			} else if (pc.muscles < -95) {
				mult -= .3;
			} else if (pc.muscles < -60) {
				mult -= .1;
			} else if (pc.muscles < -30) {
				mult -= .05;
			}
			if (pc.weight >= 190) {
				mult -= .05;
			}
			if (pc.boobs > 5000) {
				mult -= .1;
			}
			if (V.boobAccessibility === 1) {
				if (pc.boobs > 75000) {
					mult -= .1;
				}
			} else {
				if (pc.boobs > 75000) {
					mult -= .2;
				} else if (pc.boobs > 50000) {
					mult -= .15;
				} else if (pc.boobs > 25000) {
					mult -= .1;
				}
			}
			if (pc.balls >= 14) {
				mult -= .1;
			}
			if (V.ballsAccessibility === 1) {
				if (estimatedSackSag > (pc.height / 2)) {
					mult -= .05;
				}
			} else {
				if (estimatedSackSag > (pc.height / 2)) {
					mult -= .1;
				}
			}
			if (pc.hips > 2) {
				mult -= .1;
			}
			if (pc.butt > 6) {
				mult -= .1;
			}
			if (V.buttAccessibility === 1) {
				if (pc.butt > 15) {
					mult -= .05;
				}
			} else {
				if (pc.butt > 15) {
					mult -= .1;
				}
			}
		}
	} else {
		if (pc.belly >= 60000 || pc.belly >= 60000 / (1 + Math.pow(Math.E, -0.4 * (pc.physicalAge - 14))) || pc.belly >= Math.max(10000, ((12500 / 19) * pc.height) - (1172500 / 19))) {
			mult -= .1;
		}
		if (V.pregAccessibility === 1) {
			if (pc.belly >= 1000000) {
				mult -= .2;
			} else if (pc.belly >= 500000) {
				mult -= .1;
			}
		} else {
			if (pc.belly >= 1000000) {
				mult -= .5;
			} else if (pc.belly >= 750000) {
				mult -= .4;
			} else if (pc.belly >= 500000) {
				mult -= .3;
			} else if (pc.belly >= 250000) {
				mult -= .2;
			} else if (pc.belly >= 100000) {
				mult -= .1;
			}
		}
		// Assets do not hinder prostitutes
		if (!sexyTime) {
			if (pc.muscles > 95 && pc.height <= (averageHeight + 10)) {
				mult -= .1;
			} else if (pc.muscles < -95) {
				mult -= .7;
			} else if (pc.muscles < -60) {
				mult -= .5;
			} else if (pc.muscles < -30) {
				mult -= .2;
			}
			if (pc.weight >= 130 || (pc.weight >= 95 + ((pc.physicalAge - 9) * 5))) {
				mult -= .1;
			}
			if (pc.boobs > 5000) {
				mult -= .1;
			}
			if (V.boobAccessibility === 1) {
				if (pc.boobs > 75000) {
					mult -= .15;
				} else if (pc.boobs > 50000) {
					mult -= .1;
				}
			} else {
				if (pc.boobs > 75000) {
					mult -= .6;
				} else if (pc.boobs > 50000) {
					mult -= .4;
				} else if (pc.boobs > 25000) {
					mult -= .2;
				} else if (pc.boobs > 10000) {
					mult -= .1;
				}
			}
			if (pc.balls >= 14) {
				mult -= .1;
			}
			if (V.ballsAccessibility === 1) {
				if (estimatedSackSag > (pc.height / 2)) {
					mult -= .15;
				} else if (estimatedSackSag >= (pc.height / 2) - 10) {
					mult -= .1;
				} else if (estimatedSackSag >= (pc.height / 4)) {
					mult -= .05;
				}
			} else {
				if (estimatedSackSag > (pc.height / 2)) {
					mult -= .2;
				} else if (estimatedSackSag >= (pc.height / 2) - 10) {
					mult -= .15;
				} else if (estimatedSackSag >= (pc.height / 4)) {
					mult -= .1;
				}
			}
			if (pc.hips > 2) {
				mult -= .1;
			}
			if (pc.butt > 6) {
				mult -= .1;
			}
			if (V.buttAccessibility === 1) {
				if (pc.butt > 15) {
					mult -= .1;
				}
			} else {
				if (pc.butt > 15) {
					mult -= .2;
				} else if (pc.butt > 10) {
					mult -= .1;
				}
			}
		}
	}
	if (sexyTime) { // But they do get tired
		if (pc.muscles < -95) {
			mult -= .7;
		} else if (pc.muscles < -60) {
			mult -= .3;
		} else if (pc.muscles < -30) {
			mult -= .1;
		}
		if (pc.weight >= 130 || (pc.weight >= 95 + ((pc.physicalAge - 9) * 5))) {
			mult -= .2;
		}
	}
	return Math.max(.25, mult);
};

/** For event consistency. Returns the expected state of the player on the sidebar if debug mode is on.
 * @param {FC.PlayerState} actor
 * @returns {string}
 */
globalThis.playerConsistencyCheck = function(actor = V.PC) {
	let r;
	if (onBedRest(actor)) {
		if (onBedRest(actor, true)) {
			if (!isMovable(actor)) {
				return "Bedbound";
			} else if (isTrapped(actor)) {
				r = "Roombound";
				if (!canWalk(actor)) {
					r += "+Wheelchair";
				}
				if (isHindered(actor)) {
					r += "+Hindered";
				}
				return r;
			} else {
				r = "Bedrest";
				if (!canWalk(actor)) {
					r += "+Wheelchair";
				}
				if (isHindered(actor)) {
					r += "+Hindered";
				}
				return r;
			}
		} else {
			return "Incapacitated";
		}
	} else if (!canWalk(actor)) {
		r = "Wheelchair";
		if (isHindered(actor)) {
			r += "+Hindered";
		}
		return r;
	} else if (isHindered(actor)) {
		return "Hindered";
	} else {
		return "Unhindered";
	}
};

/** Returns if the player can eat solid food.
 * Consider moving this to encompass slaves in the future.
 * @param {FC.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;
};

/** Returns if the player is thinking with their genitals.
 * Mainly for use in events and personal attention.
 * Intended to allow ravishing a slave to give you mental clarity at the cost of making the problem worse in the long run.
 * @param {FC.PlayerState} actor
 * @returns {boolean}
 */
globalThis.isPlayerLusting = function(actor = V.PC) {
	if (isHorny(actor) || actor.lusty) {
		if (getPersonalAttention(null, "ravish") || getPersonalAttention(null, "ravished")) {
			return false;
		} else {
			return true;
		}
	}
	return false;
};

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

/** Identifies which "tier" a particular career is in
 * @param {string} [career]
 * @returns {"master" | "apprentice" | "child"}
 */
globalThis.PCCareerTier = function(career = V.PC.career) {
	/** @type {("master" | "apprentice" | "child")[]} */
	const tiers = ["master", "apprentice", "child"];
	const careerGroups = Array.from(App.Data.player.career.values());
	for (const grp of careerGroups) {
		for (const tier of tiers) {
			if (grp[tier] === career) {
				return tier;
			}
		}
	}
	console.log(`"${career}" not found in App.Data.player.career`);
	return "master";
};

/** Identifies which category the PC's career is in
 * @param {string} [career]
 * @returns {string}
 */
globalThis.PCCareerCategory = function(career = V.PC.career) {
	for (const [category, group] of App.Data.player.career) {
		if (Object.values(group).includes(career)) {
			return category;
		}
	}
	console.log(`"${career}" not found in App.Data.player.career`);
	return career;
};

/** Returns warning text for the player being penetrated and also warns about losing virginities.
 * @param {string} [holes = "vaginal first"] (vaginal | anal | both | either | vaginal first)
 * @param {boolean} [escape = false] (true | false) is there a way to escape the penetration or it is unavoidable
 * @returns {string}
 */
globalThis.PCPenetrationWarning = function(holes = "vaginal first", escape = false) {
	let targets = [];
	let virgins = [];
	let result = "";
	let action = escape ? "can" : "will";
	if (holes === "vaginal" || holes === "both" || holes === "either" || holes === "vaginal first") {
		if (V.PC.vagina >= 0) {
			targets.push("vagina");
			if (V.PC.vagina === 0) {
				virgins.push("vaginal");
			}
		}
	}
	if (holes === "anal" || holes === "both" || holes === "either" || (holes === "vaginal first" && targets.length === 0)) {
		if (V.PC.anus >= 0) {
			targets.push("anus");
			if (V.PC.anus === 0) {
				virgins.push("anal");
			}
		}
	}
	if (targets.length === 0) {
		return result;
	} else if (targets.length === 2) {
		if (holes === "either") {
			result = `This option ${action} penetrate your vagina or anus.`;
		} else {
			result = `This option ${action} penetrate your vagina and anus.`;
		}
	} else {
		result = `This option ${action} penetrate your ${targets[0]}.`;
	}
	if (virgins.length === 0) {
		return result;
	} else if (virgins.length === 2) {
		if (holes === "either") {
			result += `WARNING# This option ${action} take your vaginal or anal virginities.`;
		} else {
			result += `WARNING# This option ${action} take your vaginal and anal virginities.`;
		}
	} else {
		result += `WARNING# This option ${action} take your ${virgins[0]} virginity.`;
	}
	return result;
};

/** Returns "true" if the player can be receptive to penetration, even if the sexualOpenness policy is not adopted. This function should only be used to offer options to the player, it does not imply true willingness.
 * @param {any} [penetrator = null]  If a slave is passed as an argument, it also takes into account if slave.toyHole is "dick".
 * @returns {boolean}
 */
globalThis.isPlayerReceptive = function(penetrator = null) {
	if (penetrator && penetrator.toyHole && penetrator.toyHole === "dick") {
		return true; // if argument is passed, it is a slave and toyHole is dick
	} else if (V.policies.sexualOpenness === 1) {
		return true; // penetrative campaign
	} else if (V.PC.drugs.includes("fertility") || V.PC.diet.includes("fertility") || V.PC.refreshment.includes("fertility")) {
		return true; // PC wants to be pregnant, even if not fertile
	} else if (
		getSlaves().filter(s => s.toyHole === "dick").size * 4 > getSlaves().filter(s => s.dick > 0).size ||
		getSlaves().filter(s => s.toyHole === "dick").size * 10 > slaveCount() ||
		getSlaves().filter(s => s.toyHole === "dick").size >= 8
	) {
		return true; // at least 8 slaves, or 25% of the slaves with penises, or 10% of all the slaves, have dick as toyHole
	} else if (V.PC.counter.anal * 2 > V.week) {
		return true; // player is used to anal penetration
	/** } else if (V.PC.counter.vaginal * 2 V.week) {
		return true; // vaginal isn't only increased by penetration, but it's also increased by tribbing and cunnilingus */
	} else if (isPlayerLusting() && (V.PC.anus > 0 || V.PC.vagina > 0)) {
		return true; // player needs sex and has some penetrative experience
	} else if (V.PC.preg > V.PC.pregData.normalBirth / 2 && V.PC.pregMood === 2) {
		return true; // player's pregnancy increases libido
	} else if (isFertile(V.PC) && V.PC.forcedFertDrugs > 0) {
		return true; // forcedFertDrugs influence the behavior of the player, who unconsciously wants to be fertilized
	}
	return false;
};

/** Determine if the player favors masturbation over sex.
 * Currently not used much, but will be used more with the addition of new .pregMoods
 * @returns {boolean}
 */
globalThis.isPlayerFrigid = function() {
	if (V.PC.diet === PCDiet.WEANING) {
		return true;
	} else if (V.PC.preg < -3) { // < -2? Does it start at 4 or 3 at first week start?
		return true;
	} else if (V.PC.preg > V.PC.pregData.normalBirth / 2 && V.PC.pregMood === 2) {
		return false;
	} else if (V.PC.energy === 0) {
		return true;
	}
	return false;
};

/** Returns the value of bad rumors circulating about the player, of a specific type or the overall weighted value.
 * * 0: no rumors
 * * 1 - 10: occasional whispers
 * * 11	- 25: minor rumors
 * * 26	- 50: rumors
 * * 51	- 75: bad rumors
 * * 70	- 100: severe rumors
 * * 101+: life ruining rumors
 * @param {"penetrative" | "birth" | "weakness" | "all"} rumorType
 * @returns {number}
 */
globalThis.getRumors = function(rumorType = "all") {
	let result = {
		penetrative: V.PC.badRumors.penetrative,
		birth: V.PC.badRumors.birth,
		weakness: V.PC.badRumors.weakness,
	};
	if (V.PC.counter.raped > 0) { // rapes are everlasting weakness rumors
		result.weakness += Math.min(V.PC.counter.raped, 10);
		if (V.PC.counter.raped > 10) {
			result.weakness += Math.sqrt(V.PC.counter.raped - 10);
		}
		result.weakness = Math.floor(result.weakness);
	}
	if (V.policies.sexualOpenness === 1 || V.arcologies[0].FSChattelReligionistLaw === 1 || V.arcologies[0].FSRestartDecoration > 99) {
		result.penetrative = 0;
	}
	if ((rumorType === "weakness" || rumorType === "all") && result.weakness > 0) {
		result.weakness = 1 + Math.round((result.weakness - 1) * 0.5); // weakness is not as devastating as other bad rumors.
		if (result.penetrative + result.birth > 20) {
			result.weakness = Math.round(result.weakness * 0.66); // other rumors downplay the impact of weakness ones.
		}
	}
	return rumorType === "all" ? result.penetrative + result.birth + result.weakness : result[rumorType];
};

/** Records possible harmful rumors about the player. */
globalThis.newRumor = (function() {
	return {
		penetrative: penetrated,
		birth: miscegenated,
		weakness: weak,
		// other: other,
	};

	/** Records rumors about the player being penetrated by a slave, if applicable, and returns a warning text in that case.
	 * @param {FC.SlaveState} slave - penetrator
	 * @param {number} strength - multiplier, defaults to 1
	 * @returns {string}
	 */
	function penetrated(slave = undefined, strength = 1) {
		if (V.policies.sexualOpenness === 1 || V.arcologies[0].FSChattelReligionistLaw === 1 || V.arcologies[0].FSRestartDecoration > 99 || (V.arcologies[0].FSRepopulationFocus >= 60 && (canGetPregnant(V.PC) || V.PC.preg.isBetween(0, 11)))) {
			return "";
		}
		let acceptance = 0;
		if (!slave || !asSlave(slave)) {
			acceptance = penetrativeSocialUse();
		} else {
			acceptance = penetrativeSocialUse(slave);
		}
		if (acceptance > 70) {
			return "";
		} else if (acceptance > 40) {
			let luck = 70 - jsRandom(40, acceptance); // from 1 to 30
			strength = Math.round(strength / 30 * luck); // the higher the acceptance, the highest chance of stength being 0
			if (strength === 0) {
				return "";
			}
		}
		V.PC.badRumors.penetrative = Math.round(V.PC.badRumors.penetrative + strength);
		return `Rumors spread that you <span class="warning">enjoy taking it from slaves.</span>`;
	}

	/** Records the displeasure of the society over the fact that the player gave birth to babies of slaves or miscegenated. Call only once per birth. It returns nothing.
	 * @param {number} strength - multiplier, defaults to 1
	 */
	function miscegenated(strength = 10) {
		if (V.arcologies[0].FSChattelReligionistLaw === 1) { // society accepts the acts of the prophet
			return;
		}
		let weight = FutureSocieties.isActive("FSRepopulationFocus") && V.arcologies[0].FSRepopulationFocus > 99 ? .1 : 2;
		let impregnator = findFather(V.PC.pregSource);
		if (FutureSocieties.isActive('FSSupremacist') && impregnator.race !== V.arcologies[0].FSSupremacistRace) {
			weight += 1;
		}
		if (FutureSocieties.isActive('FSSubjugationist') && impregnator.race === V.arcologies[0].FSSubjugationistRace) {
			weight += 5;
		}
		V.PC.badRumors.birth = Math.round(V.PC.badRumors.birth + weight * strength);
	}

	/** Records rumors about the player having a weak character to impose himself on his slaves.
	 * @param {number} strength - multiplier, defaults to 1
	 * @returns {string}
	 */
	function weak(strength = 1) {
		let luck = Math.min(V.rep > 7000 ? 1 : (jsRandom(1, V.rep) / 4000), 1) - (FutureSocieties.isActive("FSPaternalist") && jsRandom(0, V.arcologies[0].FSPaternalist) > 50 ? 1 : 0) + (FutureSocieties.isActive("FSDegradationist") && jsRandom(0, V.arcologies[0].FSDegradationist) > 50 ? 1 : 0);
		luck = Math.max(luck, 0);
		strength = Math.round(luck * strength);
		V.PC.badRumors.weakness += strength;
		return strength > 0 ? `Rumors spread that <span class="warning">you are not able to impose yourself on your slaves.</span>` : "";
	}
})();

/** Softens harmful rumors about the player. */
globalThis.softenRumors = (function() {
	return {
		penetrative: penetrated,
		birth: miscegenated,
		weakness: weak,
		// other: other,
		all: all,
	};

	/** Softens rumors about the player being penetrated by slaves.
	 * @param {number} strength - multiplier, defaults to 1
	 */
	function penetrated(strength = 1) {
		if (V.policies.sexualOpenness === 1 || V.arcologies[0].FSChattelReligionistLaw === 1 || V.arcologies[0].FSRestartDecoration > 99) {
			V.PC.badRumors.penetrative = 0;
			return;
		}
		if (V.PC.badRumors.penetrative <= 1) {
			return;
		}
		if (V.arcologies[0].FSRepopulationFocus > 60 && (canGetPregnant(V.PC) || V.PC.preg.isBetween(0, 11))) {
			if (V.PC.badRumors.penetrative > 5 * strength) {
				V.PC.badRumors.penetrative -= 5 * strength;
			} else {
				V.PC.badRumors.penetrative = 1;
			}
		}
		if (V.PC.badRumors.penetrative <= strength) {
			strength = V.PC.badRumors.penetrative - 1;
		}
		if (V.PC.badRumors.penetrative > Math.max(10, strength)) {
			V.PC.badRumors.penetrative -= strength;
			if (V.PC.badRumors.penetrative > Math.max(20, strength) && penetrativeSocialUse() > 75) {
				V.PC.badRumors.penetrative -= [0, 0, 1].random();
			}
		} else if (V.PC.badRumors.penetrative > 1 && (jsRandom(0, 10 + penetrativeSocialUse()) > 20)) { //intended: doesn't go below 10 if there isn't a minimum social acceptance of penetration.
			V.PC.badRumors.penetrative -= Math.min(strength, V.PC.badRumors.penetrative - 1);
		}
	}

	/** Softens rumors about unpopular births by the player.
	 * @param {number} strength - multiplier, defaults to 1
	 */
	function miscegenated(strength = 1) {
		if (V.arcologies[0].FSChattelReligionistLaw === 1) {
			if (V.PC.badRumors.birth > 10) { // implies baby born before the player became the prophet
				V.PC.badRumors.birth = Math.round(V.PC.badRumors.birth / 2);
			} else {
				V.PC.badRumors.birth =  0;
			}
			return;
		}
		if (V.PC.badRumors.birth > 1) {
			let luck = 3 + (FutureSocieties.isActive("FSRestart") || FutureSocieties.isActive("FSSubjugationist") || FutureSocieties.isActive("FSSupremacist") ? 1 : 0);
			V.PC.badRumors.birth -= jsRandom(0, luck) < 3 ? Math.min(strength, V.PC.badRumors.birth - 1) : 0;
		}
	}

	/** Softens rumors about the player's weakness of character.
	 * @param {number} strength - multiplier, defaults to 1
	 */
	function weak(strength = 1) {
		let luck = 1 + Math.floor(V.rep / 7000); // the higher the reputation, less chances of reducing rumors since society expects more authority of the player
		if (V.PC.badRumors.weakness > 1 && jsRandom(0, luck) <= 1) {
			V.PC.badRumors.weakness - Math.min(strength, V.PC.badRumors.weakness - 1);
		}
	}

	/** Softens all rumors about the player.
	 * @param {number} strength - multiplier, defaults to 1
	 */
	function all(strength = 1) {
		penetrated(strength);
		miscegenated(strength);
		weak(strength);
	}
})();

/**
 * Check which of their own holes the player prefers
 * @returns {"vagina" | "anus"}
 */
globalThis.getPCPreferredHole = function() {
	if (V.PC.vagina > -1 && V.PC.preferredHole !== 0) {
		if (V.PC.preferredHole === -1) {
			const choices = [];
			// low chance of virgin hole (1 in 11) being chosen if a non virgin hole exists
			// 50/50 if both holes are virgin or both holes have had their cherries popped
			if (V.PC.vagina === 0) {
				choices.push("vagina");
			} else {
				choices.push(
					"vagina", "vagina", "vagina", "vagina", "vagina",
					"vagina", "vagina", "vagina", "vagina", "vagina",
				);
			}
			if (V.PC.anus === 0) {
				choices.push("anus");
			} else {
				choices.push(
					"anus", "anus", "anus", "anus", "anus",
					"anus", "anus", "anus", "anus", "anus",
				);
			}
			return /** @type {"vagina" | "anus"} */ (choices.random());
		}
		return "vagina";
	} else {
		return "anus";
	}
};


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

/**
 * @deprecated use App.UI.DOM.link() instead
 * TODO: @franklygeorge this is only used in FCTVshows.js. Replace the usage there and then remove this function
 * @see App.UI.DOM.link
 */
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
	 * @deprecated use App.UI.DOM.link() instead
	 * @see App.UI.DOM.link
	 * @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);
};

/**
 * @typedef {object} App.UI.DOM.slaveDescriptionDialogOptions
 * @property {boolean} [noButtons] if true then we won't add the favorite toggle or the reminder button
 * @property {string[]} [linkClasses] a list of classes to add to the link element
 */

/**
 * Generates a link which shows a slave description dialog for a specified slave.
 * Do not call from within another dialog.
 * @param {FC.SlaveState} slave
 * @param {string} [text] link text to use instead of slave name
 * @param {FC.Desc.LongSlaveOptions} [longSlaveOptions]
 * @param {App.UI.DOM.slaveDescriptionDialogOptions} [options]
 * @returns {HTMLElement} link
 */
App.UI.DOM.slaveDescriptionDialog = function(
	slave, text,
	longSlaveOptions = {descType: DescType.EVENT, noArt: true},
	options = {
		noButtons: false,
		linkClasses: [],
	}
) {
	const span = App.UI.DOM.makeElement("span");
	if ((!text || text === SlaveFullName(slave)) && !options.noButtons && V.addButtonsToSlaveLinks) {
		// text = [favorite button] [reminder button] [slave name]
		span.append(
			App.UI.favoriteToggle(slave),
			App.Reminders.slaveLink(slave.ID),
		);
	}
	const link = App.UI.DOM.link(text ? text: SlaveFullName(slave), () => {
		Dialog.create(SlaveFullName(slave));
		App.UI.DOM.drawOneSlaveRight(Dialog.body(), slave);
		Dialog.append(App.Desc.longSlave(slave, longSlaveOptions));
		Dialog.open();
	});
	if (options.linkClasses && Array.isArray(options.linkClasses)) {
		link.classList.add(...options.linkClasses);
	}
	span.append(link);
	return span;
};

/**
 * @param {FC.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,
		appendTo: document.getElementById("story")
	});
	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));
};

/**
 * The passage switch handler is executed upon leaving BEFORE cloning the state. This means any change will be kept even
 * if the game is saved and reloaded or the page refreshed.
 *
 * @type {Readonly<{
 *          set: function(function():void):void,
 *          get: (function(): function(): void),
 *          clear: function():void,
 *          execute: function():void}>}
 */
App.Utils.PassageSwitchHandler = (function() {
	/** @type {function():void} */
	let handler = null;

	/**
	 * @param {function():void} newHandler
	 */
	function setHandler(newHandler) {
		handler = newHandler;
	}

	/**
	 * @returns {function(): void}
	 */
	function getHandler() {
		return handler;
	}

	function clearHandler() {
		handler = null;
	}

	function executeHandler() {
		if (handler) {
			handler();
		}
		clearHandler();
	}

	return Object.freeze({
		set: setHandler,
		get: getHandler,
		clear: clearHandler,
		execute: executeHandler,
	});
}());