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 one of: "<", "<=", ">", ">="
		 * @param {string} [name=value]
		 */
		addRange(value, compareValue, mode, name = value) {
			this.valuePairs.push({
				name: name, value: value, mode: mode, compareValue: compareValue
			});
			return this;
		}

		/**
		 * Add a TextBox where the user can input a value.
		 *
		 * By default the TextBox will only accept inputs of the same Type as previous options(if any)
		 * @param {object} [params]
		 * @param {string} [params.unit] Will be displayed next to the TextBox
		 * @param {boolean} [params.large=false] If set to true the TextBox will render as 'full-width'
		 * @param {boolean} [params.forceString=false] Force the TextBox to accept String inputs
		 *
		 * Note: If forceString is true any value entered into the TextBox will be interpreted as a String.
		 * @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; // technical impossible according to documentation, but it works and the solution below breaks things
			// let value = this.valuePairs.pop();
			// value.descAppend = description;
			// this.valuePairs.push();
			return this;
		}

		/**
		 * @param {function(any):void} callback gets executed on every click of this button. Doesn't get executed for other buttons. Selected value is given as argument.
		 */
		addCallback(callback) {
			let value = this.valuePairs.pop();
			value.callback = callback;
			this.valuePairs.push(value);
			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() {
			let value = this.valuePairs.pop();
			value.on = true;
			this.valuePairs.push(value);
			return this;
		}

		/**
		 * Mark option as off to style differently.
		 * @returns {OptionButtonRow}
		 */
		off() {
			let value = this.valuePairs.pop();
			value.off = true;
			this.valuePairs.push(value);
			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;
		}

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

	class SliderRow extends Row {
		/**
		 * Warning: Due to limitations to sliders this updates values in real time WITHOUT calling refresh.
		 * The Caller has to implement their own submit functionality if required
		 *
		 * @param {string} description
		 * @param {number} min Minimum allowed value
		 * @param {number} max Maximum allowed value
		 * @param {string} property
		 * @param {object} object
		 */
		constructor(description, min, max, property, object) {
			super();
			this.description = description;
			this.min = min;
			this.max = max;
			this.step = -1;
			this.property = property;
			this.object = object;

			this.textbox = false;
			/**
			 * @type {null|Array<string>}
			 */
			this.endLabels = null;
		}

		/**
		 * Set the step size (default = unlimited)
		 * @param {number} step
		 */
		setStep(step) {
			this.step = step;
			return this;
		}

		/**
		 * Show a textbox to allow manual inputs.
		 */
		addTextBox() {
			this.textbox = true;
			return this;
		}

		/**
		 * Add Labels to the ends of the slider
		 * @param {string} left
		 * @param {string} right
		 */
		addEndLabels(left, right) {
			this.endLabels = [left, right];
			return this;
		}

		render(container, _) {
			/* left side */
			App.UI.DOM.appendNewElement("div", container, this.description, ["description"]);

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

			if (this.endLabels !== null) {
				App.UI.DOM.appendNewElement("span", div, this.endLabels[0]);
			}

			// Create slider first, so textbox can reference it
			const slider = document.createElement("input");

			let textbox = null;
			if (this.textbox) {
				// Create textbox, using reference to slider
				textbox = App.UI.DOM.makeTextBox(this.object[this.property], input => {
					// Run input through slider to ensure it's a valid value.
					slider.value = String(input);
					this.object[this.property] = parseFloat(slider.value);
					textbox.value = this.object[this.property];
				}, true);
				textbox.classList.add("number");
			}

			// Finish of slider, using reference to textbox
			slider.type = "range";
			slider.step = (this.step <= 0) ? "any" : `${this.step}`;
			slider.min = String(this.min);
			slider.max = String(this.max);
			slider.value = String(this.object[this.property]);

			slider.oninput = () => {
				this.object[this.property] = parseFloat(slider.value);
				if (textbox !== null) {
					textbox.value = this.object[this.property];
				}
			};

			// Now append everything
			div.append(slider);

			if (this.endLabels !== null) {
				App.UI.DOM.appendNewElement("span", div, this.endLabels[1]);
			}

			if (textbox !== null) {
				div.append(textbox);
			}

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

		/**
		 * @param {string} description
		 * @param {number} min Minimum allowed value
		 * @param {number} max Maximum allowed value
		 * @param {string} property
		 * @param {object} [object=V]
		 */
		addSlider(description, min, max, property, object = V) {
			return this._addRow(new SliderRow(description, min, max, property, object));
		}

		/**
		 * @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.create(caption);
			}
			$(Dialog.body()).empty().append(dialogContent);
			Dialog.open();
		}
	};
};


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

const A1111InstallHTML = `

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.

`;

// cSpell: ignore loras
const loraHTML = `

Why are LoRAs 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.

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.

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.

Download and copy any LoRAs that you want to use into your 'stable-diffusion-webui/models/Lora/' (A1111) or 'ComfyUI/models/loras/' (ComfyUI) folder.
`;

const comfyHTML = `

What is ComfyUI?

ComfyUI is a Stable Diffusion UI that has a some performance benefits over A1111's webui and allows for more complicated pipelines.

Download ComfyUI

Download the portable version (Windows) or follow the installation instructions (Linux/Mac (M1 or M2))

Edit run_nvidia_gpu.bat or run_cpu.bat (Windows) to include  --port=7860  --enable-cors-header * at the end of the first line. If you are on Linux or Mac run python main.py --port=7860  --enable-cors-header * instead

Use  --enable-cors-header null instead of  --enable-cors-header * if you are using a Chromium-based host (Chrome, Edge, FCHost, or similar) and you are running Free Cities from a local HTML file rather than a webserver.

Place your models in "ComfyUI/models/checkpoints"

Install ComfyUI extensions

ComfyUi Manager - For installing extensions + other QoL features

Impact-Pack - For Face Detailing + other useful nodes

rgthree-comfy - A collection of nodes and improvements

Dynamic CFG - For dynamic thresholding of CFG scales

Some extensions useful for building custom workflows:

efficiency-nodes - A collection of ComfyUI custom nodes to help streamline workflows and reduce total node count.

WAS Node Suite - A node suite for ComfyUI with many new nodes, such as image processing, text processing, and more.

Restart ComfyUI

Make sure ComfyUI is started

You should see "Starting server" and the API URL.

Setup FC for ComfyUI integration

Switch AI User Interface from A1111 to ComfyUI.

Select your checkpoint model, sampling method, and scheduling method.

Confirm successful generation

If the preview image is not being generated and the preview is:

A spinner going forever; It's probably an issue with connecting to ComfyUI. Check API URL and ComfyUI setup.

A blank grey square; It's probably a prompt related issue, check the ComfyUI terminal, the issue should be mentioned there.

`;

const comfyCustomWorkflowHTML = `

Instructions to run custom workflows in FC with a locally hosted copy

1. Host FC locally:

Run the compile.bat (Windows) or compile.sh (Linux/Mac) script inside the FC directory.

Follow the instructions for installing the required dependencies and wait for the compiler to compile FC.

Run the serve.bat (Windows) or serve.sh (Linux/Mac) script to start the local server.

Open your web browser and go to the local server url (usually http://localhost:6969/bin/FC_pregmod.html).

Repeat steps 1 & 2 each time you update FC

Repeat steps 3 & 4 each time you want to play FC

2. Prepare your Workflow:

Identify the node that holds your seed image and add the word "Seed" (case-insensitive) to its title.

Find the nodes containing your positive and negative prompts, and add "Positive Prompt" and "Negative Prompt" (respectively, case-insensitive) to their titles. If both prompts are in the same node, convert one of the prompt widgets to an input and use an extension like WAS Node Suite.

Ensure that the final generated image is connected to a SaveImageWebsocket node.

3. Save workflow in API format:

Enable Developer Mode in ComfyUI's settings.

Click the "Save in API Format" button.

Create a folder named "workflows" inside the "bin/resources" directory.

Save your workflow file in the "workflows" folder.

In FC, switch the AI Prebuilt Workflow option to Custom Workflow and enter the filename of your saved workflow file into the workflow option field.

By following these steps, you should be able to run custom workflows in your locally hosted instance of FC.

`;

/**
 * Generates a link which shows the A1111 installation guide.
 * @param {string} [text] link text to use
 * @returns {HTMLElement} link
 */
App.UI.A1111InstallationGuide = function(text) {
	return App.UI.DOM.link(text ? text : "A1111 WebUI Installation Guide", () => {
		Dialog.create("A1111 WebUI Installation Guide");
		const content = document.createElement("div").innerHTML = A1111InstallHTML;
		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 Installation Guide", () => {
		Dialog.create("Stable Diffusion LoRA Installation Guide");
		const content = document.createElement("div").innerHTML = loraHTML;
		Dialog.append(content);
		Dialog.open();
	});
};

/**
 * Generates a link with the ComfyUI installation guide.
 * @param {string} [text] link text to use
 * @returns {HTMLElement} link
 */
App.UI.comfyUIInstallationGuide = function(text) {
	return App.UI.DOM.link(text ? text : "ComfyUI Installation Guide", () => {
		Dialog.create("ComfyUI Installation Guide");
		const content = document.createElement("div").innerHTML = comfyHTML;
		Dialog.append(content);
		Dialog.open();
	});
};

/**
 * Generates a link with the ComfyUI custom workflow guide.
 * @param {string} [text] link text to use
 * @returns {HTMLElement} link
 */
App.UI.comfyUICustomWorkflowGuide = function(text) {
	return App.UI.DOM.link(text ? text : "ComfyUI Custom Workflow Guide", () => {
		Dialog.create("ComfyUI Custom Workflow Guide");
		const content = document.createElement("div").innerHTML = comfyCustomWorkflowHTML;
		Dialog.append(content);
		Dialog.open();
	});
};

repo_name: Nithhogg/fc

license: mit


code:
/** @returns {DocumentFragment} */
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([getSlaves().randomValue().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 = getPassagesWithTag("jump-from-safe").map(passage => passage.title);
	const jumpTo = getPassagesWithTag("jump-to-safe").map(passage => passage.title);
	const hidden = getPassagesWithTag("jump-hidden").map(passage => passage.title);
	const noHistory = getPassagesWithTag("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": () => !App.Entity.facilities.servantsQuarters.established,
		"Spa": () => !V.spa,
		"The Black Market": () => V.rep < 10000,
	});

	// 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": () => V.sideBarOptions.notifyFS === 1 && (FutureSocieties.availCredits() > 0 || V.FSReminder),
		"Incubator": () => V.sideBarOptions.notifyIncubator === 1 && V.incubator.capacity > 0 && App.Facilities.Incubator.readySlaves() > 0,
		"Manage Corporation": () => V.sideBarOptions.notifyCorp === 1 && 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;
		UI.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 = getPassagesWithTag("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.makeElement("span", App.UI.DOM.link("Reminders", () => App.Reminders.dialog()), null);
		if (V.reminders.find(r => r.week <= V.week)) {
			reminders.append(" ", App.UI.DOM.makeElement("span", "\uf06a", ["icons", "noteworthy"]));
		}
		fragment.append(
			App.UI.DOM.generateLinksStrip([
				reminders,
				App.UI.DOM.link("Log", App.PersonalLog.dialog)
			])
		);
	} 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(` | ${slaveCount()}`);
		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, `${slaveCount()}`);
	}

	/**
	 * @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-expect-error In starting girls we know that there is always an active slave
		let cost = startingSlaveCost(V.temp.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.temp.limitedCheatStart || V.cheatMode) {
			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.Utils.setNonexistentProperties(V, App.Data.defaultGameStateVariables);
	// @ts-expect-error V.genePoolDefaults is read only, but we need to set it if it doesn't exist
	V.genePoolDefaults = V.genePoolDefaults || new App.Entity.GenePoolRecord();

	/* These variables must be created AND set to default values, NG+ or not */
	App.Utils.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.pedoMode === 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.temp.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 getSlaves().values()) {
		if (slave.balls > 0 && slave.pubertyXY === 1 && canBreed(getSlave(V.temp.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.temp.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 (tankSlaveCount() > 0 && App.Entity.facilities.incubator.upgrade('reproduction') === 1) {
		r = [];
		any = false;
		for (const tank of getTankSlaves().values()) {
			if (tank.balls > 0 && tank.dick > 0 && tank.incubatorSettings.reproduction === 2 && canBreed(getSlave(V.temp.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.temp.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.temp.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.temp.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 {FC.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;
};