code:
Macro.add("textbox2", {
	handler: function() {
		if (this.args.length < 2) {
			const e = [];
			return this.args.length < 1 && e.push("variable name"), this.args.length < 2 && e.push("default value"), this.error(`no ${e.join(" or ")} specified`);
		}
		if (typeof this.args[0] !== "string") { return this.error("variable name argument is not a string"); }
		const t = this.args[0].trim();
		if (t[0] !== "$" && t[0] !== "_") { return this.error(`variable name "${this.args[0]}" is missing its sigil ($ or _)`); }
		Config.debug && this.debugView.modes({
			block: true
		});
		const r = Util.slugify(t);
		const a = this.args[1];
		const isNumber = typeof(a) === "number";
		const inputElement = document.createElement("input");
		let autofocus = false;
		let passage = void 0;
		let setargs = null;
		if (this.args.length > 3) {
			passage = this.args[2];
			autofocus = this.args[3] === "autofocus";
			if (!autofocus) {
				setargs = this.args[3];
			}
		} else if (this.args.length > 2) {
			if (this.args[2] === "autofocus") {
				autofocus = true;
			} else {
				passage = this.args[2];
			}
		}
		if (passage !== (void 0) && typeof(passage) === "object") {
			passage = passage.link;
		}
		if (!passage) {
			passage = State.passage;
		}

		function gotoPassage() {
			if (passage) {
				const currentScrollPosition = window.pageYOffset;
				const currentPassage = State.passage;
				if (setargs) {
					Scripting.evalTwineScript(setargs);
				}
				Engine.play(passage);
				if (currentPassage === passage) {
					Scripting.evalJavaScript(`window.scrollTo(0, ${currentScrollPosition});`);
				}
			}
		}

		function valueToNumberIfSame(v) {
			if (!isNumber) {
				return v;
			} // Do nothing
			try {
				return parseInt(v, 10);
			} catch (error) {
				return v;
			}
		}

		jQuery(inputElement).attr({
			id: `${this.name}-${r}`,
			name: `${this.name}-${r}`,
			// type: isNumber ? "number" : "text", /* TODO - hide spinner if we do this */
			tabindex: 0
		}).addClass(`macro-${this.name}`)
			.on("change", function() {
				State.setVar(t, valueToNumberIfSame(this.value));
			}).on("blur", function() {
				State.setVar(t, valueToNumberIfSame(this.value));
				// eslint-disable-next-line eqeqeq
				if (this.value != a) { // If the value has actually changed, reload the page. Note != and not !== because types might be different
					gotoPassage();
				}
			})
			.on("keypress", function(e) {
				e.which === 13 && (e.preventDefault(), State.setVar(t, valueToNumberIfSame(this.value)), gotoPassage());
			}).appendTo(this.output), State.setVar(t, a), inputElement.value = a, autofocus && (inputElement.setAttribute("autofocus", "autofocus"), postdisplay[`#autofocus:${inputElement.id}`] = function(e) {
			delete postdisplay[e], setTimeout(function() {
				return inputElement.focus();
			}, Engine.minDomActionDelay);
		});
	}
});


code:
/* Nicked off greyelf, works for replace textboxes */
globalThis.setReplaceTextboxMaxLength = function(storyVarName, maxLength) {
	const textboxId = `#textbox-${Util.slugify(storyVarName)}`;
	$(textboxId)
		.attr("maxlength", maxLength)
		.css({
			"min-width": "initial",
			"width": `${maxLength}em`,
			"padding": "3px 2px"
		});
};

/* Nicked off TheMadExile, works for non-replace textboxes */
globalThis.setTextboxMaxLength = function(storyVarName, maxLength) {
	const textboxId = `#textbox-${Util.slugify(storyVarName)}`;
	postdisplay[`${textboxId}-maxlength`] = function(taskName) {
		delete postdisplay[taskName];
		$(textboxId)
			.attr("maxlength", maxLength)
			.css({
				"min-width": "initial",
				"width": `${maxLength}em`,
				"padding": "3px 2px"
			});
	};
};

repo_name: Welptard/fc

license: mit


code:
App.Underperformers = {};

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

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

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

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

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

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

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


code:
/**
 * @callback passageLinkHandler
 * @returns {void}
 */
/**
 * Creates a HTML element with custom SugarCube attributes which works as a passage link
 *
 * The result works in the same way as the wiki markup in the SugarCube
 * @see https://www.motoslave.net/sugarcube/2/docs/#markup-html-attribute
 * @template {keyof PassageLinkMap} K
 * @param {string} linkText link text
 * @param {string} passage the passage name to link to
 * @param {passageLinkHandler} [handler] setter text (optional)
 * @param {string} [tooltip=''] tooltip text (optional)
 * @param {K} [elementType='a'] element type (optional) default is 'a'.
 * Could be any of 'a', 'audio', img', 'source', 'video'
 * @returns {PassageLinkMap[K]} element text
 *
 * @example
 * // equal to [[Go to town|Town]]
 * App.UI.passageLink("Go to town", "Town")
 */
App.UI.DOM.passageLink = function(linkText, passage, handler, tooltip = '', elementType = 'a') {
	let res = document.createElement(elementType);
	res.setAttribute("data-passage", passage);
	res.onclick = (ev) => {
		ev.preventDefault();
		if (handler) {
			handler();
		}
		Engine.play(passage);
	};

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

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

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

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

/**
 * @template {keyof HTMLElementTagNameMap} K
 * @param {K} tag - valid HTML tag
 * @param {string|Node} [content]
 * @param {string|Array<string>} [classNames]
 * @returns {HTMLElementTagNameMap[K]}
 */
App.UI.DOM.makeElement = function(tag, content, classNames) {
	const element = document.createElement(tag);
	if (classNames !== undefined) {
		if (Array.isArray(classNames)) {
			element.classList.add(...classNames);
		} else {
			element.classList.add(classNames);
		}
	}
	if (content) {
		element.append(content);
	}
	return element;
};

/**
 * @template {keyof HTMLElementTagNameMap} K
 * @param {K} tag - valid HTML tag
 * @param {ParentNode} parent
 * @param {string|Node} [content]
 * @param {string|Array<string>} [classNames]
 * @returns {HTMLElementTagNameMap[K]}
 */
App.UI.DOM.appendNewElement = function(tag, parent, content, classNames) {
	const element = App.UI.DOM.makeElement(tag, content, classNames);
	parent.append(element);
	return element;
};

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

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

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

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

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

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

	return input;
};

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

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

	return input;
};

/**
 * Concats an array of DOM nodes or strings into a human readable list.
 *
 * @param {Array<Node|string>} content
 * @param {string} [delimiter]
 * @param {string} [lastDelimiter]
 * @returns {DocumentFragment|string}
 */
App.UI.DOM.arrayToList = function(content, delimiter = ", ", lastDelimiter = " and ") {
	if (content.length === 0) {
		return "none";
	}
	if (content.length === 1) {
		return content[0];
	}
	const fragment = document.createDocumentFragment();
	const last = content.pop();
	for (let i = 0; i < content.length; i++) {
		fragment.append(content[i]);
		if (i < content.length - 1) {
			fragment.append(delimiter);
		}
	}
	content.push(last); // don't leave the array modified
	fragment.append(lastDelimiter, last);
	return fragment;
};

/**
 * @param {function (*):void} callback
 * @param {Object.<string, *>} options
 * @param {*|function (): *} currentValue
 * @param {string} [description]
 * @returns {HTMLSpanElement}
 */
App.UI.DOM.Widgets.optionSelector = function(callback, options, currentValue, description) {
	const res = document.createElement("span");
	res.classList.add("optionMacro");
	if (description) {
		App.UI.DOM.appendNewElement("span", res, description, "optionDescription");
	}
	const curVal = (currentValue instanceof Function) ? currentValue() : currentValue;
	const valueNode = App.UI.DOM.appendNewElement("span", res, null, "optionValue");
	for (const s in options) {
		const v = options[s];
		const choice = App.UI.DOM.appendNewElement("span", valueNode, null, "optionMacroOption");
		if (v === curVal) {
			choice.classList.add("optionMacroSelected");
			choice.innerText = s;
		} else {
			choice.append(App.UI.DOM.link(s, () => { callback(v); }));
		}
	}
	return res;
};

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

/**
 * Show a list of links (or disabled links) as a delimited strip
 * @param {Node[]} links
 * @returns {HTMLUListElement}
 */
App.UI.DOM.generateLinksStrip = function(links) {
	const strip = document.createElement('ul');
	strip.className = "choicesStrip";

	links.reduce((list, lnk) => {
		const li = document.createElement("li");
		li.appendChild(lnk);
		list.appendChild(li);
		return list;
	}, strip);
	return strip;
};

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

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

	return fragment;
};

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

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

App.UI.DOM.cashFormat = function(s) {
	const span = document.createElement("span");
	if (s < 0) { // Display red if the value is negative, unless invert is true
		span.classList.add("red");
	} else if (s !== 0) { // Yellow for positive
		span.classList.add("cash");
	}
	span.textContent = cashFormat(Math.trunc(s));
	return span;
};