code:
App.UI.OptionsGroup = (function() {
	class Row {
		/**
		 * @param {HTMLDivElement} container
		 * @param {function():void} refresh
		 */
		render(container, refresh) {} // jshint ignore:line
	}

	/**
	 * @typedef value
	 * @property {*} value
	 * @property {string} [name]
	 * @property {string} mode
	 * @property {number} [compareValue]
	 * @property {string} [descAppend] can be SC markup
	 * @property {boolean} [on]
	 * @property {boolean} [off]
	 * @property {boolean} [neutral]
	 * @property {Function} [callback]
	 */

	class OptionButtonRow extends Row {
		/**
		 * @param {string|HTMLElement} description string can be HTML markup
		 * @param {string} property
		 * @param {object} [object=V]
		 */
		constructor(description, property, object = V) {
			super();
			this.description = description;
			this.property = property;
			this.object = object;
			/**
			 * @type {Array<value>}
			 */
			this.valuePairs = [];
			/**
			 * @type {function(any):void}
			 * @private
			 */
			this._globalCallback = undefined;
		}

		/**
		 * @template {any} T
		 * @param {string} name
		 * @param {T|string} [value=name]
		 * @param {function(T|string):void} [callback]
		 * @returns {OptionButtonRow}
		 */
		addValue(name, value = name, callback = undefined) {
			this.valuePairs.push({
				name: name, value: value, mode: "=", callback: callback
			});
			return this;
		}

		/**
		 * @param {Array<*|Array>} values
		 * @returns {OptionButtonRow}
		 */
		addValueList(values) {
			for (const value of values) {
				if (Array.isArray(value)) {
					this.addValue(value[0], value[1], value[2]);
				} else {
					this.addValue(value);
				}
			}
			return this;
		}

		/**
		 * @param {Map} values
		 * @returns {OptionButtonRow}
		 */
		addValueMap(values) {
			for (const [key, value] of values) {
				this.addValue(key, value);
			}
			return this;
		}

		/**
		 * @param {*} value
		 * @param {number} compareValue
		 * @param {string} mode on of: "<", "<=", ">", ">="
		 * @param {string} [name=value]
		 */
		addRange(value, compareValue, mode, name = value) {
			this.valuePairs.push({
				name: name, value: value, mode: mode, compareValue: compareValue
			});
			return this;
		}

		/**
		 * @param {object} [params]
		 * @param {string} [params.unit]
		 * @param {boolean} [params.large=false]
		 * @param {boolean} [params.forceString=false]
		 * @returns {OptionButtonRow}
		 */
		showTextBox({unit, large = false, forceString = false} = {}) {
			this.textbox = {unit: unit, large: large, forceString: forceString};
			return this;
		}

		/**
		 * @param {string|DocumentFragment|HTMLElement} comment string can be HTML
		 * @returns {OptionButtonRow}
		 */
		addComment(comment) {
			this.comment = comment;
			return this;
		}

		/**
		 * Adds a button that executes the callback when clicked AND reloads the passage
		 *
		 * @param {string} name
		 * @param {function():void} callback
		 * @param {string} passage
		 */
		customButton(name, callback, passage) {
			this.valuePairs.push({
				name: name, value: passage, callback: callback, mode: "custom"
			});
			return this;
		}

		/**
		 * @param {Node} node
		 * @returns {OptionButtonRow}
		 */
		addCustomDOM(node) {
			this.valuePairs.push({
				value: node, mode: "DOM"
			});
			return this;
		}

		/* modify last added option */

		/**
		 * Added to the description if last added value is selected.
		 * example use: addValue(...).customDescription(...).addValue(...).customDescription(...)
		 * @param {string} description can be SC markup
		 */
		customDescription(description) {
			this.valuePairs.last().descAppend = description;
			return this;
		}

		/**
		 * @param {function(any):void} callback gets executed on every button click. Selected value is given as argument.
		 */
		addCallback(callback) {
			this.valuePairs.last().callback = callback;
			return this;
		}

		/**
		 * Only executed if no specific callback for this option exists.
		 * @param {function(any):void} callback gets executed on every button click. Selected value is given as argument.
		 */
		addGlobalCallback(callback) {
			this._globalCallback = callback;
			return this;
		}

		/**
		 * Mark option as on to style differently.
		 * @returns {OptionButtonRow}
		 */
		on() {
			this.valuePairs.last().on = true;
			return this;
		}

		/**
		 * Mark option as off to style differently.
		 * @returns {OptionButtonRow}
		 */
		off() {
			this.valuePairs.last().off = true;
			return this;
		}

		/**
		 * Puts the options in side a pulldown if there are at least 6.
		 * Not counting text boxes or comments.
		 * @returns {OptionButtonRow}
		 */
		pulldown() {
			this.enablePulldown = true;
			return this;
		}

		/**
		 * @param {HTMLDivElement} container
		 * @param {function():void} refresh
		 */
		render(container, refresh) {
			/* left side */
			const desc = document.createElement("div");
			desc.className = "description";
			$(desc).append(this.description);
			container.append(desc);

			/* right side */
			const currentValue = this.object[this.property];
			if (currentValue === undefined) {
				throw new TypeError(`Current value is undefined for option row "${this.description}"`);
			}
			let anySelected = false;

			const buttonGroup = document.createElement("div");
			buttonGroup.classList.add("button-group");
			if (!this.enablePulldown || this.valuePairs.length < 6) {
				for (const value of this.valuePairs) {
					if (value.mode === "DOM") {
						/* insert DOM and go to next element */
						buttonGroup.append(value.value);
						continue;
					}
					const button = document.createElement("button");
					button.append(value.name);
					if (value.on) {
						button.classList.add("on");
					} else if (value.off) {
						button.classList.add("off");
					}
					if (value.mode === "custom") {
						button.onclick = () => {
							value.callback();
							if (value.value) {
								Engine.play(value.value);
							} else {
								refresh();
							}
						};
					} else {
						if (value.mode === "=" && _.isEqual(currentValue, value.value)) {
							button.classList.add("selected", "disabled");
							anySelected = true;
							if (value.descAppend !== undefined) {
								desc.append(" ");
								$(desc).wiki(value.descAppend);
							}
						} else if (!anySelected && inRange(value.mode, value.compareValue, currentValue)) {
							button.classList.add("selected");
							// disable the button if clicking it won't change the variable value
							if (_.isEqual(currentValue, value.value)) {
								button.classList.add("disabled");
							}
							anySelected = true;
							if (value.descAppend !== undefined) {
								desc.append(" ");
								$(desc).wiki(value.descAppend);
							}
						}
						button.onclick = () => {
							this.object[this.property] = value.value;
							if (value.callback) {
								value.callback(value.value);
							} else if (this._globalCallback) {
								this._globalCallback(value.value);
							}
							refresh();
						};
					}
					buttonGroup.append(button);
				}
			} else {
				const options = this.valuePairs.map(value => {
					return {key: value.value, name: value.name};
				});
				buttonGroup.append(App.UI.DOM.makeSelect(options, this.object[this.property], value => {
					if (!isNaN(Number(value))) {
						// @ts-ignore
						value = Number(value);
					}
					this.object[this.property] = value;
					const originalObj = this.valuePairs.find(obj => obj.value === value);
					if (originalObj && typeof originalObj.callback === "function") {
						originalObj.callback(originalObj.value);
					} else if (this._globalCallback) {
						this._globalCallback(originalObj.value);
					}
					refresh();
				}));
			}

			if (this.textbox) {
				const onlyNumber = !this.textbox.forceString && typeof currentValue === "number";
				const textbox = App.UI.DOM.makeTextBox(currentValue, input => {
					this.object[this.property] = input;
					if (this._globalCallback) {
						this._globalCallback(input);
					}
					refresh();
				}, onlyNumber);
				if (onlyNumber) {
					textbox.classList.add("number");
				}
				if (this.textbox.large) {
					textbox.classList.add("full-width");
				}
				buttonGroup.append(textbox);
				if (this.textbox.unit) {
					buttonGroup.append(" ", this.textbox.unit);
				}
			}

			if (this.comment) {
				const comment = document.createElement("span");
				comment.classList.add("comment");
				$(comment).append(this.comment);
				buttonGroup.append(comment);
			}
			container.append(buttonGroup);

			function inRange(mode, compareValue, value) {
				if (mode === "<") {
					return value < compareValue;
				} else if (mode === "<=") {
					return value <= compareValue;
				} else if (mode === ">") {
					return value > compareValue;
				} else if (mode === ">=") {
					return value >= compareValue;
				}
				return false;
			}
		}
	}

	class CustomButtonRow extends Row {
		/**
		 * @typedef CustomButton
		 * @property {string} name
		 * @property {function():void} callback
		 * @property {string} [passage] if undefined reloads current passage
		 */

		/**
		 * @param {string} description may be HTML markup
		 */
		constructor(description) {
			super();
			/**
			 * @private
			 */
			this._description = description;
			/**
			 * @type {string}
			 * @private
			 */
			this._comment = undefined;
			/**
			 * @type {Array<CustomButton>}
			 * @private
			 */
			this._buttons = [];
		}

		/**
		 * @param {string} name
		 * @param {function():void} callback
		 * @param {string} [passage] if undefined reloads current passage
		 * @returns {CustomButtonRow}
		 */
		addButton(name, callback, passage) {
			this._buttons.push({
				name: name,
				callback: callback,
				passage: passage
			});
			return this;
		}

		/**
		 * @param {string} comment may be HTML markup
		 * @returns {CustomButtonRow}
		 */
		addComment(comment) {
			this._comment = comment;
			return this;
		}

		/**
		 * @param {HTMLDivElement} container
		 * @param {function():void} refresh
		 */
		render(container, refresh) {
			/* left side */
			const desc = document.createElement("div");
			desc.className = "description";
			$(desc).append(this._description);
			container.append(desc);

			/* right side */
			const buttonGroup = document.createElement("div");
			buttonGroup.classList.add("button-group");

			for (const button of this._buttons) {
				const buttonElement = document.createElement("button");
				buttonElement.append(button.name);
				buttonElement.onclick = () => {
					button.callback();
					if (button.passage) {
						Engine.play(button.passage);
					} else {
						refresh();
					}
				};
				buttonGroup.append(buttonElement);
			}

			if (this._comment) {
				const comment = document.createElement("span");
				comment.classList.add("comment");
				$(comment).append(this._comment);
				buttonGroup.append(comment);
			}

			container.append(buttonGroup);
		}
	}

	class Comment extends Row {
		/**
		 * @param {string} comment can be SC markup
		 */
		constructor(comment) {
			super();
			this.comment = comment;
			this.long = false;
		}

		/**
		 * @param {HTMLDivElement} container
		 * @param {function():void} refresh
		 */
		render(container, refresh) {
			/* left */
			container.append(document.createElement("div"));

			/* right */
			const comment = document.createElement("div");
			comment.classList.add("comment");
			$(comment).wiki(this.comment);
			container.append(comment);
		}
	}

	class CustomRow extends Row {
		/**
		 * @param {HTMLElement|string|DocumentFragment} element
		 */
		constructor(element) {
			super();
			this.element = element;
		}

		/**
		 * @param {HTMLDivElement} container
		 */
		render(container) {
			/** @type {HTMLDivElement} */
			const div = App.UI.DOM.makeElement("div", this.element, "custom-row");
			container.append(div);
		}
	}

	return class OptionsGroup {
		constructor() {
			/**
			 * @type {Array<Row>}
			 */
			this.rows = [];
			this.doubleColumn = false;
			this.refresh = App.UI.reload;
		}

		/**
		 * @returns {OptionsGroup}
		 */
		enableDoubleColumn() {
			this.doubleColumn = true;
			return this;
		}

		/**
		 * Adds a custom function to be executed when changing an option. By default App.UI.reload() is executed.
		 * Can be overwritten by some options, the behaviour is the same as with the default function.
		 *
		 * @param {function():void} refresh
		 * @returns {OptionsGroup}
		 */
		customRefresh(refresh) {
			this.refresh = refresh;
			return this;
		}

		/**
		 * @template {Row} T
		 * @param {T} row
		 * @returns {T}
		 * @private
		 */
		_addRow(row) {
			this.rows.push(row);
			return row;
		}

		/**
		 * @param {string|HTMLElement} name
		 * @param {string} property
		 * @param {object} [object=V]
		 * @returns {OptionButtonRow}
		 */
		addOption(name, property, object = V) {
			const option = new OptionButtonRow(name, property, object);
			return this._addRow(option);
		}

		/**
		 * @param {string} name
		 * @returns {CustomButtonRow}
		 */
		addCustomOption(name) {
			return this._addRow(new CustomButtonRow(name));
		}

		/**
		 * @param {string} comment may contain SC markup
		 * @returns {Comment}
		 */
		addComment(comment) {
			return this._addRow(new Comment(comment));
		}

		/**
		 * Adds a custom element taking up both rows
		 *
		 * @param {HTMLElement|string|DocumentFragment} element
		 * @returns {CustomRow}
		 */
		addCustom(element) {
			return this._addRow(new CustomRow(element));
		}

		/**
		 * @returns {HTMLDivElement}
		 */
		render() {
			const container = document.createElement("div");
			container.className = "options-group";
			if (this.doubleColumn) {
				container.classList.add("double");
			}

			for (const row of this.rows) {
				row.render(container, this.refresh);
			}

			return container;
		}
	};
})();

/** A wrapper for option handlers that shows a dialog with the results of setting the option.
 * @template T
 * @param {function(T): string|HTMLElement|DocumentFragment} contentGenerator
 * @param {string} [caption]
 * @returns {function(T): void}
 */
App.UI.DialogHandler = function(contentGenerator, caption) {
	return (arg) => {
		const dialogContent = contentGenerator(arg);
		if (dialogContent) {
			if (Dialog.isOpen()) {
				Dialog.close();
			}
			if (caption) {
				Dialog.setup(caption);
			}
			$(Dialog.body()).empty().append(dialogContent);
			Dialog.open();
		}
	};
};


code:
// cSpell:ignore Optimise, webui, medvram

const html = `

What is Stable Diffusion and Automatic1111's Stable Diffusion WebUI?

Stable Diffusion is an AI model for generating images given a text prompt. Automatic1111's Stable Diffusion WebUI is a web interface for running Stable Diffusion. It is the easiest way to run Stable Diffusion on your computer, and provides an API that we can use to integrate Stable Diffusion into other applications.

Steps

Install Automatic1111's Stable Diffusion WebUI

Download the relevant models

Place the models in their corresponding directories

Configure Automatic1111's Stable Diffusion WebUI

Running the WebUI

Confirm successful setup

2. Install Automatic1111's Stable Diffusion WebUI

Before we start, you need to install the Stable Diffusion WebUI. To do this, follow the detailed instructions for installation on Windows, Linux, and Mac on the Stable Diffusion WebUI GitHub page.

3. Download the relevant models

You will now need to download the following models:

MajicMix v6

LowRA v2

Note that MajicMix is a photorealistic model heavily biased towards Asian girls; if you have a more diverse arcology, you may prefer a different base model like Life Like Diffusion, or you might want to try a different style entirely, like Hassaku for an anime style. Your results may vary with other models, since generated prompts are tuned primarily for these models, but don't be afraid to experiment.

4. Place the models in their corresponding directories

Next, you need to place the models in the appropriate directories in the Stable Diffusion WebUI:

Place MajicMix v6 in stable-diffusion-webui/models/Stable-diffusion

Place LowRA v2 in stable-diffusion-webui/models/Lora

5. Configure Automatic1111's Stable Diffusion WebUI

To prepare the WebUI for running, you need to modify the COMMANDLINE_ARGS line in either webui-user.sh (if you're using Linux/Mac) or webui-user.bat (if you're using Windows) to include the following:

Linux/Mac:

export COMMANDLINE_ARGS="--medvram --no-half-vae --listen --port=7860 --api --cors-allow-origins *"

Windows:

set COMMANDLINE_ARGS=--medvram --no-half-vae --listen --port=7860 --api --cors-allow-origins *

You may need to use --cors-allow-origins null instead of --cors-allow-origins * if you are using a Chromium-based host (Chrome, Edge, FCHost, or similar), and are running Free Cities from a local HTML file rather than a webserver.

6. Running the WebUI

Now you can run the WebUI by executing either webui.sh (Linux/Mac) or webui-user.bat (Windows). Note that the WebUI server needs to be running the whole time you're using it.

Once it's running, open your browser and go to localhost:7860. The WebUI should open. In the top left is a dropdown for model selection, pick MajicMix (or a different model of your choice) in that dropdown.

7. Check it works

At this point, if you go to a slave's detail page their image should load after a short (<30 seconds) delay. If it doesn't seem to be working, have a look at the terminal window running Automatic1111's Stable Diffusion WebUI to see if there are any errors.

The request will time out if the image can't be generated fast enough; if this is the case for you, try to find a guide to optimizing Stable Diffusion for your particular hardware setup, or disable the "Upscaling/highres fix" option.

(Optional) 8. Optimise Your Install

ADetailer

ADetailer is a performant way to refine your images (taking less than 10ms on some systems)

Follow the instructions on the GitHub page to install. Remember to restart Stable Diffusion!

ControlNet and OpenPose

ControlNet allows for precise control of image layout. Free Cities can use the OpenPose module to enforce poses on your slave images, which is much more reliable than prompting for the pose.

Follow the instructions on the GitHub page to install. You only need to set up an OpenPose model (you can ignore all the other models). Remember to restart Stable Diffusion!

Custom OpenPose poses (png or json) can be downloaded and placed in resources/OpenPose in your Free Cities install location, and specified from the Customize tab of Slave Interact.

Webp Images

WEBP images (~30 kB) take up much less space compared to JPEG (60-200 kB) or PNG (1-2.5 MB). This is one way you can make images quicker to display, and have less saved on your hard disk as well.

In the Automatic1111 WebUI, in the top toolbar and click "Settings"

Then, click on the top option of the sidebar (Saving images/grids). You might be there by default.

In the first text field, "File format for images", enter "webp" without quotes.

Click the "Apply settings" button at the top, then the "Reload UI" button.

`;

const loraHTML = `

Why is a LoRA pack helpful?

Slaves in the Free Cities can sometimes have unusual features that Stable Diffusion base models are not trained to handle. LoRAs are small models specifically designed to handle these situations. NGBot has assembled a pack of LoRAs specifically for Free Cities and, if you install the LoRAs and enable prompting for the pack, you can see these features in your AI renders.

Do I really need all these LoRAs?

Each of the LoRAs serves a specific purpose; you may install any or all of them at your preference. Without the LoRA for a particular feature, slaves with that feature might not render well. The prompting toggle will generate prompting for all of them, but Stable Diffusion will just skip LoRAs you don't have, so it's safe to turn on if you want to use ANY of the LoRAs in the pack.

Note that many of these LoRAs tend to work better on less realistic base models. If you have many slaves with exotic features, it may be worth switching to an anime-style or pseudorealistic model, rather than a realistic one.

How do I get NGBot's LoRA pack?

You'll need to download any or all of the relevant LoRAs:

Amputation Lora

Android Arms and Legs Lora

Very large assets Lora

Blind Eyes Lora

Mindbroken Lora

Massive Futa Asset

Average Futa Asset

Micro Futa Asset

Null gender slaves

Fuckdoll Hood

Fuckdoll Posture

Improved Average Male Assets

Catperson Lora

High Profile Implants

Copy any that you've chosen to use into your stable-diffusion-webui/models/Lora folder (see the Stable Diffusion Installation instructions for details).
`;

/**
 * Generates a link which shows a Stable Diffusion installation guide.
 * @param {string} [text] link text to use
 * @returns {HTMLElement} link
 */
App.UI.stableDiffusionInstallationGuide = function(text) {
	return App.UI.DOM.link(text ? text : "Stable Diffusion Installation Guide", () => {
		Dialog.setup("Stable Diffusion Installation Guide");
		const content = document.createElement("div").innerHTML = html;
		Dialog.append(content);
		Dialog.open();
	});
};

/**
 * Generates a link which the LoRA pack installation guide.
 * @param {string} [text] link text to use
 * @returns {HTMLElement} link
 */
App.UI.loraInstallationGuide = function(text) {
	return App.UI.DOM.link(text ? text : "LoRA Pack Installation Guide", () => {
		Dialog.setup("Stable Diffusion LoRA Pack Installation Guide");
		const content = document.createElement("div").innerHTML = loraHTML;
		Dialog.append(content);
		Dialog.open();
	});
};

repo_name: Ducati/fc

license: mit


code:
App.UI.summaryOptions = function() {
	const el = new DocumentFragment();
	let options;

	App.UI.DOM.appendNewElement("h1", el, "Summary Options");

	App.UI.DOM.appendNewElement("p", el, `These options will affect the short slave summaries that appear on the main menu and the facility management screens.`, "scene-intro");

	App.UI.DOM.appendNewElement("h2", el, "Main menu features");

	options = new App.UI.OptionsGroup();

	options.addOption("Rules Assistant visibility", "rulesAssistantMain")
		.addValue("Shown", 1).on().addValue("Hidden", 0).off();

	options.addOption("Facilities in the sidebar are", "abbreviateSidebar")
		.addValueList([["Summarized", 2], ["Abbreviated", 1]]);

	options.addOption("Sorting main menu options are", "sortSlavesMain")
		.addValue("Shown", 1).on().addValue("Hidden", 0).off();

	if (V.sortSlavesMain > 0) {
		options.addOption("Sorting direction", "sortSlavesOrder")
			.addValueList([["Ascending", "ascending"], ["Descending", "descending"]]);

		options.addOption("Slaves are sorted by", "sortSlavesBy")
			.addValueList(Array.from(App.Data.SlaveSorting.entries()));
	}

	el.append(options.render());

	App.UI.DOM.appendNewElement("h2", el, "Individual panels");
	App.UI.DOM.appendNewElement("div", el, "Sample summary:");
	el.append(App.UI.SlaveList.render([V.slaves.random().ID], [], App.UI.SlaveList.SlaveInteract.stdInteract));

	options = (new App.UI.OptionsGroup()).enableDoubleColumn();

	options.addOption("Panel style is", "slavePanelStyle")
		.addValueList([
			["None", 0],
			["Line Separator", 1],
			["Card", 2]
		]);

	App.UI.SlaveSummary.addOptions(options);

	options.addOption("Granular slave stat numbers are", "summaryStats")
		.addValue("Shown", 1).on().addValue("Hidden", 0).off();

	options.addOption("Main menu assignment shortcuts are", "displayAssignments")
		.addValue("Shown", 1).on().addValue("Hidden", 0).off();

	if (V.showMissingSlaves) {
		options.addOption("Missing slave parents are", "showMissingSlavesSD")
			.addValue("Shown", true).on().addValue("Hidden", false).off();
	}

	options.addCustomOption("FC Dev's preferred options")
		.addButton(
			"Apply",
			() => {
				V.seeDesk = 0;
				V.seeFCNN = 0;
				V.sortSlavesBy = "devotion";
				V.sortSlavesOrder = "descending";
				V.sortSlavesMain = 0;
				V.rulesAssistantMain = 1;
				Object.assign(
					V.UI.slaveSummary.abbreviation,
					{
						devotion: 1,
						mental: 1,
						rules: 1,
						health: 1,
						diet: 1,
						drugs: 1,
						hormoneBalance: 1,
						race: 1,
						genitalia: 1,
						physicals: 1,
						skills: 1,
						nationality: 1,
						rulesets: 1,
						clothes: 0,
						origins: 0
					}
				);
				V.abbreviateSidebar = 1;
			}
		);

	el.append(options.render());

	return el;
};


code:
/**
 * Generate a list of links the player can safely jump to.
 *
 * Tags are:
 * jump-to-safe: Allow to jump to this passage from anywhere
 * jump-from-safe: Allow to jump from this passage to anywhere
 * hidden: Allows to jump to via history, but does not show up in the quick links menu; use together with jump-to-safe
 * no-history: Do not record history for this passage, act as if was never opened; mutually exclusive with the jump tags
 *
 * @returns {string|HTMLParagraphElement}
 */
App.UI.quickMenu = (function() {
	// setup passage lists
	const jumpFrom = Story.lookup("tags", "jump-from-safe").map(passage => passage.title);
	const jumpTo = Story.lookup("tags", "jump-to-safe").map(passage => passage.title);
	const hidden = Story.lookup("tags", "jump-hidden").map(passage => passage.title);
	const noHistory = Story.lookup("tags", "no-history").map(passage => passage.title);

	// if property name is a passage name, then it's a link, otherwise only text.
	// category titles are never links to passages
	// Only two values are allowed: true or an object following the same rules
	const layout = addOtherCategory({
		Main: true,
		Manage: {
			"Manage Penthouse": true,
			"Manage Arcology": true,
			"Manage Personal Affairs": true,
			"Manage Corporation": true,
			"Personal assistant options": true,
			"Firebase": true,
			"propagandaHub": true,
			"securityHQ": true,
			"secBarracks": true,
			"riotControlCenter": true,
		},
		Social: {
			"Future Society": true,
			"Policies": true,
			"edicts": true,
			"Universal Rules": true,
			"Neighbor Interact": true,
		},
		Facilities: {
			"Head Girl Suite": true,
			"BG Select": true,
			"Brothel": true,
			"Club": true,
			"Arcade": true,
			"Dairy": true,
			"Farmyard": true,
			"Servants' Quarters": true,
			"Master Suite": true,
			"Schoolroom": true,
			"Spa": true,
			"Nursery": true,
			"Clinic": true,
			"Cellblock": true,
			"Incubator": true,
			"Pit": true,
		},
		Locations: {
			"Gene Lab": true,
			"Dispensary": true,
			"Dressing Room": true,
			"Organ Farm": true,
			"Implant Manufactory": true,
			"Prosthetic Lab": true,
			"Wardrobe": true,
			"Toy Shop": true,
			"Media Studio": true,
			"The Black Market": true,
		},
		Tools: {
			"Find Slave": true,
			"Underperforming Slaves": true,
			"Rules Assistant": true,
			"Personal Attention Select": true,
		},
		Options: {
			"Options": true,
			"Summary Options": true,
			"Description Options": true,
			"Hotkey Settings": true,
		}
	});

	// true means hidden
	const hiddenPassages = cleanPassageMapping({
		"Arcade": () => !V.arcade,
		"BG Select": () => !V.dojo,
		"Brothel": () => !V.brothel,
		"Cellblock": () => !V.cellblock,
		"Clinic": () => !V.clinic,
		"Club": () => !V.club,
		"Dairy": () => !V.dairy,
		"Dispensary": () => !V.dispensary,
		"edicts": () => V.secExpEnabled <= 0,
		"Farmyard": () => !V.farmyard,
		"Firebase": () => !V.SF.Toggle || V.SF.Toggle < 1 || V.SF.Active < 1,
		"Future Society": () => !V.FSAnnounced,
		"Gene Lab": () => !V.geneticMappingUpgrade,
		"Toy Shop": () => !V.toyShop,
		"Media Studio": () => !V.studio,
		"Head Girl Suite": () => !V.HGSuite,
		"Implant Manufactory": () => !V.ImplantProductionUpgrade,
		"Incubator": () => V.incubator.capacity === 0,
		"Manage Corporation": () => V.corp.Announced !== 1,
		"Master Suite": () => !V.masterSuite,
		"Neighbor Interact": () => V.arcologies.length === 0,
		"Nursery": () => !V.nursery,
		"Organ Farm": () => !V.organFarmUpgrade,
		"Pit": () => !V.pit,
		"propagandaHub": () => V.secExpEnabled === 0 || !V.SecExp.buildings.propHub,
		"Prosthetic Lab": () => V.researchLab.level === 0,
		"riotControlCenter": () => V.secExpEnabled === 0 || !V.SecExp.buildings.riotCenter,
		"Schoolroom": () => !V.schoolroom,
		"secBarracks": () => V.secExpEnabled === 0 || !V.SecExp.buildings.barracks,
		"securityHQ": () => V.secExpEnabled === 0 || !V.SecExp.buildings.secHub,
		"Servants' Quarters": () => !V.servantsQuarters,
		"Spa": () => !V.spa,
		"The Black Market": () => V.rep < 10000,
		"Weapons Manufacturing": () => true,
	});

	// show different names than the actual passage name, can be a function
	const uiNames = cleanPassageMapping({
		"Arcade": () => App.Entity.facilities.arcade.UIName,
		"BG Select": "Armory",
		"Brothel": () => App.Entity.facilities.brothel.UIName,
		"Cellblock": () => App.Entity.facilities.cellblock.UIName,
		"Clinic": () => App.Entity.facilities.clinic.UIName,
		"Club": () => App.Entity.facilities.club.UIName,
		"Dairy": () => App.Entity.facilities.dairy.UIName,
		"Dispensary": "Pharmaceutical Fabricator",
		"edicts": "Edicts",
		"Farmyard": () => App.Entity.facilities.farmyard.UIName,
		"Find Slave": "Locate Slave",
		"Firebase": () => `${capFirstChar(V.SF.Lower || "No one")}'s Firebase`,
		"Future Society": "Future Societies",
		"Head Girl Suite": () => App.Entity.facilities.headGirlSuite.UIName,
		"Incubator": () => App.Entity.facilities.incubator.UIName,
		"Master Suite": () => App.Entity.facilities.masterSuite.UIName,
		"Neighbor Interact": "Diplomacy",
		"Nursery": () => App.Entity.facilities.nursery.UIName,
		"Options": "Game Options",
		"Personal assistant options": "Personal Assistant",
		"Personal Attention Select": "Personal Attention",
		"Pit": () => App.Entity.facilities.pit.UIName,
		"propagandaHub": "Manage PR",
		"riotControlCenter": "Manage Rebels",
		"Schoolroom": () => App.Entity.facilities.schoolroom.UIName,
		"secBarracks": "Manage Military",
		"securityHQ": "Manage Security",
		"Servants' Quarters": () => App.Entity.facilities.servantsQuarters.UIName,
		"Spa": () => App.Entity.facilities.spa.UIName,
		"The Black Market": "Black Market",
	});

	// extra information behind the link, is a function
	const f = App.Entity.facilities;
	const extraInfo = cleanPassageMapping({
		"Arcade": () => occupancy(f.arcade),
		"BG Select": () => occupancy(f.armory),
		"Brothel": () => occupancy(f.brothel),
		"Cellblock": () => occupancy(f.cellblock),
		"Clinic": () => occupancy(f.clinic),
		"Club": () => occupancy(f.club),
		"Dairy": () => occupancy(f.dairy),
		"Farmyard": () => occupancy(f.farmyard),
		"Head Girl Suite": () => occupancy(f.headGirlSuite),
		"Incubator": () => occupancy(f.incubator),
		"Master Suite": () => occupancy(f.masterSuite),
		"Nursery": () => occupancy(f.nursery),
		"Pit": () => occupancy(f.pit),
		"Schoolroom": () => occupancy(f.schoolroom),
		"Servants' Quarters": () => occupancy(f.servantsQuarters),
		"Spa": () => occupancy(f.spa),
	});

	// true shows a notification symbol
	const notifications = cleanPassageMapping({
		"Future Society": () => FutureSocieties.availCredits() > 0 || V.FSReminder,
		"Incubator": () => V.incubator.capacity > 0 && V.incubator.readySlaves > 0,
		"Manage Corporation": () => V.corp.SpecToken > 0 && V.corp.SpecTimer === 0,
	});

	/**
	 * The DOM element of name of the currently played passage or any of its parents. Used during generation to
	 * expand the category with the current passage.
	 * @type {HTMLElement}
	 */
	let currentPassage;
	/**
	 * DOM elements that have a notification. Used to traverse upwards to give the categories notifications too.
	 * @type {Array<HTMLElement>}
	 */
	let notificationPassages;

	let hotkeysEnabled = false;

	// register hotkeys
	// this is in its own scope as we can forget the hotkeys object immediately afterwards
	{
		// setup hotkeys list, upper/lower case is important!
		const hotkeys = cleanPassageMapping({
			"BG Select": "b",
			"Buy Slaves": "s",
			"edicts": "shift+e",
			"Firebase": "z",
			"Future Society": "f",
			"Main": "m",
			"Manage Arcology": "c",
			"Manage Corporation": "shift+c",
			"Manage Penthouse": "p",
			"Manage Personal Affairs": "x",
			"Neighbor Interact": "d",
			"Options": "o",
			"Personal assistant options": "t",
			"Personal Attention Select": "a",
			"Policies": "y",
			"propagandaHub": "shift+h",
			"Recruiter Select": "u",
			"riotControlCenter": "shift+r",
			"Rules Assistant": "r",
			"secBarracks": "shift+a",
			"securityHQ": "shift+s",
			"Universal Rules": "v",
			"Find Slave": "l",
			// Facilities
			"Brothel": "1",
			"Club": "2",
			"Arcade": "3",
			"Dairy": "4",
			"Farmyard": "5",
			"Servants' Quarters": "6",
			"Master Suite": "7",
			"Schoolroom": "8",
			"Spa": "9",
			"Nursery": "0",
			"Clinic": "shift+1",
			"Cellblock": "shift+2",
			"Incubator": "shift+3",
			"Pit": "shift+4",
		});

		// register
		for (const passage of jumpTo) {
			if (!hidden.includes(passage) ||
				// some passages may be hidden, but still accessible through hotkeys
				hotkeys.hasOwnProperty(passage)) {
				const action = {
					callback: () => {
						if (hotkeysEnabled &&
							// we are not already on the passage
							State.passage !== passage &&
							// the passage is accessible
							!(hiddenPassages[passage] && hiddenPassages[passage]())) {
							Engine.play(passage);
						}
					},
					combinations: [],
				};
				// add hotkey if there is one
				if (hotkeys[passage]) {
					action.combinations.push(hotkeys[passage]);
				}
				// custom ui text
				if (uiNames[passage]) {
					action.uiName = uiNames[passage];
				}
				App.UI.Hotkeys.add(passage, action);
			}
		}
	}

	// setup history
	let history = [];
	let historyNavigation = false;
	$(document).on(':passageinit', event => {
		// if navigated here normally, add passage to history, otherwise remove last entry from history
		if (!historyNavigation) {
			if (State.passage === event.passage.title || // reloaded
				noHistory.includes(State.passage) || // came from a no-history passage
				noHistory.includes(event.passage.title) // no history for this passage
			) {
				return;
			}
			// if last passage can be jumped to add passage to history, otherwise clear history
			if (jumpTo.includes(State.passage)) {
				history.unshift(State.passage);
			} else {
				history = [];
			}
		} else {
			historyNavigation = false;
			history.shift();
		}
	});
	App.UI.Hotkeys.add("historyBack", {callback: goBack, combinations: ["backspace"], uiName: "Back in history"});

	/**
	 * Goes back in history if possible.
	 */
	function goBack() {
		if (history.length > 0 && jumpFrom.includes(State.passage)) {
			historyNavigation = true;
			Engine.play(history[0]);
		}
	}

	/**
	 * Add a "Return" link at second position if there is a history.
	 * @param {Array<HTMLElement>} linkList
	 */
	function addBackLink(linkList) {
		if (history.length > 0) {
			const div = document.createElement("div");
			div.classList.add("menu-link");
			const a = document.createElement("a");
			a.append("Return");
			a.onclick = goBack;
			const hotkey = App.UI.Hotkeys.hotkeys("historyBack");
			if (hotkey !== "") {
				div.append(a, " ", App.UI.DOM.makeElement("span", hotkey, "hotkey"));
			}
			// insert at second position
			linkList.splice(1, 0, div);
		}
	}

	/**
	 * Generate a quick menu
	 * @returns {string|HTMLParagraphElement}
	 */
	function generateMenu() {
		if (!jumpFrom.includes(State.passage)) {
			hotkeysEnabled = false;
			return "";
		}
		hotkeysEnabled = true;
		currentPassage = null;
		notificationPassages = [];

		const p = document.createElement("p");
		p.classList.add("quick-links");

		try {
			// quick menu
			const links = generateLinkList(layout);

			if (V.debugMode) {
				addCategory(links, "Hidden", hidden.reduce((acc, cur) => {
					acc[cur] = true;
					return acc;
				}, {}));
			}

			addBackLink(links);
			p.append(...links);

			// traverse from current passage up to expand.
			if (currentPassage !== null) {
				while (!currentPassage.classList.contains("quick-links")) {
					currentPassage.classList.remove("collapsed");
					currentPassage = currentPassage.parentElement;
				}
			}

			// traverse from notifications up to add icon
			for (let i = 0; i < notificationPassages.length; i++) {
				while (!notificationPassages[i].classList.contains("quick-links")) {
					if (notificationPassages[i].classList.contains("category")) {
						notificationPassages[i].classList.add("notification");
					}
					notificationPassages[i] = notificationPassages[i].parentElement;
				}
			}
		} catch (e) {
			console.error(e);
			p.append("Quick Links generation failed. Please run ", App.UI.DOM.passageLink("Backwards Compatibility", "Backwards Compatibility"));
		}

		return p;
	}

	/**
	 * Create a list of links and/or categories based on the given category
	 *
	 * @param {object} category
	 * @returns {Array<HTMLElement>}
	 */
	function generateLinkList(category) {
		/** @type {Array<HTMLElement>} */
		const links = [];
		for (const passage in category) {
			if (category[passage] === true) {
				addMenuLink(links, passage);
			} else {
				addCategory(links, passage, category[passage]);
			}
		}
		return links;
	}

	/**
	 * Add a category to a link list
	 *
	 * @param {Array<HTMLElement>} outerLinkList
	 * @param {string} name
	 * @param {object} category
	 */
	function addCategory(outerLinkList, name, category) {
		const innerLinkList = generateLinkList(category);
		if (innerLinkList.length === 0) {
			// category is empty
			return;
		}
		const toggle = document.createElement("button");
		toggle.classList.add("toggle");
		toggle.append(name);
		const content = document.createElement("div");
		content.classList.add("content");
		content.append(...innerLinkList);

		// wrap everything in one div, so we can control collapsing by changing only one class
		const wrapper = document.createElement("div");
		wrapper.classList.add("category", "collapsed");
		toggle.onclick = () => {
			wrapper.classList.toggle("collapsed");
		};
		wrapper.append(toggle, content);
		outerLinkList.push(wrapper);
	}

	/**
	 * Create a link to the given passage and add it to the link list if link is accessible.
	 *
	 * @param {Array<HTMLElement>} linkList
	 * @param {string} passage
	 */
	function addMenuLink(linkList, passage) {
		// passage is hidden
		if (hiddenPassages.hasOwnProperty(passage) && hiddenPassages[passage]()) {
			return;
		}
		// we show some kind of text/link
		const div = document.createElement("div");
		linkList.push(div);
		div.classList.add("menu-link");
		// this passage has a notification
		if (notifications[passage] && notifications[passage]()) {
			notificationPassages.push(div);
			div.classList.add("notification");
		}
		// there is additional non-link info
		if (extraInfo[passage]) {
			div.append(" ", extraInfo[passage]());
		}
		// we are already on this passage
		if (State.passage === passage) {
			div.prepend(uiName(passage));
			currentPassage = div;
			return;
		}
		// That is not a passage we can safely jump to, it's a deco element
		if (!jumpTo.includes(passage)) {
			div.prepend(uiName(passage));
			return;
		}
		// Create link
		const a = document.createElement("a");
		a.append(uiName(passage));
		a.onclick = () => {
			Engine.play(passage);
		};
		div.prepend(a);
		const hotkeyString = App.UI.Hotkeys.hotkeys(passage);
		if (hotkeyString !== "") {
			div.append(" ", App.UI.DOM.makeElement("span", hotkeyString, "hotkey"));
		}
	}

	/**
	 * Gives back the proper name for the passage or the passage title itself if there is none
	 *
	 * @param {string} passage
	 * @returns {string}
	 */
	function uiName(passage) {
		if (uiNames[passage]) {
			if (typeof uiNames[passage] === "function") {
				return uiNames[passage]();
			}
			return uiNames[passage];
		}
		return passage;
	}

	/**
	 * Adds an "Other" category to a given layout consisting of passages that can be jumped to not sorted into the
	 * existing layout.
	 *
	 * @param {object} layout
	 * @returns {object}
	 */
	function addOtherCategory(layout) {
		const passages = jumpTo.slice();

		// filter out passages used in layout
		filterPassages(passages, layout);

		// filter out hidden passages
		for (let i = 0; i < hidden.length; i++) {
			const index = passages.indexOf(hidden[i]);
			if (index > -1) {
				passages.splice(index, 1);
			}
		}

		// Other category
		if (passages.length > 0) {
			const other = {};
			for (let i = 0; i < passages.length; i++) {
				other[passages[i]] = true;
			}
			layout.Other = other;
		}
		return layout;
	}

	/**
	 * Remove all entries from a given list that exist as keys in a given layout.
	 *
	 * @param {Array<string>} passages
	 * @param {object} layout
	 */
	function filterPassages(passages, layout) {
		for (const category in layout) {
			if (layout[category] === true) {
				const index = passages.indexOf(category);
				if (index > -1) {
					passages.splice(index, 1);
				}
			} else {
				filterPassages(passages, layout[category]);
			}
		}
	}

	/**
	 * @param {App.Entity.Facilities.Facility} facility
	 * @returns {string}
	 */
	function occupancy(facility) {
		const desc = facility.occupancyReport(V.abbreviateSidebar === 2);
		if (desc === "") {
			return "";
		}
		return `(${desc})`;
	}

	/**
	 * Cleans out all mappings that are not contained in jumpTo and therefore not considered safe.
	 *
	 * @param {object} keys
	 * @returns {object}
	 */
	function cleanPassageMapping(keys) {
		const result = {};
		for (const key in keys) {
			if (jumpTo.includes(key)) {
				result[key] = keys[key];
			} else {
				console.log("Found mapping to unsafe passage: " + key + " to " + keys[key]);
			}
		}
		return result;
	}

	return generateMenu;
})();


code:
/**
 * Notify the game that the sidebar needs to be refreshed as soon as possible, but do not do it immediately.
 * This allows us to call this function repeatedly without impacting performance (for example, from repX() and cashX()).
 * The game will redraw the sidebar exactly once, as soon as all the scripts have finished executing.
 */
App.Utils.scheduleSidebarRefresh = (function() {
	let refresh = false;

	function updateSidebar() {
		refresh = false;
		UIBar.update();
	}

	function schedule() {
		if (!refresh) {
			refresh = true;
			setTimeout(updateSidebar, 0);
		}
	}

	return schedule;
})();

App.Utils.userButton = function(nextButton = V.nextButton, nextLink = V.nextLink) {
	const el = document.createElement("span");
	el.id = "next-button-wrapper"; // We must always have a named element so we have something to refresh even if the button is hidden for a scene
	const pass = passage();
	let link;

	if (pass !== "End Week") {
		if (pass === "Main") {
			link = App.UI.DOM.link(
				"END WEEK",
				() => endWeek()
			);
			link.style.fontWeight = "bold";
			link.id = "endWeekButton";
			el.append(link);
			el.append(" ");
			App.UI.DOM.appendNewElement("span", el, App.UI.Hotkeys.hotkeys("endWeek"), ["hotkey"]);
		} else {
			if (nextButton !== " ") {
				link = App.UI.DOM.passageLink(
					nextButton,
					nextLink
				);
				link.style.fontWeight = "bold";
				link.id = "nextButton";
				el.append(link);
				el.append(" ");
				App.UI.DOM.appendNewElement("span", el, App.UI.Hotkeys.hotkeys("nextLink"), ["hotkey"]);
			}
		}
	}
	return el;
};

App.Utils.updateUserButton = function() {
	return jQuery("#next-button-wrapper").empty().append(App.Utils.userButton());
};


code:
App.UI.StoryCaption = {};

/**
 * Which encyclopedia entry should be opened from the sidebar?
 * @type {string}
 */
App.UI.StoryCaption.encyclopedia = "";

App.UI.StoryCaption.render = function() {
	const pass = passage();
	const passageSafe = Story.lookup("tags", "jump-from-safe")
		.reduce((acc, cur) => acc || cur.title === pass, false);
	const fragment = new DocumentFragment();

	if (V.ui !== "start") {
		App.UI.DOM.appendNewElement("p", fragment, App.Utils.userButton());

		if (V.cheatMode || V.debugMode) {
			App.UI.DOM.appendNewElement("div", fragment, pass);
			if (V.event instanceof App.Events.BaseEvent) {
				App.UI.DOM.appendNewElement("div", fragment, V.event.eventName);
			}
			if (V.debugMode) {
				App.UI.DOM.appendNewElement("div", fragment, playerConsistencyCheck());
			}
		}

		fragment.append(week());
		fragment.append(weather());

		if (V.sideBarOptions.Cash > 0) {
			fragment.append(cash());
		}

		if (V.mods.food.enabled && V.mods.food.market) {
			fragment.append(food());
		}

		if (V.sideBarOptions.Upkeep > 0) {
			fragment.append(upkeep());
		}

		if (V.sideBarOptions.roomPop > 0 && V.sideBarOptions.Style === 'expanded') {
			const div = document.createElement("div");
			div.classList.add("sidebar-grid");

			if (V.sideBarOptions.SexSlaveCount > 0) {
				slavesGrid(div);
			}
			addRooms(div);

			fragment.append(div);
		} else if (V.sideBarOptions.SexSlaveCount > 0) {
			fragment.append(slavesSimple());
		}

		if (pass !== "Manage Penthouse") {
			if (V.sideBarOptions.GSP > 0 && pass !== "Neighbor Interact") {
				fragment.append(economy());
			}

			if (V.sideBarOptions.Rep > 0) {
				fragment.append(reputation());
			}

			if (V.secExpEnabled > 0) {
				if (V.sideBarOptions.Authority > 0) {
					fragment.append(authority());
				}

				if (V.sideBarOptions.Security > 0) {
					fragment.append(security());
				}

				if (V.sideBarOptions.Crime > 0) {
					fragment.append(crime());
				}
			}
		}

		fragment.append(App.UI.quickMenu());

		if (V.nextButton !== "Continue" && V.nextButton !== " ") {
			if (V.debugMode > 0) {
				fragment.append(debug());
			}
		}

		const reminders = App.UI.DOM.appendNewElement("div", fragment, App.UI.DOM.link("Reminders", () => App.Reminders.dialog()));
		if (V.reminders.find(r => r.week <= V.week)) {
			reminders.append(" ", App.UI.DOM.makeElement("span", String.fromCharCode(0xe80c), ["icons", "noteworthy"]));
		}
	} else if (pass === "Starting Girls") {
		fragment.append(startingGirls());
	}

	const p = document.createElement("p");
	let article = App.UI.StoryCaption.encyclopedia;
	if (!article) {
		console.log(`No FCE article set for passage '${pass}'.`);
		article = "How to Play";
	}
	p.append(App.UI.DOM.makeElement("span", "FCE", ["bold"]), ": ", App.Encyclopedia.link(article));
	fragment.append(p);

	return fragment;

	function week() {
		const fragment = new DocumentFragment();
		const div = document.createElement("div");
		const tooltip = document.createElement("div");
		App.UI.DOM.appendNewElement("div", tooltip, capFirstChar(years(V.week)));
		if (V.arcologies[0].weeks !== V.week) {
			App.UI.DOM.appendNewElement("div", tooltip, `${capFirstChar(years(V.arcologies[0].weeks))} in ${V.arcologies[0].name}`);
		}
		App.UI.DOM.appendNewElement("span", fragment, App.UI.DOM.spanWithTooltip(`Week ${num(V.week)}`, tooltip, ['bold']));
		fragment.append(div);
		App.UI.DOM.appendNewElement("div", fragment, `Week of ${asDateString(V.week)}`);

		if (showCheats()) {
			fragment.append(App.UI.DOM.makeTextBox(V.week, week => {
				week = Math.trunc(week);
				V.week = week > 0 ? week : V.week;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return fragment;
	}

	function weather() {
		const div = App.UI.DOM.appendNewElement("div", fragment, V.weatherToday.name, ["note"]);
		if (V.weatherToday.severity === 1) {
			div.classList.add("cyan");
		} else if (V.weatherToday.severity === 2) {
			div.classList.add("yellow");
		} else if (V.weatherToday.severity === 3) {
			div.classList.add("orange");
		} else {
			div.classList.add("red");
		}
		return div;
	}

	function cash() {
		const fragment = new DocumentFragment();
		V.cash = Math.trunc(V.cash);
		const css = ["cash"];
		if (V.cash <= 0) {
			css.push("dec");
		}
		const cashDiv = document.createElement("div");
		const cashDiff = V.cash - V.cashLastWeek;
		const classes = cashDiff > 0 ? 'cash' : 'cash dec';

		App.UI.DOM.appendNewElement("span", cashDiv, "Cash", css);
		cashDiv.append(" | ");
		App.UI.DOM.appendNewElement("span", cashDiv, cashFormat(V.cash), css);
		fragment.append(cashDiv);

		if (V.sideBarOptions.Style === 'compact') {
			tippy(cashDiv, {
				content: `<span class="${classes}">${cashFormat(cashDiff)}</span> since last week`,
				allowHTML: true,
			});
		} else {
			if (pass === "Main") {
				const div = document.createElement("div");
				div.append("(");
				let cashDiff = V.cash - V.cashLastWeek;
				if (cashDiff > 0) {
					App.UI.DOM.appendNewElement("span", div, cashFormat(cashDiff), ["green"]);
				} else {
					App.UI.DOM.appendNewElement("span", div, cashFormat(cashDiff), ["cash", "dec"]);
				}
				div.append(" since last week)");
				fragment.append(div);
			}
		}

		if (V.lastCashTransaction) {
			const transaction = document.createElement("div");
			css.push('note');
			App.UI.DOM.appendNewElement("span", transaction, `Last transaction`, css);
			transaction.append(" | ");
			if (V.lastCashTransaction > 0) {
				App.UI.DOM.appendNewElement("span", transaction, cashFormat(V.lastCashTransaction), css);
			} else {
				App.UI.DOM.appendNewElement("span", transaction, cashFormat(V.lastCashTransaction), ["cash", "dec"]);
			}
			fragment.append(transaction);
		}

		if (showCheats()) {
			fragment.append(App.UI.DOM.makeTextBox(V.cash, cash => {
				V.cash = Math.trunc(cash);
				V.cheater = 1;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return fragment;
	}

	function food() {
		const fragment = new DocumentFragment();
		const foodDiv = document.createElement("div");

		const css = ["food"];
		if (V.mods.food.amount < App.Facilities.Farmyard.foodConsumption()) {
			css.push("dec");
		}
		const foodDiff = V.mods.food.amount - V.mods.food.lastWeek;
		const classes = foodDiff > 0 ? 'food' : 'food dec';

		/* check if there is enough food for the next week */
		App.UI.DOM.appendNewElement("span", foodDiv, "Food", css);
		if (V.mods.food.amount < 0) {
			V.mods.food.amount = 0;
		}
		foodDiv.append(" | ");
		App.UI.DOM.appendNewElement("span", foodDiv, massFormat(V.mods.food.amount), css);
		fragment.append(foodDiv);

		if (V.sideBarOptions.Style === 'compact') {
			tippy(foodDiv, {
				content: `<span class="${classes}">${massFormat(foodDiff)}</span> since last week`,
				allowHTML: true,
			});
		} else {
			if (pass === "Main") {
				const innerDiv = document.createElement("div");
				innerDiv.append("(");
				const foodDiff = (V.mods.food.amount - V.mods.food.lastWeek);
				App.UI.DOM.appendNewElement("span", innerDiv, massFormat(foodDiff), foodDiff < 0 ? ["red"] : ["chocolate"]);
				innerDiv.append(" since last week)");
				fragment.append(innerDiv);
			}
		}

		if (showCheats()) {
			fragment.append(App.UI.DOM.makeTextBox(V.mods.food.amount, food => {
				food = Math.trunc(food);
				V.mods.food.amount = food > 0 ? food : 0;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return fragment;
	}

	function upkeep() {
		const div = document.createElement("div");
		if (passageSafe) {
			div.append(App.UI.DOM.passageLink("Upkeep", "Costs Budget"), " | ");
		} else {
			div.append(" Upkeep | ");
		}
		App.UI.DOM.appendNewElement("span", div, cashFormat(V.costs), ["cash", "dec"]);
		return div;
	}

	function slavesSimple() {
		const div = document.createElement("div");
		const dormsPop = V.dormitoryPopulation;
		const roomsPop = V.roomsPopulation;
		const dormsCap = V.dormitory;
		const roomsCap = V.rooms;
		const css = [];

		if (dormsPop > dormsCap || roomsPop > roomsCap) {
			css.push("warning");
		} else {
			css.push("pink");
		}

		App.UI.DOM.appendNewElement("span", div, "Total Sex Slaves", css);
		div.append(` | ${V.slaves.length}`);
		if (V.sideBarOptions.roomPop > 0 && V.sideBarOptions.Style === 'compact') {
			div.classList.add("has-tooltip");
			const popupDiv = document.createElement("div");
			popupDiv.classList.add("sidebar-grid");
			addRooms(popupDiv);
			tippy(div, {
				content: popupDiv,
				allowHTML: true,
			});
		}
		return div;
	}

	/**
	 * @param {HTMLDivElement} container
	 */
	function slavesGrid(container) {
		App.UI.DOM.appendNewElement("span", container, "Total Sex Slaves", ["pink"]);
		App.UI.DOM.appendNewElement("div", container, "|");
		App.UI.DOM.appendNewElement("div", container, `${V.slaves.length}`);
	}

	/**
	 * @param {HTMLDivElement} container
	 */
	function addRooms(container) {
		roomRow(container, "Penthouse Beds", V.dormitoryPopulation + V.roomsPopulation, V.dormitory + V.rooms);
		roomRow(container, "Dormitory Beds", V.dormitoryPopulation, V.dormitory);
		roomRow(container, "Luxury Rooms", V.roomsPopulation, V.rooms);
	}

	/**
	 * @param {HTMLDivElement} container
	 * @param {string} name
	 * @param {number} pops
	 * @param {number} cap
	 */
	function roomRow(container, name, pops, cap) {
		App.UI.DOM.appendNewElement("div", container, name, ["pink"]);
		App.UI.DOM.appendNewElement("div", container, "|");

		const text = `${pops}/${cap}`;
		if (pops > cap) {
			App.UI.DOM.appendNewElement("div", container, text, ["warning"]);
		} else {
			App.UI.DOM.appendNewElement("div", container, text);
		}
	}

	function economy() {
		const div = document.createElement("div");
		App.UI.DOM.appendNewElement("span", div, "GSP", ["yellowgreen"]);
		div.append(` | ${num(Math.trunc(0.1 * V.arcologies[0].prosperity))}`);
		if (V.showNumbers === 2 || (V.showNumbers === 1 && Math.trunc(0.1 * V.arcologies[0].prosperity) > V.showNumbersMax)) {
			div.append("m");
		} else {
			div.append(" million");
		}
		div.append(" ", ownershipReport(true));
		return div;
	}

	function reputation() {
		const div = document.createElement("div");
		if (passageSafe) {
			div.append(App.UI.DOM.passageLink("Rep", "Rep Budget"));
		} else {
			App.UI.DOM.appendNewElement("span", div, "Rep", ["reputation", "inc"]);
		}
		div.append(" | ");

		if (V.rep > 19000) {
			App.UI.DOM.appendNewElement("span", div, "worshipped").style.color = "rgb(0,145,0)";
		} else if (V.rep > 18000) {
			App.UI.DOM.appendNewElement("span", div, "great").style.color = "rgb(0,150,0)";
		} else if (V.rep > 17000) {
			App.UI.DOM.appendNewElement("span", div, "exalted").style.color = "rgb(0,155,0)";
		} else if (V.rep > 16000) {
			App.UI.DOM.appendNewElement("span", div, "illustrious").style.color = "rgb(0,160,0)";
		} else if (V.rep > 15000) {
			App.UI.DOM.appendNewElement("span", div, "prestigious").style.color = "rgb(0,165,0)";
		} else if (V.rep > 14000) {
			App.UI.DOM.appendNewElement("span", div, "renowned").style.color = "rgb(0,170,0)";
		} else if (V.rep > 13000) {
			App.UI.DOM.appendNewElement("span", div, "famed").style.color = "rgb(0,175,0)";
		} else if (V.rep > 12000) {
			App.UI.DOM.appendNewElement("span", div, "celebrated").style.color = "rgb(0,180,0)";
		} else if (V.rep > 11000) {
			App.UI.DOM.appendNewElement("span", div, "honored").style.color = "rgb(0,185,0)";
		} else if (V.rep > 10000) {
			App.UI.DOM.appendNewElement("span", div, "acclaimed").style.color = "rgb(0,190,0)";
		} else if (V.rep > 9000) {
			App.UI.DOM.appendNewElement("span", div, "eminent").style.color = "rgb(0,195,0)";
		} else if (V.rep > 8250) {
			App.UI.DOM.appendNewElement("span", div, "prominent").style.color = "rgb(0,200,0)";
		} else if (V.rep > 7500) {
			App.UI.DOM.appendNewElement("span", div, "distinguished").style.color = "rgb(0,205,0)";
		} else if (V.rep > 6750) {
			App.UI.DOM.appendNewElement("span", div, "admired").style.color = "rgb(0,210,0)";
		} else if (V.rep > 6000) {
			App.UI.DOM.appendNewElement("span", div, "esteemed").style.color = "rgb(0,215,0)";
		} else if (V.rep > 5250) {
			App.UI.DOM.appendNewElement("span", div, "respected").style.color = "rgb(0,220,0)";
		} else if (V.rep > 4500) {
			App.UI.DOM.appendNewElement("span", div, "known").style.color = "rgb(0,225,0)";
		} else if (V.rep > 3750) {
			App.UI.DOM.appendNewElement("span", div, "recognized").style.color = "rgb(0,230,0)";
		} else if (V.rep > 3000) {
			App.UI.DOM.appendNewElement("span", div, "rumored").style.color = "rgb(0,235,0)";
		} else if (V.rep > 2250) {
			App.UI.DOM.appendNewElement("span", div, "envied").style.color = "rgb(0,240,0)";
		} else if (V.rep > 1500) {
			App.UI.DOM.appendNewElement("span", div, "resented").style.color = "rgb(0,245,0)";
		} else if (V.rep > 750) {
			App.UI.DOM.appendNewElement("span", div, "disliked").style.color = "rgb(0,250,0)";
		} else {
			App.UI.DOM.appendNewElement("span", div, "unknown").style.color = "rgb(0,255,0)";
		}
		div.append(` (${num(V.rep)})`);

		if (showCheats()) {
			div.append(App.UI.DOM.makeTextBox(V.rep, rep => {
				V.rep = Math.clamp(Math.trunc(rep), 0, 20000);
				V.cheater = 1;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return div;
	}

	function authority() {
		const div = document.createElement("div");
		App.UI.DOM.appendNewElement("span", div, "Auth", ["darkviolet"]);

		div.append(" | ");
		const span = document.createElement("span");
		span.className = "darkviolet";
		App.Mods.SecExp.authorityX();
		if (V.SecExp.core.authority > 19500) {
			span.append("divine will");
		} else if (V.SecExp.core.authority > 19000) {
			span.append("sovereign");
		} else if (V.SecExp.core.authority > 18000) {
			span.append("monarch");
		} else if (V.SecExp.core.authority > 17000) {
			span.append("tyrant");
		} else if (V.SecExp.core.authority > 15000) {
			span.append("dictator");
		} else if (V.SecExp.core.authority > 14000) {
			span.append("prince");
		} else if (V.SecExp.core.authority > 13000) {
			span.append("master");
		} else if (V.SecExp.core.authority > 12000) {
			span.append("leader");
		} else if (V.SecExp.core.authority > 11000) {
			span.append("director");
		} else if (V.SecExp.core.authority > 10000) {
			span.append("overseer");
		} else if (V.SecExp.core.authority > 9000) {
			span.append("chief");
		} else if (V.SecExp.core.authority > 8000) {
			span.append("manager");
		} else if (V.SecExp.core.authority > 7000) {
			span.append("principal");
		} else if (V.SecExp.core.authority > 6000) {
			span.append("auxiliary");
		} else if (V.SecExp.core.authority > 5000) {
			span.append("subordinate");
		} else if (V.SecExp.core.authority > 4000) {
			span.append("follower");
		} else if (V.SecExp.core.authority > 3000) {
			span.append("powerless");
		} else if (V.SecExp.core.authority > 2000) {
			span.append("toothless");
		} else if (V.SecExp.core.authority > 1000) {
			span.append("mostly harmless");
		} else {
			span.append("harmless");
		}

		div.append(span, ` (${num(Math.trunc(V.SecExp.core.authority))})`);
		if (showCheats()) {
			div.append(App.UI.DOM.makeTextBox(V.SecExp.core.authority, auth => {
				V.SecExp.core.authority = Math.clamp(Math.trunc(auth), 0, 20000);
				V.cheater = 1;
				App.Utils.scheduleSidebarRefresh();
			}));
		}
		return div;
	}

	function security() {
		const div = document.createElement("div");
		App.UI.DOM.appendNewElement("span", div, "Security", ["deepskyblue"]);
		div.append(" | ");

		App.UI.DOM.appendNewElement("span", div, `${Math.trunc(V.SecExp.core.security)}%`, ["deepskyblue"]);
		if (showCheats()) {
			div.append(App.UI.DOM.makeTextBox(V.SecExp.core.security, sec => {
				V.SecExp.core.security = Math.clamp(Math.trunc(sec), 0, 100);
				V.cheater = 1;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return div;
	}

	function crime() {
		V.SecExp.core.crimeLow = Math.clamp(V.SecExp.core.crimeLow, 0, 100);
		const div = document.createElement("div");
		App.UI.DOM.appendNewElement("span", div, "Crime", ["orangered"]);
		div.append(" | ");
		App.UI.DOM.appendNewElement("span", div, `${Math.trunc(V.SecExp.core.crimeLow)}%`, ["orangered"]);

		if (showCheats()) {
			div.append(App.UI.DOM.makeTextBox(V.SecExp.core.crimeLow, crime => {
				V.SecExp.core.crimeLow = Math.clamp(Math.trunc(crime), 0, 100);
				V.cheater = 1;
				App.Utils.scheduleSidebarRefresh();
			}, true));
		}
		return div;
	}

	function showCheats() {
		return pass === "Main" && V.cheatMode && V.cheatModeM;
	}

	function debug() {
		const p = document.createElement("p");
		p.append("Debugging Tools");

		App.UI.DOM.appendNewElement("div", p, App.UI.DOM.link("Display Variables", App.checkVars));

		App.UI.DOM.appendNewElement("div", p, App.UI.DOM.link("Display Changed Variables", () => {
			/* makes sure we store the current state so we can return to it */
			Config.history.maxStates = 2;
			Engine.play("Variable Difference");
		}));

		if (V.debugModeCustomFunction > 0) {
			const textarea = document.createElement("textarea");
			textarea.id = "cheat-function";
			p.append(textarea);

			p.append(App.UI.DOM.link("Run Custom Function", () => {
				let value = textarea.value;
				if (value.charAt(0) !== "(") {
					value = `() => {${value}}`;
				}
				if (typeof eval(value) === "function") {
					(eval(value))();
					Engine.play(pass);
				} else {
					Dialog.append("Not a valid function").open();
				}
			}));
		}

		App.UI.DOM.appendNewElement("div", p, App.UI.DOM.link("Dump Game State", App.Debug.dumpGameState));

		return p;
	}

	function startingGirls() {
		const f = new DocumentFragment();

		// @ts-ignore // In starting girls we know that there is always an active slave
		let cost = startingSlaveCost(V.activeSlave);
		let p = document.createElement("p");
		if (cost > V.cash) {
			const div = document.createElement("div");
			div.classList.add("cash", "dec");
			div.append("This slave will cost ",
				App.UI.DOM.makeElement("span", cashFormat(cost), ["bold"]), ".");
			p.append(div);

			App.UI.DOM.appendNewElement("div", p, `You only have: ${cashFormat(V.cash)}.`);
		} else {
			const div = document.createElement("div");
			div.append("This slave will cost ",
				App.UI.DOM.makeElement("span", cashFormat(cost), ["cash"]), ".");
			p.append(div);

			App.UI.DOM.appendNewElement("div", p, `You have ${cashFormat(V.cash)}.`);
		}
		f.append(p);

		if (V.limitedCheatStart) {
			p = document.createElement("p");
			p.append("Edit cash: ", App.UI.DOM.makeTextBox(V.cash, cash => {
				cashX(cash - V.cash, "cheating");
				App.UI.reload();
			}, true));
			f.append(p);
		}

		return f;
	}
};


code:
App.UI.Tabs = (function() {
	/**
	 * @type {Array<HTMLButtonElement|HTMLAnchorElement>}
	 */
	let active = null;

	/**
	 * TabBar class responsible for collecting the tabs and rendering to one DOM Node.
	 *
	 * Multiple TabBars on one page will work, but hotkeys may behave unexpectedly. (It's also considered bad ui design)
	 */
	class TabBar {
		/**
		 * @typedef tab
		 * @property {string} name
		 * @property {string} id
		 * @property {Node} content
		 * @property {string} [buttonClass]
		 * @property {string} [contentClass]
		 */

		/**
		 * @param {string} id Saved, should stay the same across versions. No spaces allowed
		 */
		constructor(id) {
			/**
			 * @type {string}
			 * @private
			 */
			this._id = id;
			/**
			 * @type {Array<tab>}
			 * @private
			 */
			this._tabs = [];
			/**
			 * @type {Node}
			 * @private
			 */
			this._customNode = null;
		}

		/**
		 * @param {string} name Display name
		 * @param {string} id Saved, should stay the same across versions
		 * @param {Node} content
		 * @param {string} [buttonClass] CSS class applied to the button
		 * @param {string} [contentClass] CSS class applied to the content holder
		 */
		addTab(name, id, content, buttonClass, contentClass) {
			this._tabs.push({
				name: name,
				id: id,
				content: content,
				buttonClass: buttonClass,
				contentClass: contentClass,
			});
		}

		/**
		 * Add a DOM element between the button row and the content. Mostly intended for art.
		 *
		 * @param {Node} node
		 */
		set customNode(node) {
			this._customNode = node;
		}

		/**
		 * @param {boolean} plainLinks
		 * @returns {DocumentFragment}
		 */
		render(plainLinks = false) {
			const f = new DocumentFragment();
			const tabBar = document.createElement("div");
			tabBar.classList.add("tab-bar");
			f.append(tabBar);

			if (this._customNode) {
				f.append(this._customNode);
			}

			/**
			 * @type {Array<HTMLButtonElement|HTMLAnchorElement>}
			 */
			const buttonList = [];
			/**
			 * @type {Array<HTMLDivElement>}
			 */
			const contentList = [];

			let any = false;
			for (const tab of this._tabs) {
				// check if selected.
				const selected = V.tabChoice.hasOwnProperty(this._id) && V.tabChoice[this._id] === tab.id;

				/** @type {HTMLButtonElement|HTMLAnchorElement} */
				let button;
				if (!plainLinks) {
					button = App.UI.DOM.appendNewElement("button", tabBar, tab.name, tab.buttonClass);
				} else {
					button = App.UI.DOM.makeElement("a", tab.name, tab.buttonClass);
				}
				buttonList.push(button);

				const contentHolder = App.UI.DOM.appendNewElement("div", f, tab.content, "tab-content");
				if (tab.contentClass) {
					contentHolder.classList.add(tab.contentClass);
				}
				contentList.push(contentHolder);

				button.onclick = () => {
					buttonList.forEach(c => { c.classList.remove("active"); });
					contentList.forEach(c => { c.classList.remove("active"); });
					button.classList.add("active");
					contentHolder.classList.add("active");
					V.tabChoice[this._id] = tab.id;
				};

				if (selected) {
					contentHolder.classList.add("active");
					button.classList.add("active");
					any = true;
				}
			}

			if (plainLinks) {
				tabBar.append(App.UI.DOM.generateLinksStrip(buttonList));
			}

			// if no tabs are open, open the first one.
			if (!any && buttonList.length > 0) {
				buttonList[0].click();
			}

			active = buttonList;

			return f;
		}
	}

	/**
	 * Call on passageinit to clear the button cache.
	 * Shouldn't matter, but we don't need to have huge DOM trees floating around.
	 */
	function clear() {
		active = null;
	}

	/**
	 * Open the tab to the left of the currently open tab. If there is none, don't do anything.
	 */
	function openLeft() {
		if (active) {
			const index = currentIndex();
			if (index - 1 >= 0) {
				active[index - 1].click();
			}
		}
	}

	/**
	 * Open the tab to the right of the currently open tab. If there is none, don't do anything.
	 */
	function openRight() {
		if (active) {
			const index = currentIndex();
			if (index > -1 && index + 1 < active.length) {
				active[index + 1].click();
			}
		}
	}

	/**
	 * @returns {number} Index of the currently open tab
	 */
	function currentIndex() {
		for (let i = 0; i < active.length; i++) {
			if (active[i].classList.contains("active")) {
				return i;
			}
		}
		return -1;
	}

	return {
		TabBar: TabBar,
		clear: clear,
		left: openLeft,
		right: openRight
	};
})();


code:
App.UI.Theme = (function() {
	// NOTE: Due to browser limitations it is not possible to retrieve the path of selected files, only the filename.
	// We therefore expect all files to be located in the same directory as the HTML file. Selected files from somewhere
	// else will simply not be loaded or if a file in the correct place has the same name, it will be loaded instead.
	/** @type {HTMLLinkElement} */
	let currentThemeElement = null;

	return {
		selector: selector,
		loadTheme: loadTheme,
		init: loadFromStorage,
	};

	/**
	 * @returns {HTMLDivElement}
	 */
	function selector() {
		const div = document.createElement("div");

		const selector = document.createElement("input");
		selector.type = "file";
		selector.accept = ".css";
		div.append(selector);

		div.append(" ", App.UI.DOM.link("Load", () => {
			if (selector.files.length > 0) {
				const themeFile = selector.files[0];
				loadTheme(themeFile.name);
			}
		}), " ", App.UI.DOM.link("Unload", unloadTheme));

		return div;
	}

	/**
	 * @param {string} [filename] or filepath relative to the HTML file.
	 */
	function loadTheme(filename) {
		unloadTheme();
		if (filename) {
			SugarCube.storage.set("theme", filename);
			currentThemeElement = document.createElement("link");
			currentThemeElement.setAttribute("rel", "stylesheet");
			currentThemeElement.setAttribute("type", "text/css");
			currentThemeElement.setAttribute("href", `./${filename}`);
			// make it unique to force reloading instead of using the cached version
			currentThemeElement.href += `?id=${Date.now()}`;
			document.head.appendChild(currentThemeElement);
		}
	}

	/**
	 * Unloads the current theme
	 */
	function unloadTheme() {
		if (currentThemeElement !== null) {
			document.head.removeChild(currentThemeElement);
			currentThemeElement = null;
			SugarCube.storage.delete("theme");
		}
	}

	/**
	 * Loads a saved theme from browser storage
	 */
	function loadFromStorage() {
		const file = SugarCube.storage.get("theme");
		if (file !== null) {
			loadTheme(file);
		}
	}
})();


code:
App.UI.GlobalTooltips = (function() {
	let instances = [];

	const tooltips = new Map([
		[".exampleTooltip", "I am a helpful tooltip. We are very rare because we are still in development."],

		[".devotion", "Devotion is a measure of a slave's love for you."],
		[".trust", "Trust is a measure of a slave's expectations of you and confidence to perform well."],
		[".defiant", "Defiant slaves will actively work against you."],

		[".flaw", "Flaws impend your slaves performance. Try removing or converting them into quirks."],
		[".intelligent", "More intelligent slaves tend to perform better."],
		[".stupid", "More intelligent slaves tend to perform better."],
		[".health", "The healthier your slaves, the better they perform."],
		[".libido", "Sex drive has various effects, generally higher is better."],
		[".positive", "This is good."],
		[".warning", "This is very bad. Try removing the cause for this."],

		[".error", "Something just broke. Please report this."],

		[".cash", "Money. Always useful."],
		[".reputation", "Your reputation as a slave owner. The higher, the better."],
	]);

	function enable() {
		disable();
		instances = [];
		for (const [identifier, tooltip] of tooltips.entries()) {
			instances.push(tippy.delegate(document.body, {
				target: identifier,
				content: tooltip
			}));
		}
	}

	function disable() {
		for (const instance of instances) {
			instance.destroy();
		}
		instances = [];
	}

	function update() {
		if (V.tooltipsEnabled === 1) {
			enable();
		} else {
			disable();
		}
	}

	return {update: update};
})();


code:
App.Intro.init = function() {
	/*
	FREE CITIES
	a text-based slave management game
	Copyright (C) 2017 freecitiesdev

	This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

	This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

	You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
	*/

	/* Set up the game as politely as possible. If values are already set, they are preserved. */
	App.Update.setNonexistentProperties(V, App.Data.defaultGameStateVariables);

	/* These variables must be created AND set to default values, NG+ or not */
	App.Update.setExistentProperties(V, App.Data.resetOnNGPlus);

	V.ver = App.Version.base;
	V.pmodVer = App.Version.pmod;
	V.releaseID = App.Version.release;

	/* 0 out the record books as we start a new game */
	setupLastWeeksCash();
	setupLastWeeksRep();

	if (V.saveImported === 0) { // new game (not NG+)
		V.PC = basePlayer();
		WombInit(V.PC);
		cashX(10000, "personalBusiness");
		initRules();
	} else { // imported save (NG+)
		App.Data.NewGamePlus();
	}

	/* Porn star counts (prestige 1) and ID's (prestige 3) */
	V.pornStars = {};
	for (const genre of App.Porn.getAllGenres()) {
		V.pornStars[genre.fameVar] = {p1count: 0, p3ID: 0};
	}

	assistant.object();
	repX(1000, "event");
	App.Data.prostheticIDs.forEach(function(id) {
		V.prosthetics[id] = {amount: 0, research: 0};
	});
	V.JobIDMap = makeJobIdMap();

	App.Mods.SF.Init();

	V.weatherToday = App.Data.Weather.hotNice.random();
	V.weatherLastWeek = 1;
	V.weatherType = 1;
	V.weatherRemaining = 6;
	V.prisonCircuitIndex = random(0, V.prisonCircuit.length-1);
	if (V.prisonCircuit[V.prisonCircuitIndex] === "juvenile detention" && (V.minimumSlaveAge >= 16 || V.pedo_mode === 1)) {
		V.prisonCircuitIndex = 0; // skip juvenile detention if juvenile slaves are not allowed, or we're in pedo mode (where all prisoners are juvenile)
	}

	/* I am not a slave object! Do not treat me like one! */
	V.customSlave = new App.Entity.CustomSlaveOrder();
	V.huskSlave = new App.Entity.CustomSlaveOrder();
};


code:
/**
 * @returns {DocumentFragment}
 */
App.UI.SlaveInteract.artificialInsemination = function() {
	const f = new DocumentFragment();
	let r;

	App.UI.DOM.appendNewElement("p", f, `${getSlave(V.AS).slaveName} is prepped for fertilization; now you must select a target to harvest sperm from.`, "scene-intro");

	App.UI.DOM.appendNewElement("h2", f, "Select an eligible slave to serve as the semen donor");

	r = [];
	let any = false;
	for (const slave of V.slaves) {
		if (slave.balls > 0 && slave.pubertyXY === 1 && canBreed(getSlave(V.AS), slave)) {
			const {his} = getPronouns(slave);

			const name = App.UI.DOM.makeElement("span", SlaveFullName(slave), "has-tooltip");
			tippy(name, {
				content: App.UI.DOM.slaveDescriptionDialog(slave, "Pop-up", {noArt: true}),
				interactive: true,
			});
			r.push(App.UI.DOM.makeElement("div", name));

			r.push(App.UI.DOM.makeElement("div",
				App.Medicine.Surgery.makeLink(
					new App.Medicine.Surgery.Procedures.Insemination(getSlave(V.AS), `Use ${his} sperm.`, slave),
					exit, false)
			));

			any = true;
		}
	}
	if (r.length > 0) {
		App.Events.addNode(f, r, "div", "grid-2columns-auto");
	} else {
		App.UI.DOM.appendNewElement("p", f, "You have no slaves with potent sperm.", "note");
	}

	if (V.incubator.tanks.length > 0 && V.incubator.upgrade.reproduction === 1) {
		r = [];
		any = false;
		for (const tank of V.incubator.tanks) {
			if (tank.balls > 0 && tank.dick > 0 && tank.incubatorSettings.reproduction === 2 && canBreed(getSlave(V.AS), tank)) {
				if (any === false) {
					App.UI.DOM.appendNewElement("h2", f, "Select an eligible incubatee to milk for semen");
					App.UI.DOM.appendNewElement("p", f, "Incubator settings are resulting in large-scale fluid secretion.", "scene-intro");
					any = true;
				}
				r.push(App.UI.DOM.makeElement("div",
					App.Medicine.Surgery.makeLink(
						new App.Medicine.Surgery.Procedures.Insemination(getSlave(V.AS), `Use ${tank.slaveName}'s sperm.`, tank),
						exit, false)
				));
			}
		}

		if (any) {
			App.Events.addParagraph(f, r);
		} else {
			App.UI.DOM.appendNewElement("p", f, "You have no growing slaves producing sperm.", "note");
		}
	}

	if (V.PC.balls !== 0) {
		App.UI.DOM.appendNewElement("p", f,
			App.Medicine.Surgery.makeLink(
				new App.Medicine.Surgery.Procedures.Insemination(getSlave(V.AS), "Use your own", V.PC),
				exit, false)
		);
	} else if (V.PC.counter.storedCum > 0) {
		r = [];
		r.push(App.Medicine.Surgery.makeLink(
			new App.Medicine.Surgery.Procedures.InseminationFromStored(getSlave(V.AS), "Use a vial of your own", V.PC),
			exit, false));
		r.push(`<span class="detail">You have enough sperm stored away to inseminate ${V.PC.counter.storedCum} more ${V.PC.counter.storedCum > 1 ? "slaves" : "slave"}.</span>`);
		App.Events.addParagraph(f, r);
	}

	function exit() {
		Engine.play("Remote Surgery");
	}

	return f;
};


code:
App.Data.Records = {};

App.Data.Records.LastWeeksCash = function() {
	// Slaves in general
	this.slaveMod = 0;
	this.slaveSurgery = 0;
	this.birth = 0;
	this.porn = 0;
	// Slave Income and Expenses per assignment
	this.slaveAssignmentRest = 0;
	this.slaveAssignmentRestVign = 0;
	this.slaveAssignmentFucktoy = 0;
	this.slaveAssignmentClasses = 0;
	this.slaveAssignmentHouse = 0;
	this.slaveAssignmentHouseVign = 0;
	this.slaveAssignmentWhore = 0;
	this.slaveAssignmentWhoreVign = 0;
	this.slaveAssignmentPublic = 0;
	this.slaveAssignmentPublicVign = 0;
	this.slaveAssignmentSubordinate = 0;
	this.slaveAssignmentMilked = 0;
	this.slaveAssignmentMilkedVign = 0;
	this.slaveAssignmentExtraMilk = 0;
	this.slaveAssignmentExtraMilkVign = 0;
	this.slaveAssignmentGloryhole = 0;
	this.slaveAssignmentConfinement = 0;
	this.slaveAssignmentBodyguard = 0;
	this.slaveAssignmentHeadgirl = 0;
	this.slaveAssignmentHeadgirlsuite = 0;
	this.slaveAssignmentRecruiter = 0;
	this.slaveAssignmentConcubine = 0;
	this.slaveAssignmentMastersuite = 0;
	this.slaveAssignmentAgent = 0;
	this.slaveAssignmentAgentPartner = 0;
	this.slaveAssignmentArcade = 0;
	this.slaveAssignmentMadam = 0;
	this.slaveAssignmentMadamVign = 0;
	this.slaveAssignmentBrothel = 0;
	this.slaveAssignmentBrothelVign = 0;
	this.slaveAssignmentWarden = 0;
	this.slaveAssignmentCellblock = 0;
	this.slaveAssignmentDj = 0;
	this.slaveAssignmentDjVign = 0;
	this.slaveAssignmentClub = 0;
	this.slaveAssignmentClubVign = 0;
	this.slaveAssignmentNurse = 0;
	this.slaveAssignmentClinic = 0;
	this.slaveAssignmentMilkmaid = 0;
	this.slaveAssignmentDairy = 0;
	this.slaveAssignmentDairyVign = 0;
	this.slaveAssignmentFarmer = 0;
	this.slaveAssignmentFarmyard = 0;
	this.slaveAssignmentFarmyardVign = 0;
	this.slaveAssignmentMatron = 0;
	this.slaveAssignmentNursery = 0;
	this.slaveAssignmentNurseryVign = 0;
	this.slaveAssignmentTeacher = 0;
	this.slaveAssignmentSchool = 0;
	this.slaveAssignmentSteward = 0;
	this.slaveAssignmentQuarter = 0;
	this.slaveAssignmentQuarterVign = 0;
	this.slaveAssignmentAttendant = 0;
	this.slaveAssignmentSpa = 0;
	this.slaveAssignmentBabyFactory = 0;
	this.slaveAssignmentChoice = 0;
	// Menial Slaves
	this.menialTrades = 0;
	this.fuckdolls = 0;
	this.menialBioreactors = 0;
	// Misc Slaves
	this.labScientists = 0;
	this.labMenials = 0;
	this.slaveTransfer = 0;
	this.indentureRenewal = 0;
	this.menialTransfer = 0;
	this.fuckdollsTransfer = 0;
	this.menialBioreactorsTransfer = 0;
	this.menialTransferA = 0;
	this.fuckdollsTransferA = 0;
	this.menialBioreactorsTransferA = 0;
	this.labScientistsTransfer = 0;
	this.babyTransfer = 0;
	this.menialRetirement = 0;

	// Buildings
	this.masterSuite = 0;
	this.arcade = 0;
	this.brothel = 0;
	this.brothelAds = 0;
	this.cellblock = 0;
	this.club = 0;
	this.clubAds = 0;
	this.clinic = 0;
	this.dairy = 0;
	this.farmyard = 0;
	this.incubator = 0;
	this.incubatorSlaves = 0;
	this.nursery = 0;
	this.pit = 0;
	this.lab = 0;
	this.labResearch = 0;
	this.school = 0;
	this.servantsQuarters = 0;
	this.spa = 0;

	// Arcology
	this.environment = 0;
	this.weather = 0;

	this.mercenaries = 0;
	this.peacekeepers = 0;
	this.specialForces = 0;
	this.specialForcesCap = 0;
	this.securityExpansion = 0;

	this.citizenOrphanage = 0;
	this.privateOrphanage = 0;

	this.capEx = 0;
	this.futureSocieties = 0;
	this.schoolBacking = 0;
	this.policies = 0;
	this.subsidiesAndBarriers = 0;
	this.edicts = 0;

	// Personal Finance
	this.personalBusiness = 0;
	this.personalLivingExpenses = 0;
	this.PCSkills = 0;
	this.PCtraining = 0;
	this.PCdiet = 0;
	this.PCdrugs = 0;
	this.PCmedical = 0;
	this.PCcosmetics = 0;
	this.PCskills = 0;
	this.stocksTraded = 0; // trading
	this.stocks = 0; // share growth
	this.fines = 0;
	this.event = 0; // poker night etc. Try to file things elsewhere if you can.
	this.war = 0;
	this.loan = 0;

	// Policies
	this.food = 0;

	this.rents = 0;

	this.cheating = 0;
	this.total = 0;
};

App.Data.Records.LastWeeksRep = function() {
	// Slave Jobs
	this.fucktoy = 0;
	this.publicServant = 0;
	this.gloryholeArcade = 0;
	this.gloryhole = 0;
	this.disobedience = 0;
	this.concubine = 0;
	this.headGirl = 0;
	this.bodyguard = 0;
	this.recruiter = 0;

	// Slaves in general
	this.fuckdolls = 0;
	this.slaveTransfer = 0;
	this.babyTransfer = 0;
	this.birth = 0;
	this.retirement = 0;
	this.slavesViewOfPC = 0;
	this.prestigiousSlave = 0;
	this.vignette = 0;
	this.porn = 0;

	// Buildings
	this.arcade = 0;
	this.brothel = 0;
	this.club = 0;
	this.publicServantClub = 0;
	this.clubAds = 0;
	this.pit = 0;
	this.servantsQuarters = 0;
	this.spa = 0;

	this.shows = 0;	// e.g. Farmyard shows, etc
	this.architecture = 0;

	// Policies
	this.capEx = 0;
	this.futureSocieties = 0;
	this.policies = 0;
	this.subsidiesAndBarriers = 0;
	this.edicts = 0;
	this.war = 0;
	this.food = 0;

	// Forces
	this.peacekeepers = 0;
	this.specialForces = 0;
	this.securityExpansion = 0;

	// Personal Finance
	this.personalBusiness = 0;
	this.PCappearance = 0;
	this.PCactions = 0; // actions involving your body = becoming pregnant; etc.
	this.PCRelationships = 0;
	this.SlaveRelationships = 0;
	this.event = 0; // poker night etc. Try to file things elsewhere if you can.
	this.war = 0;

	// special
	this.multiplier = 0; // decay (from reputation.js)
	this.overflow = 0; // loss above 20k cap
	this.curve = 0; // loss due to income curving
	this.cheating = 0;
	this.weather = 0;

	this.total = 0;
};


code:
/** @param {App.Entity.SlaveState} slave */
App.Interact.discard = function(slave) {
	const frag = document.createDocumentFragment();

	const {him} = getPronouns(slave);

	let cost = 0;
	try {
		cost = 50 * Math.trunc(slaveCost(slave) / 100); // slightly less than you'd get from Sell Slave
	} catch {
		// just use the minimum price
	}
	cost = Math.max(cost, minimumSlaveCost(false));

	const result = document.createElement("span");

	App.UI.DOM.appendNewElement("p", frag, `This will permanently remove ${SlaveFullName(slave)} from the game, and you will receive a minimal reimbursement of ${cashFormat(cost)} for ${him}. This is primarily intended as a way to rid yourself of bugged slaves. Are you certain?`);

	App.UI.DOM.appendNewElement("div", result, App.UI.DOM.link(`Discard ${him}`, () => {
		$(result).empty();
		try {
			result.append(App.Interact.Sale.separationReactions(slave));
		} catch {
			// oh well
		}

		App.UI.DOM.appendNewElement("div", result, `${slave.slaveName} has been removed from the game.`);

		cashX(cost, "slaveTransfer");
		removeSlave(slave);
		V.nextButton = "Back to Main";
		V.nextLink = "Main";
		App.Utils.scheduleSidebarRefresh();
	}));

	App.UI.DOM.appendNewElement("div", result, App.UI.DOM.passageLink(`Put ${him} up for sale instead`, "Sell Slave"));
	App.UI.DOM.appendNewElement("div", result, App.UI.DOM.passageLink("Cancel", V.nextLink));

	frag.append(result);

	return frag;
};


code:
// cSpell:ignore Creem, Taggart, Malnockestivi, Desha

App.FCNN = (function() {
	const starting = [
		"...coming up at the top of the hour: Catgirl slaves, science fact or science fiction...",
		"...coming up at the top of the hour: Malnockestivi Smith, Free Cities' first MtFtMtFtH transgendered person...",
		"...new arcology construction up 23% worldwide this year, according to...",
		"...United States Congress spends 1,264th consecutive day gridlocked over post office...",
		"...coming up at the top of the hour: Arcology owners: oversexed oligarchs or attractive, oversexed oligarchs?...",
		"...coming up at the top of the hour: Anal sex: not just for sex any more...",
		"...coming up at the top of the hour: Oral sex: the new hello...",
		"...new book by prominent feminist suggests that women should not be used as sexual appliances...",
		"...just ahead, interview with Desha Moore, prominent advocate for compulsory female enslavement...",
		"...just ahead, Slave Market Trends: will the pierced anus be in again this year...",
		"...just ahead, Slave Market Trends: upstart slave trainers avoid implants...",
		"...implant technology firm BusTech notches successful initial public offering...",
		"...the upcoming shortage of authentic leather and what it means for the whip industry...",
		"...dairy conglomerate Creem Inc. denies allegations of adulterating breast milk with...",
		"...two-time award-winning actress Linda Loveless debuted new implants on the red carpet this...",
		"...dick size: are your slaves lying to you when they tell you you're too big...",
		"...just ahead, slave expert's opinion on best shemale slaves to use for double penetration...",
		"...Free Cities social conservatives criticize marriage, say your slaves should be enough...",
		"...councilman Taggart suggested in a public address that involuntary enslavement...",
		"...councilman Taggart denies allegations that he has remained faithful to his wife...",
		"...councilman Taggart presented evidence that regulation of the sex slave market would...",
		"...after a word from our sponsors. Creem Inc.: for all your dairy needs...",
		"...after a word from our sponsors. Horstmann Ltd, Free Cities' finest whipmakers...",
		"...after a word from our sponsors. Coming soon to theaters, Quintuple, the musical...",
		"...critical of low-end slave training corporation Wallerson & Sons for practices that they say...",
		"...training corporation Wallerson & Sons called a study on slave illness rates 'ludicrous,' but...",
		"...our tech correspondent: the possibilities of virally-administered gene therapy...",
		"...our tech correspondent: breakthrough in in-vitro drug treatments that promise to...",
		"...our tech correspondent: next year to see release of two competing aphrodisiacs...",
		"...our tech correspondent: the coming permanent aphrodisiac implants, and what they mean...",
		"...Sex Slaves in Space: what it takes to keep a mining crew happy for an 18-month contract...",
		"...the implant-drug balance: how much tissue growth is necessary to support larger...",
		"...the actress stated that the cut to full nudity in the script violated contractual...",
		"...doping scandal as Slave Games watchdog alleges champion used internal reservoir of lube...",
		"...next on Extreme Surgery: the mouthpussy experimenters and what they...",
		"...'A hole's a hole,' said CEO of upstart budget glory hole franchise...",
		"...underground slave pit fights step into the light this evening as...",
		"...underground slave pit fight champion, freed yesterday, sells herself back into...",
		"...with the lead designer of the MP17, the new machine pistol marketed specifically for bodyguards...",
		"...the new Aegis drone series: because your arcology's security is your most important possession...",
		"...the BAe Goshawk: because you deserve to travel at twice the speed of sound in the finest style...",
		"...this year's Goat.cx award for outstanding orifice innovation goes to...",
		"...public controversy over cannibalism. Decadence taken too far or an acceptable next step...",
		"...sixth day of street cleaners' strike. Spokesman for the strikers: 'It's getting too nasty...",
		"...debuts new book, 'So Long, And Thanks For All The Dicks', in which the recently retired sex slaves tell-all about...",
		"...cure for lactose intolerance, for which he was awarded the International Association of Pastoralist...",
		"...from the Free Cities have become increasingly common clientele for the black market...",
		"...a risky gamble on the three-hour-long hardcore sex scene, but the box office figures for just...",
		"...claiming that even tourists to the Free Cities were at risk of sexual enslavement...",
	];

	const week30 = [
		"...is where I'll be when the bombs hit. Survival is my first...",
		"...time to prepare for major shortages is now, since there's no likelihood...",
		"...construction of shelters accelerated last quarter, due to fears...",
		"...major drought across the entire hemisphere shows no signs of...",
		"...medical organization offered no explanation, stating that...",
		"...just ahead, an FCNN Special Report: Immortality and You — Breakthroughs in 'Bodyswapping' technology and what they mean for...",
		"...wildfires have now cost an estimated...",
		"...widespread demonstrations against the government's friendly relations with the Free Cities...",
		"...of the small island nation, declared that his homeland may be completely underwater by...",
		"...claimed the sanctions constituted an act of war...",
		"...nuclear weapons development program, drawing harsh criticism from international observers...",
		"...simply don't have the money to adequately respond to a natural disaster of this...",
		"...representatives announced their withdrawal from the international organization...",
		"...special report on the depopulation and abandonment of rural areas worldwide, as natural...",
	];

	const week50 = [
		"...tested a thermonuclear device of some five megatons last night...",
		"...heavy fighting for the third week in the capital city of...",
		"...economic recovery looking increasingly unlikely in the event of...",
		"...complete exhaustion of safe water reserves across...",
		"...climate refugees streamed across the border despite...",
		"...released a statement today, explaining their refusal to intervene against the pillaging of several Free Cities in northern...",
		"...denies allegations of ordering last week's combined arms assault on the arcology known as...",
		"...Coming up, an FCNN Special Report: Ghost Cities — The truth about recent surge of depopulated metropolitan areas in...",
		"...was assassinated in his home in the nation's capital, alongside his entire...",
		"...forces opened fire on crowds of protesters, killing and wounding...",
		"...declared the insurgency officially defeated, despite reports of widespread violence...",
		"...suspended national elections until further notice, in a move that is seen as yet another authoritarian...",
	];

	const week70 = [
		"...second low-order nuclear detonation in the city of...",
		"...total societal collapse induced by rising sea levels declared today in...",
		"...likelihood of containing Influenza-M was downgraded to a lower...",
		"...threatened immediate use of chemical weapons should those forces not withdraw...",
		"...relief organizations unable to deal with casualties from nuclear exchange between...",
		"...guerrilla fighting intensifies in escalating proxy war between 'Paternalist' and 'Degradationist' Free Cities in the region of...",
		"...Coming up, an FCNN Special Report about the unprecedented scale of slave insurrections across...",
		"...invasive plant growth epidemic contested by persistent droughts in eastern...",
		"...Daughters of Liberty claimed responsibility for the attack...",
		"...mass suicide of the cult members, who believed that current events were prophesied...",
		"...genocide is a strong term, but there's simply no other way to describe...",
	];

	const week90 = [
		"...have enslaved the entire legislature, their leader personally sexually assaulting...",
		"...confirmed that the arcologies were subject to a nuclear detonation, but...",
		"...conflict can now be said to encompass the entire continent...",
		"...government has effectively fallen, with much of the capital destroyed...",
		"...deadliest in the nation's history, was allegedly committed by the Daughters of Liberty...",
		"...famine so devastating that confirmed reports of cannibalism are now...",
		"...dozens upon dozens of mass graves in what were once city parks...",
		"...evidence of a planned slave uprising, executed all of their arcology's several thousand...",
	];

	const bodyswap = [
		"...transferring consciousness from one body to another is morally wrong, but financially..."
	];

	const TFSCompromise = [
		"...Ladies: Do you want dick so much that you want to HAVE a dick? If yes, join the Futanari Sisters today! Learn more at..."
	];

	const corpAnnounced = [
		"...and Free Cities have definitely shaken up the stock market in recent..."
	];

	const geneMods = [
		"...27 women confirmed pregnant after man masturbates in pool. Should gene therapy..."
	];

	const catgirlsPublic = [
		"...Catgirl slaves: Fad, fluke, or fashion? Tune in for an exclusive FCNN interview with Doctor Radomir Nieskowitz later tonight..."
	];

	const projectNComplete = [
		"...Sons of Sekhmet claim responsibility for bombing campaign in...",
		"...was killed this week in a surprise ambush by Sons of Sekhmet assassins...",
		"...terrorist organizations in the Free Cities: An exclusive report, coming up next...",
		"...large scale rioting continues for the ninety-sixth consecutive day over genetic engineering concerns...",
	];

	const textGroups = [
		// dynamic headlines based on conditions
		{valid: () => true, text: () => starting},
		{valid: () => V.week >= 30, text: () => week30},
		{valid: () => V.week >= 50, text: () => week50},
		{valid: () => V.week >= 70, text: () => week70},
		{valid: () => V.week >= 90, text: () => week90},
		{valid: () => V.bodyswapAnnounced === 1, text: () => bodyswap},
		{valid: () => V.TFS.schoolUpgrade === 3, text: () => TFSCompromise},
		{valid: () => V.FCNNstation === 1, text: () => [`...is FCNN, broadcasting live from its international headquarters within ${V.arcologies[0].name}...`]},
		{valid: () => V.corp.Announced === 1, text: () => corpAnnounced},
		{valid: () => V.geneticMappingUpgrade === 2, text: () => geneMods},
		{valid: () => V.seeCats === 1 && V.projectN.public === 1, text: () => catgirlsPublic},
		{valid: () => V.seeCats === 1 && V.projectN.status >= 5, text: () => projectNComplete},
		// custom headlines can be stored in game state by events
		{valid: (/** @type {boolean} */ includeCustom) => includeCustom, text: () => V.fcnn}
	];

	return {
		/** Get all currently-valid news ticker strings for FCNN
		 * @param {boolean} [seeCustom=true] indicates whether custom headlines (from game state) are included
		 * @returns {string[]}
		 */
		getAllValidText: function(seeCustom = true) {
			return textGroups.filter(g => g.valid(seeCustom)).flatMap(g => g.text());
		},

		/** Get a random news ticker string for FCNN
		 * @returns {string}
		 */
		getText: function() {
			if (V.FCNNstation !== 1 && V.week >= 90) {
				return "FCNN: FCNN service has been temporarily suspended. Please stand by. ";
			}
			return this.getAllValidText().random() + " ";
		}
	};
})();