Skip to content
Snippets Groups Projects
Forked from pregmodfan / fc-pregmod
13402 commits behind the upstream repository.
slaveListing.js 39.75 KiB
/**
 * @file Functions for rendering lists of slave summaries for various purposes. This includes
 * lists for the penthouse/facilities, selecting a slaves, facility leaders.
 *
 * For documentation see devNotes/slaveListing.md
 */

App.UI.SlaveList = {};

/**
 * @callback slaveTextGenerator
 * @param {App.Entity.SlaveState} slave
 * @returns {string}
 */

/**
 * @callback slaveToElement
 * @param {App.Entity.SlaveState} slave
 * @returns {HTMLElement|DocumentFragment}
 */

App.UI.SlaveList.render = function() {
	const facilityPassages = new Set(
		["Main", "Head Girl Suite", "Spa", "Brothel", "Club", "Arcade", "Clinic", "Schoolroom", "Dairy", "Farmyard", "Servants' Quarters", "Master Suite", "Cellblock"]);

	/** @type {string} */
	let passageName;

	// potentially can be a problem if played long enough to reach Number.MAX_SAFE_INTEGER
	let listID = Number.MIN_SAFE_INTEGER;

	/** @type {App.Art.SlaveArtBatch} */
	let batchRenderer = null;

	return listDOM;

	/**
	 * @param {number[]} IDs
	 * @param {Array.<{id: number, rejects: string[]}>} rejectedSlaves
	 * @param {slaveToElement} interactionLink
	 * @param {slaveToElement} [postNote]
	 * @returns {DocumentFragment}
	 */
	function listDOM(IDs, rejectedSlaves, interactionLink, postNote) {
		passageName = passage();

		let res = document.createDocumentFragment();

		if ((V.seeImages === 1) && (V.seeSummaryImages === 1)) {
			batchRenderer = new App.Art.SlaveArtBatch(IDs, 1);
			res.appendChild(batchRenderer.writePreamble());
		}

		if (V.useSlaveListInPageJSNavigation === 1) {
			res.appendChild(createQuickList(IDs));
		}

		const fcs = App.Entity.facilities;
		const penthouse = fcs.penthouse;

		let anyFacilityExists = false;

		for (const f of Object.values(fcs)) {
			if (f !== penthouse && f.established) {
				anyFacilityExists = true;
				break;
			}
		}

		let showTransfers = false;
		if (anyFacilityExists) {
			if (facilityPassages.has(passageName)) {
				V.returnTo = passageName;
				showTransfers = true;
			}
		}

		for (const _sid of IDs) {
			let ss = renderSlave(_sid, interactionLink, showTransfers, postNote);
			let slaveDiv = document.createElement("div");
			slaveDiv.id = `slave-${_sid}`;
			slaveDiv.classList.add("slaveSummary");
			if (V.slavePanelStyle === 2) {
				slaveDiv.classList.add("card");
			}
			slaveDiv.appendChild(ss);
			res.appendChild(slaveDiv);
		}

		for (const rs of rejectedSlaves) {
			const slave = slaveStateById(rs.id);
			const rejects = rs.rejects;
			const slaveName = SlaveFullName(slave);
			let slaveDiv = document.createElement("div");
			slaveDiv.id = `slave-${slave.ID}`;
			slaveDiv.style.setProperty("clear", "both");
			if (rejects.length === 1) {
				slaveDiv.innerHTML = rejects[0];
			} else {
				slaveDiv.appendChild(document.createTextNode(`${slaveName}: `));
				let ul = document.createElement("ul");
				for (const rr of rejects) {
					const li = document.createElement("li");
					li.innerHTML = rr;
					ul.appendChild(li);
				}
				slaveDiv.appendChild(ul);
			}
			res.appendChild(slaveDiv);
		}

		batchRenderer = null; // release reference

		return res;
	}

	/**
	 * @param {number} id
	 * @param {slaveToElement} interactionLink
	 * @param {boolean} showTransfers
	 * @param {slaveToElement} [postNote]
	 * @returns {DocumentFragment}
	 */
	function renderSlave(id, interactionLink, showTransfers, postNote) {
		let res = document.createDocumentFragment();
		if (V.slavePanelStyle === 0) {
			res.appendChild(document.createElement("br"));
		} else if (V.slavePanelStyle === 1) {
			const hr = document.createElement("hr");
			hr.style.margin = "0";
			res.appendChild(hr);
		}
		const slave = slaveStateById(id);

		if (batchRenderer) {
			let imgDiv = document.createElement("div");
			imgDiv.classList.add("imageRef");
			imgDiv.classList.add("smlImg");
			imgDiv.appendChild(batchRenderer.render(slave));
			res.appendChild(imgDiv);
		}
		// res.push(dividerAndImage(slave));
		res.appendChild(interactionLink(slave));

		SlaveStatClamp(slave);
		slave.trust = Math.trunc(slave.trust);
		slave.devotion = Math.trunc(slave.devotion);
		slave.health.condition = Math.trunc(slave.health.condition);
		slave.health.shortDamage = Math.trunc(slave.health.shortDamage);
		slave.health.longDamage = Math.trunc(slave.health.longDamage);
		slave.health.illness = Math.trunc(slave.health.illness);
		slave.health.tired = Math.trunc(slave.health.tired);
		slave.health.health = Math.trunc(slave.health.health);
		res.appendChild(document.createTextNode(' will '));
		const assignment = document.createElement("span");
		if ((slave.assignment === Job.REST) && (slave.health.condition >= -20)) {
			assignment.className = "freeAssignment";
			assignment.innerText = slave.assignment;
		} else if ((slave.assignment === Job.CONFINEMENT) && ((slave.devotion > 20) || ((slave.trust < -20) && (slave.devotion >= -20)) || ((slave.trust < -50) && (slave.devotion >= -50)))) {
			assignment.innerText = slave.assignment;
			if (slave.sentence > 0) {
				assignment.innerText += ` (${slave.sentence} weeks)`;
			}
		} else if (slave.choosesOwnAssignment === 1) {
			assignment.innerText = `choose ${getPronouns(slave).possessive} own job`;
		} else {
			assignment.innerText = slave.assignment;
			if (slave.assignment === Job.CLASSES) {
				const role = tutorForSlave(slave);
				if (role) {
					assignment.innerText += ` on being a ${role}`;
				}
			}
			if (slave.sentence > 0) {
				assignment.innerText += ` ${slave.sentence} weeks`;
			}
		}
		if (slave.assignment === Job.CLINIC) {
			let list = [];
			if (slave.health.condition <= 40) {
				list.push(`poor health`);
			}
			if (slave.health.shortDamage >= 10) {
				list.push(`injuries`);
			}
			if (S.Nurse) {
				if ((slave.chem > 15) && (V.clinicUpgradeFilters === 1)) {
					list.push(`unhealthy chemicals`);
				}
				if ((V.clinicInflateBelly > 0) && (slave.bellyImplant >= 0) && (slave.bellyImplant <= (V.arcologies[0].FSTransformationFetishistResearch ? 800000 : 130000))) {
					list.push(`implant filling`);
				}
				if ((slave.pregKnown === 1) && (V.clinicSpeedGestation > 0 || slave.pregControl === "speed up") && ((slave.pregAdaptation * 1000 < slave.bellyPreg || slave.preg > slave.pregData.normalBirth / 1.33))) {
					list.push(`observation of accelerated pregnancy`);
				} else if ((slave.pregKnown === 1) && (V.clinicSpeedGestation > 0 || slave.pregControl === "speed up")) {
					list.push(`safely hurrying pregnancy along`);
				} else if ((slave.pregAdaptation * 1000 < slave.bellyPreg || slave.preg > slave.pregData.normalBirth / 1.33)) {
					list.push(`observation during pregnancy`);
				}
			}
			if (list.length > 0) {
				assignment.innerText += ` for ${toSentence(list)}`;
			} else {
				assignment.innerText += ", preparing to check out";
			}
		} else if (slave.assignment === Job.SPA) {
			let list = [];
			if (slave.fetish === "mindbroken") {
				assignment.innerText += `, mindbroken`;
			} else {
				if ((slave.sexualFlaw !== "none") || (slave.behavioralFlaw !== "none")) {
					list.push(`overcoming flaws`);
				}
				if ((slave.trust < 60) || (slave.devotion < 60)) {
					list.push(`learning to accept life as a slave`);
				}
				if (slave.health.condition < 20) {
					list.push(`improving in health`);
				}
				if (list.length > 0) {
					assignment.innerText += `, ${toSentence(list)}`;
				} else {
					assignment.innerText += ", preparing to move out";
				}
			}
		} else if (slave.assignment === Job.SCHOOL) {
			let lessons = [];
			if (V.schoolroomRemodelBimbo === 1 && slave.intelligenceImplant > -15) {
				lessons.push("being a dumb bimbo");
			} else if (V.schoolroomRemodelBimbo === 0 && slave.intelligenceImplant < 30) {
				lessons.push("general education");
			}
			if (!((slave.voice === 0) || (slave.accent <= 1) || ((V.schoolroomUpgradeLanguage === 0) && (slave.accent <= 2)))) {
				lessons.push("speech");
			}
			if (!((slave.skill.oral > 30) || ((V.schoolroomUpgradeSkills === 0) && (slave.skill.oral > 10)))) {
				lessons.push("oral");
			}
			if (!((slave.skill.whoring > 30) || ((V.schoolroomUpgradeSkills === 0) && (slave.skill.whoring > 10)))) {
				lessons.push("whoring");
			}
			if (!((slave.skill.entertainment > 30) || ((V.schoolroomUpgradeSkills === 0) && (slave.skill.entertainment > 10)))) {
				lessons.push("entertainment");
			}
			if (!((slave.skill.anal > 30) || ((V.schoolroomUpgradeSkills === 0) && (slave.skill.anal > 10)))) {
				lessons.push("anal");
			}
			if (!((slave.skill.vaginal > 30) || ((V.schoolroomUpgradeSkills === 0) && (slave.skill.vaginal > 10)) || (slave.vagina < 0))) {
				lessons.push("vaginal");
			}
			const role = tutorForSlave(slave);
			if (role && needsTutoring(slave)) {
				lessons.push(`being a good ${role}`);
			}
			if (lessons.length > 0) {
				assignment.innerText += `, practicing ${toSentence(lessons)}`;
			} else {
				assignment.innerText += ", studying for finals";
			}
		} else if (slave.assignment === Job.SUBORDINATE) {
			if (slave.subTarget === -1) {
				assignment.innerText += ", serving as your Stud";
			} else if (slave.subTarget !== 0) {
				const domSlave = getSlave(slave.subTarget);
				if (domSlave) {
					assignment.innerText += ", serving " + SlaveFullName(domSlave) + " exclusively";
				} else {
					slave.subTarget = 0;
				}
			}
		} else if (slave.assignment === Job.AGENT) {
			const arc = V.arcologies.find((a) => a.leaderID === slave.ID);
			if (arc) {
				assignment.innerText += `, leading `;
				if (passageName === "Neighbor Interact") {
					assignment.appendChild(App.UI.DOM.makeElement("span", arc.name, "name"));
				} else {
					assignment.appendChild(App.UI.DOM.passageLink(arc.name, "Neighbor Interact"));
				}
			}
		} else if (slave.assignment === Job.AGENTPARTNER) {
			const arc = V.arcologies.find((a) => a.leaderID === slave.relationshipTarget);
			if (arc) {
				assignment.innerText += ` in `;
				if (passageName === "Neighbor Interact") {
					assignment.appendChild(App.UI.DOM.makeElement("span", arc.name, "name"));
				} else {
					assignment.appendChild(App.UI.DOM.passageLink(arc.name, "Neighbor Interact"));
				}
			}
		}
		assignment.appendChild(document.createTextNode('.'));
		res.appendChild(assignment);
		if (V.assignmentRecords[slave.ID]) {
			res.appendChild(document.createTextNode(` Last assigned to ${V.assignmentRecords[slave.ID]}.`));
		}
		res.appendChild(document.createTextNode(' '));

		if ((V.displayAssignments === 1) && (passageName === "Main") && (slave.ID !== V.HeadGirlID) && (slave.ID !== V.RecruiterID) && (slave.ID !== V.BodyguardID)) {
			res.appendChild(App.UI.jobLinks.assignmentsFragment(slave.ID, "Main", (slave, assignment) => {
				App.UI.SlaveList.ScrollPosition.record();
				assignJob(slave, assignment);
			}));
		}
		if (showTransfers) {
			res.appendChild(document.createElement("br"));
			res.appendChild(document.createTextNode('Transfer to: '));
			res.appendChild(App.UI.jobLinks.transfersFragment(slave.ID, (slave, assignment) => {
				App.UI.SlaveList.ScrollPosition.record();
				assignJob(slave, assignment);
			}));
		}

		res.appendChild(App.UI.SlaveSummary.render(slave));

		if (postNote) {
			const pn = postNote(slave);
			if (pn) {
				let r = document.createElement("p");
				r.classList.add("si");
				r.appendChild(pn);
				res.appendChild(r);
			}
		}

		return res;
	}

	/**
	 * @param {number[]} IDs
	 * @returns {DocumentFragment}
	 */
	function createQuickList(IDs) {
		/**
		 *
		 * @param {Node} container
		 * @param {string} tagName
		 * @param {string} [content]
		 * @param {string|string[]} [classNames]
		 * @param {Object.<string, any>} [attributes]
		 * @returns {HTMLElement}
		 */
		function makeElement(container, tagName, content, classNames, attributes) {
			let res = document.createElement(tagName);
			container.appendChild(res);
			if (content) {
				res.textContent = content;
			}
			if (Array.isArray(classNames)) {
				for (const c of classNames) {
					res.classList.add(c);
				}
			} else if (classNames !== undefined) {
				res.classList.add(classNames);
			}

			if (attributes) {
				for (const [k, v] of Object.entries(attributes)) {
					res.setAttribute(k, v);
				}
			}
			return res;
		}

		const res = document.createDocumentFragment();

		/* Useful for finding weird combinations — usages of this passage that don't yet generate the quick index.
		*	<<print 'pass/count/indexed/flag::[' + passageName + '/' + _Count + '/' + _indexed + '/' + V.SlaveSummaryFiler + ']'>>
		*/

		if (IDs.length > 1 && passageName === "Main") {
			const _buttons = [];
			let _offset = -50;
			if (/Select/i.test(passageName)) {
				_offset = -25;
			}
			res.appendChild(document.createElement("br"));
			/*
			 * we want <button data-quick-index="<<= listID>>">...
			 */
			const quickIndexBtn = document.createElement("button");
			res.appendChild(quickIndexBtn);
			quickIndexBtn.id = `quick-list-toggle${listID}`;
			quickIndexBtn.setAttribute('data-quick-index', listID.toString());
			quickIndexBtn.onclick = function(ev) {
				let which = /** @type {HTMLElement} */ (ev.target).attributes["data-quick-index"].value;
				let quick = $("div#list_index" + which);
				quick.toggleClass("ql-hidden");
			};
			/*
			 * we want <div id="list_index3" class=" hidden">...
			 */
			const listIndex = makeElement(res, "div", undefined, "ql-hidden");
			listIndex.id = `list_index${listID}`;

			for (const sID of IDs) {
				const _IndexSlave = slaveStateById(sID);
				const _indexSlaveName = SlaveFullName(_IndexSlave);
				const _devotionClass = getSlaveDevotionClass(_IndexSlave);
				const _trustClass = getSlaveTrustClass(_IndexSlave);
				_buttons.push({
					"data-name": _indexSlaveName,
					"data-scroll-to": `#slave-${_IndexSlave.ID}`,
					"data-scroll-offset": _offset,
					"data-devotion": _IndexSlave.devotion,
					"data-trust": _IndexSlave.trust,
					"class": `${_devotionClass} ${_trustClass}`
				});
			}
			if (_buttons.length > 0) {
				V.sortQuickList = V.sortQuickList || 'Devotion';
				makeElement(listIndex, "em", "Sorting: ");
				const qlSort = makeElement(listIndex, "span", V.sortQuickList, "strong");
				qlSort.id = "qlSort";
				listIndex.appendChild(document.createTextNode(". "));
				const linkSortByDevotion = makeElement(listIndex, "a", "Sort by Devotion");
				linkSortByDevotion.onclick = (ev) => {
					ev.preventDefault();
					V.sortQuickList = "Devotion";
					$("#qlSort").text(V.sortQuickList);
					$("#qlWrapper").removeClass("trust").addClass("devotion");
					sortButtonsByDevotion();
				};
				const linkSortByTrust = makeElement(listIndex, "a", "Sort by Trust");
				linkSortByTrust.onclick = (ev) => {
					ev.preventDefault();
					V.sortQuickList = "Trust";
					$("#qlSort").text(V.sortQuickList);
					$("#qlWrapper").removeClass("devotion").addClass("trust");
					sortButtonsByTrust();
				};
				makeElement(listIndex, "br");
				const qlWrapper = makeElement(listIndex, "div", undefined, ["quick-list", "devotion"]);
				qlWrapper.id = "qlWrapper";
				for (const _button of _buttons) {
					const btn = makeElement(listIndex, 'button', _button['data-name'], undefined, _button);
					btn.onclick = App.UI.quickBtnScrollToHandler;
				}
			}
		}
		return res;
	}
}();

App.UI.SlaveList.Decoration = {};
/**
 * returns "HG", "BG", "PA", and "RC" prefixes
 * @param {App.Entity.SlaveState} slave
 * @returns {HTMLElement}
 */
App.UI.SlaveList.Decoration.penthousePositions = function(slave) {
	const fcs = App.Entity.facilities;
	if (fcs.headGirlSuite.manager.isEmployed(slave)) {
		return App.UI.DOM.makeElement("span", 'HG', ['lightcoral', 'strong']);
	}
	if (fcs.penthouse.manager.isEmployed(slave)) {
		return App.UI.DOM.makeElement("span", 'RC', ['lightcoral', 'strong']);
	}
	if (fcs.armory.manager.isEmployed(slave)) {
		return App.UI.DOM.makeElement("span", 'BG', ['lightcoral', 'strong']);
	}
	if (Array.isArray(V.personalAttention) && V.personalAttention.some(s => s.ID === slave.ID)) {
		return App.UI.DOM.makeElement("span", 'PA', ['lightcoral', 'strong']);
	}
	return null;
};

App.UI.SlaveList.ScrollPosition = (function() {
	let lastPassage = null;
	let position = 0;

	return {
		reset: function() {
			lastPassage = null;
			position = 0;
		},

		record: function() {
			lastPassage = passage();
			position = window.pageYOffset;
		},

		restore: function() {
			$(document).one(':passageend', () => {
				if (lastPassage === passage()) {
					window.scrollTo(0, position);
				}
				this.reset();
			});
		}
	};
})();

App.UI.SlaveList.SlaveInteract = {};

/**
 * @param {App.Entity.SlaveState} slave
 * @param {string} [text] print this text instead of slave name
 * @returns {DocumentFragment|HTMLElement}
 */
App.UI.SlaveList.SlaveInteract.stdInteract = function(slave, text) {
	const link = App.UI.DOM.passageLink(text ? text : SlaveFullName(slave), "Slave Interact", () => {
		App.UI.SlaveList.ScrollPosition.record();
		V.AS = slave.ID;
	});
	if (V.favorites.includes(slave.ID)) {
		return App.UI.DOM.combineNodes(
			App.UI.DOM.makeElement("span", String.fromCharCode(0xe800), ["icons", "favorite"]),
			" ", link);
	}
	return link;
};

/**
 * @param {App.Entity.SlaveState} slave
 * @returns {DocumentFragment|HTMLElement}
 */
App.UI.SlaveList.SlaveInteract.penthouseInteract = function(slave) {
	let decoration = App.UI.SlaveList.Decoration.penthousePositions(slave);
	let stdLink = App.UI.SlaveList.SlaveInteract.stdInteract(slave);
	if (decoration) {
		let fr = document.createDocumentFragment();
		fr.appendChild(decoration);
		fr.appendChild(document.createTextNode(' '));
		fr.appendChild(stdLink);
		return fr;
	}
	return stdLink;
};

/**
 * Adds/removes a slave with the given id to/from the personal attention array
 * @param {number} id slave id
 */
App.UI.selectSlaveForPersonalAttention = function(id) {
	if (!Array.isArray(V.personalAttention)) { // first PA target
		V.personalAttention = [{
			ID: id,
			trainingRegimen: "undecided"
		}];
	} else {
		const _pai = V.personalAttention.findIndex(s => s.ID === id);
		if (_pai === -1) { // not already a PA target; add
			V.personalAttention.push({
				ID: id,
				trainingRegimen: "undecided"
			});
		} else { // already a PA target; remove
			V.personalAttention.deleteAt(_pai);
			if (V.personalAttention.length === 0) {
				V.personalAttention = "sex";
			}
		}
	}
	SugarCube.Engine.play("Personal Attention Select");
};

/**
 * @param {string} passage
 * @returns {HTMLElement}
 */
App.UI.SlaveList.sortingLinks = function(passage) {
	const outerDiv = document.createElement("div");
	const textify = string => capFirstChar(string.replace(/([A-Z])/g, " $1"));

	let innerDiv = App.UI.DOM.makeElement("div", "Sort by: ", "indent");
	let order = ["devotion", "name", "assignment", "seniority", "actualAge", "visualAge", "physicalAge", "weeklyIncome", "health", "weight", "muscles", "sexDrive", "pregnancy"]
		.map(so => V.sortSlavesBy !== so ?
			App.UI.DOM.passageLink(textify(so), passage, () => { V.sortSlavesBy = so; }) : textify(so));
	innerDiv.append(App.UI.DOM.arrayToList(order, " | ", " | "));
	outerDiv.append(innerDiv);

	innerDiv = App.UI.DOM.makeElement("div", "Sort direction: ", "indent");
	order = ["descending", "ascending"].map(so => V.sortSlavesOrder !== so ?
		App.UI.DOM.passageLink(textify(so), passage, () => { V.sortSlavesOrder = so; }) : textify(so));
	innerDiv.append(App.UI.DOM.arrayToList(order, " | ", " | "));
	outerDiv.append(innerDiv);

	return outerDiv;
};

/**
 * Standard tabs for a facility with a single job (SJ)
 * @param {App.Entity.Facilities.Facility} facility
 * @param {string} [facilityPassage]
 * @param {boolean} [showTransfersTab=false]
 * @param {{assign: string, remove: string, transfer: (string| undefined)}} [tabCaptions]
 * @returns {DocumentFragment}
 */
App.UI.SlaveList.listSJFacilitySlaves = function(facility, facilityPassage, showTransfersTab = false, tabCaptions = undefined) {
	const job = facility.job();

	facilityPassage = facilityPassage || passage();
	tabCaptions = tabCaptions || {
		assign: 'Assign a slave',
		remove: 'Remove a slave',
		transfer: 'Transfer from Facility'
	};
	const frag = document.createDocumentFragment();
	if (V.sortSlavesMain) {
		frag.append(this.sortingLinks(facilityPassage));
	}
	const tabBar = App.UI.DOM.appendNewElement("div", frag, '', "tab-bar");
	tabBar.append(
		App.UI.tabBar.tabButton('assign', tabCaptions.assign),
		App.UI.tabBar.tabButton('remove', tabCaptions.remove),
		(showTransfersTab ? App.UI.tabBar.tabButton('transfer', tabCaptions.transfer) : '')
	);

	const facilitySlaves = [...job.employeesIDs()];
	if (facilitySlaves.length > 0) {
		SlaveSort.IDs(facilitySlaves);
		frag.append(App.UI.tabBar.makeTab("remove", App.UI.SlaveList.render(facilitySlaves, [],
			App.UI.SlaveList.SlaveInteract.stdInteract,
			(slave) => App.UI.DOM.link(`Retrieve ${getPronouns(slave).object} from ${facility.name}`, () => removeJob(slave, job.desc.assignment), [], facilityPassage)
		)));
	} else {
		frag.append(App.UI.tabBar.makeTab("remove", App.UI.DOM.makeElement("em", `${capFirstChar(facility.name)} is empty for the moment`)));
	}

	/**
	 * @param {number[]} slaveIDs
	 * @returns {DocumentFragment}
	 */
	function assignableTabContent(slaveIDs) {
		SlaveSort.IDs(slaveIDs);
		let rejectedSlaves = [];
		let passedSlaves = [];
		slaveIDs.forEach((id) => {
			const rejects = facility.canHostSlave(slaveStateById(id));
			if (rejects.length > 0) {
				rejectedSlaves.push({id: id, rejects: rejects});
			} else {
				passedSlaves.push(id);
			}
		}, []);
		return App.UI.SlaveList.render(passedSlaves, rejectedSlaves,
			App.UI.SlaveList.SlaveInteract.stdInteract,
			(slave) => App.UI.DOM.link(`Send ${getPronouns(slave).object} to ${facility.name}`, () => { assignmentTransition(slave, job.desc.assignment, facilityPassage); }));
	}

	if (facility.hasFreeSpace) {
		const assignableSlaveIDs = job.desc.partTime ?
			V.slaves.map(slave => slave.ID) : // all slaves can work here
			[...App.Entity.facilities.penthouse.employeesIDs()]; // only slaves from the penthouse can be transferred here
		frag.append(App.UI.tabBar.makeTab("assign", assignableTabContent(assignableSlaveIDs)));
	} else {
		frag.append(App.UI.tabBar.makeTab("assign", App.UI.DOM.makeElement("strong", `${capFirstChar(facility.name)} is full and cannot hold any more slaves`)));
	}

	if (showTransfersTab) {
		if (facility.hasFreeSpace) {
			// slaves from other facilities can be transferred here
			const transferableIDs = V.slaves.reduce((acc, slave) => {
				if (!assignmentVisible(slave) && !facility.isHosted(slave)) {
					acc.push(slave.ID);
				}
				return acc;
			}, []);
			frag.append(App.UI.tabBar.makeTab("transfer", assignableTabContent(transferableIDs)));
		} else {
			frag.append(App.UI.tabBar.makeTab("transfer", App.UI.DOM.makeElement("strong", `${capFirstChar(facility.name)} is full and cannot hold any more slaves`)));
		}
	}
	App.UI.tabBar.handlePreSelectedTab();

	return frag;
};

/**
 * @param {string[]} classNames
 */
App.UI.SlaveList.makeNameDecorator = function(classNames) {
	return (slave) => {
		const r = document.createElement("span");
		for (const c of classNames) {
			r.classList.add(c);
		}
		r.textContent = SlaveFullName(slave);
		return r;
	};
};

/**
 * @returns {DocumentFragment}
 */
App.UI.SlaveList.listNGPSlaves = function() {
	const thisPassage = 'New Game Plus';

	const frag = document.createDocumentFragment();
	frag.append(this.sortingLinks(thisPassage));
	const tabBar = App.UI.DOM.appendNewElement("div", frag, '', "tab-bar");
	tabBar.append(
		App.UI.tabBar.tabButton('assign', "Import a slave"),
		App.UI.tabBar.tabButton('remove', "Remove from import")
	);

	let imported = [];
	let nonImported = [];
	for (const slave of V.slaves) {
		// @ts-ignore: handle the legacy assignment string
		if (slave.assignment === "be imported") {
			slave.assignment = Job.IMPORTED;
		}
		if (slave.assignment === Job.IMPORTED) {
			imported.push(slave.ID);
		} else {
			nonImported.push(slave.ID);
		}
	}

	if (imported.length > 0) {
		SlaveSort.IDs(imported);
		frag.append(App.UI.tabBar.makeTab("remove", App.UI.SlaveList.render(imported, [],
			App.UI.SlaveList.makeNameDecorator(["emphasizedSlave", "pink"]),
			(s) => App.UI.DOM.passageLink('Remove from import list', thisPassage, () => removeJob(s, Job.IMPORTED))
		)));
	} else {
		frag.append(App.UI.tabBar.makeTab("remove", App.UI.DOM.makeElement('em', "No slaves will go with you to the new game")));
	}

	if (imported.length < V.slavesToImportMax) {
		SlaveSort.IDs(nonImported);
		frag.append(App.UI.tabBar.makeTab("assign", App.UI.SlaveList.render(nonImported, [],
			App.UI.SlaveList.makeNameDecorator(["emphasizedSlave", "pink"]),
			(s) => App.UI.DOM.passageLink('Add to import list', thisPassage, () => assignJob(s, Job.IMPORTED))
		)));
	} else {
		frag.append(App.UI.tabBar.makeTab("assign", App.UI.DOM.makeElement('strong', `Slave import limit reached`)));
	}

	App.UI.tabBar.handlePreSelectedTab();
	return frag;
};

/**
 * Renders facility manager summary or a note with a link to select one
 * @param {App.Entity.Facilities.Facility} facility
 * @param {string} [selectionPassage] passage name for manager selection. "${Manager} Select" if omitted
 * @returns {DocumentFragment}
 */
App.UI.SlaveList.displayManager = function(facility, selectionPassage) {
	const managerCapName = capFirstChar(facility.desc.manager.position);
	selectionPassage = selectionPassage || `${managerCapName} Select`;
	const manager = facility.manager.currentEmployee;
	if (manager) {
		return this.render([manager.ID], [],
			App.UI.SlaveList.SlaveInteract.stdInteract,
			() => App.UI.DOM.passageLink(`Change or remove ${managerCapName}`, selectionPassage));
	} else {
		const frag = document.createDocumentFragment();
		frag.append(`You do not have a slave serving as a ${managerCapName}. `, App.UI.DOM.passageLink(`Appoint one`, selectionPassage));
		return frag;
	}
};

/**
 * Displays standard facility page with manager and list of workers
 * @param {App.Entity.Facilities.Facility} facility
 * @param {boolean} [showTransfersPage]
 * @returns {DocumentFragment}
 */
App.UI.SlaveList.stdFacilityPage = function(facility, showTransfersPage) {
	const frag = this.displayManager(facility);
	frag.append(document.createElement('br')); // TODO: replace with margin on one of the divs?
	frag.append(this.listSJFacilitySlaves(facility, passage(), showTransfersPage));
	return frag;
};

App.UI.SlaveList.penthousePage = function() {
	const ph = App.Entity.facilities.penthouse;

	function overviewTabContent() {
		const fragment = document.createDocumentFragment();
		const A = V.arcologies[0];

		let slaveWrapper = document.createElement("div"); // first is a div so we have no space between slave and buttons
		if (V.HeadGirlID) {
			const HG = S.HeadGirl;
			slaveWrapper.append(App.UI.DOM.makeElement("span", SlaveFullName(HG), "slave-name"),
				" is serving as your Head Girl");
			if (A.FSEgyptianRevivalistLaw === 1) {
				slaveWrapper.append(" and Consort");
			}
			slaveWrapper.append(". ");
			const link = App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Manage Head Girl", "HG Select"), "major-link");
			link.id = "manageHG";
			slaveWrapper.append(link, " ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("HG Select"), "hotkey"));
			slaveWrapper.append(App.UI.SlaveList.render([HG.ID], [],
				App.UI.SlaveList.SlaveInteract.penthouseInteract));
		} else {
			if (V.slaves.length > 1) {
				slaveWrapper.append("You have ", App.UI.DOM.makeElement("span", "not", "warning"), " selected a Head Girl");
				if (A.FSEgyptianRevivalistLaw === 1) {
					slaveWrapper.append(" and Consort");
				}
				slaveWrapper.append(". ", App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Select One", "HG Select"), "major-link"),
					" ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("HG Select"), "hotkey"));
				slaveWrapper.id = "manageHG";
				if (V.slavePanelStyle === 2) {
					slaveWrapper.classList.add("slaveSummary", "card");
				}
			} else {
				slaveWrapper.append("You do not have enough slaves to keep a Head Girl");
				slaveWrapper.classList.add("note");
			}
		}
		fragment.append(slaveWrapper);

		slaveWrapper = document.createElement("p");
		if (V.RecruiterID) {
			/** @type {App.Entity.SlaveState} */
			const RC = S.Recruiter;
			const {he} = getPronouns(RC);
			slaveWrapper.append(App.UI.DOM.makeElement("span", SlaveFullName(RC), "slave-name"),
				" is working");
			if (V.recruiterTarget !== "other arcologies") {
				slaveWrapper.append(" to recruit girls");
			} else {
				slaveWrapper.append(" as a Sexual Ambassador");
				if (A.influenceTarget === -1) {
					slaveWrapper.append(", but ", App.UI.DOM.makeElement("span", `${he} has no target to influence.`, "warning"));
				} else {
					const targetName = V.arcologies.find(a => a.direction === A.influenceTarget).name;
					slaveWrapper.append(` to ${targetName}.`);
				}
			}
			slaveWrapper.append(". ");
			const link = App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Manage Recruiter", "Recruiter Select"), "major-link");
			link.id = "manageRecruiter";
			slaveWrapper.append(link, " ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("Recruiter Select"), "hotkey"));
			slaveWrapper.append(App.UI.SlaveList.render([RC.ID], [],
				App.UI.SlaveList.SlaveInteract.penthouseInteract));
		} else {
			slaveWrapper.append("You have ", App.UI.DOM.makeElement("span", "not", "warning"), " selected a Recruiter. ",
				App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Select one", "Recruiter Select"), "major-link"),
				" ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("Recruiter Select"), "hotkey"));
			slaveWrapper.id = "manageRecruiter";
			if (V.slavePanelStyle === 2) {
				slaveWrapper.classList.add("slaveSummary", "card");
			}
		}
		fragment.append(slaveWrapper);

		if (V.dojo) {
			slaveWrapper = document.createElement("p");
			const BG = S.Bodyguard;
			if (BG) {
				slaveWrapper.append(App.UI.DOM.makeElement("span", SlaveFullName(BG), "slave-name"),
					" is serving as your bodyguard. ");
				const link = App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Manage Bodyguard", "BG Select"), "major-link");
				link.id = "manageBG";
				slaveWrapper.append(link, " ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("BG Select"), "hotkey"));
				slaveWrapper.append(App.UI.SlaveList.render([BG.ID], [],
					App.UI.SlaveList.SlaveInteract.penthouseInteract));
				slaveWrapper.append(App.MainView.useGuard());
			} else {
				slaveWrapper.append("You have ", App.UI.DOM.makeElement("span", "not", "warning"), " selected a Bodyguard. ",
					App.UI.DOM.makeElement("span", App.UI.DOM.passageLink("Select one", "BG Select"), "major-link"),
					" ", App.UI.DOM.makeElement("span", App.UI.Hotkeys.hotkeys("BG Select"), "hotkey"));
				slaveWrapper.id = "manageBG";
				if (V.slavePanelStyle === 2) {
					slaveWrapper.classList.add("slaveSummary", "card");
				}
			}

			fragment.append(slaveWrapper);
		}
		return fragment;
	}

	/**
	 * @param {string} job
	 * @returns {{n: number, dom: DocumentFragment}}
	 */
	function _slavesForJob(job) {
		const employeesIDs = job === 'all' ? [...ph.employeesIDs()] : [...ph.job(job).employeesIDs()];
		if (employeesIDs.length === 0) {
			return {n: 0, dom: document.createDocumentFragment()};
		}

		SlaveSort.IDs(employeesIDs);
		return {
			n: employeesIDs.length,
			dom: App.UI.SlaveList.render(employeesIDs, [], App.UI.SlaveList.SlaveInteract.penthouseInteract,
				V.fucktoyInteractionsPosition === 1 && job === "fucktoy" ? App.MainView.useFucktoy : null)
		};
	}

	/**
	 * @typedef tabDesc
	 * @property {string} tabName
	 * @property {string} caption
	 * @property {Node} content
	 */

	/**
	 * @param {string} tabName
	 * @param {string} caption
	 * @param {Node} content
	 * @returns {tabDesc}
	 */
	function makeTabDesc(tabName, caption, content) {
		return {
			tabName: tabName,
			caption: caption,
			content: content
		};
	}
	/**
	 * Displays encyclopedia entries for occupations at the top of the tab, if enabled
	 * @returns {HTMLSpanElement}
	 */
	function encycTips(jn) {
		const span = document.createElement("span");
		span.classList.add("note");
		if (V.showTipsFromEncy) {
			switch (jn) {
				case "rest":
					span.append(App.Encyclopedia.Entries.rest());
					break;
				case "chooseOwn":
					break; /* no entry written for choose own */
				case "fucktoy":
					span.append(App.Encyclopedia.Entries.fucktoy());
					break;
				case "classes":
					span.append(App.Encyclopedia.Entries.attendingClasses());
					break;
				case "houseServant":
					span.append(App.Encyclopedia.Entries.servitude());
					break;
				case Job.WHORE:
					span.append(App.Encyclopedia.Entries.whoring());
					break;
				case "publicServant":
					span.append(App.Encyclopedia.Entries.publicService());
					break;
				case "subordinateSlave":
					span.append(App.Encyclopedia.Entries.sexualServitude());
					break;
				case "cow":
					span.append(App.Encyclopedia.Entries.milking());
					break;
				case "gloryhole":
					span.append(App.Encyclopedia.Entries.gloryHole());
					break;
				case "confinement":
					span.append(App.Encyclopedia.Entries.confinement());
					break;
				default:
					span.append(App.UI.DOM.makeElement("span", "missing tip for this tab", "error"));
					break;
			}
		}
		return span;
	}

	function allTab() {
		const penthouseSlavesIDs = [...ph.employeesIDs()];
		if (S.HeadGirl) {
			penthouseSlavesIDs.push(S.HeadGirl.ID);
		}
		if (S.Recruiter) {
			penthouseSlavesIDs.push(S.Recruiter.ID);
		}
		if (S.Bodyguard) {
			penthouseSlavesIDs.push(S.Bodyguard.ID);
		}
		SlaveSort.IDs(penthouseSlavesIDs);
		return makeTabDesc("all", `All${V.useSlaveSummaryTabs > 0 ? ` (${penthouseSlavesIDs.length})` : ""}`,
			App.UI.SlaveList.render(penthouseSlavesIDs, [], App.UI.SlaveList.SlaveInteract.penthouseInteract));
	}

	function favorites() {
		SlaveSort.IDs(V.favorites);
		return makeTabDesc("favorites", `Favorites${V.useSlaveSummaryTabs > 0 ? ` (${V.favorites.length})` : ""}`,
			App.UI.SlaveList.render(V.favorites, [], App.UI.SlaveList.SlaveInteract.penthouseInteract));
	}

	let fragment = document.createDocumentFragment();

	if (V.positionMainLinks >= 0) {
		fragment.append(App.UI.DOM.makeElement("div", App.UI.View.mainLinks(), "center"));
	}

	if (V.sortSlavesMain) {
		fragment.append(App.UI.SlaveList.sortingLinks("Main"));
	}

	/** @type {tabDesc[]} */
	let tabs = [];

	// Overview tab
	if (V.useSlaveSummaryOverviewTab) {
		tabs.push(makeTabDesc("overview", "Special Roles", overviewTabContent()));
	}

	if (V.useSlaveSummaryTabs === 0) {
		tabs.push(allTab());
	}

	if (V.favorites.length > 0 || V.useSlaveSummaryTabs === 0) {
		tabs.push(favorites());
	}

	// tabs for each assignment
	for (const jn of ph.jobsNames) {
		const slaves = _slavesForJob(jn);
		if (slaves.n > 0) {
			tabs.push(makeTabDesc(jn, `${ph.desc.jobs[jn].position}${V.useSlaveSummaryTabs > 0 ? ` (${slaves.n})` : ""}`, App.UI.DOM.combineNodes(encycTips(jn), slaves.dom)));
		} else if (V.useSlaveSummaryTabs === 0) {
			tabs.push(makeTabDesc(jn, ph.desc.jobs[jn].position, encycTips(jn)));
		}
	}

	if (V.useSlaveSummaryTabs > 0) {
		tabs.push(allTab());
	}

	const div = document.createElement("div");
	div.classList.add("tab-bar");
	if (V.useSlaveSummaryTabs === 0) {
		const links = [];
		for (const tab of tabs) {
			links.push(App.UI.tabBar.tabButton(tab.tabName, tab.caption, true));
		}
		div.append(App.UI.DOM.arrayToList(links, " | ", " | "));
	} else {
		for (const tab of tabs) {
			const button = App.UI.tabBar.tabButton(tab.tabName, tab.caption);
			if (V.useSlaveSummaryTabs === 2) {
				button.classList.add("card");
			}
			div.append(button);
		}
	}
	fragment.append(div);

	for (const tab of tabs) {
		const div = App.UI.tabBar.makeTab(tab.tabName, tab.content);
		if (V.useSlaveSummaryTabs === 0) {
			div.classList.add("noFade");
		} else if (V.useSlaveSummaryTabs === 2) {
			div.classList.add("card");
		}
		fragment.append(div);
	}
	if (V.positionMainLinks <= 0) {
		fragment.append(App.UI.DOM.makeElement("div", App.UI.View.mainLinks(), "center"));
	}

	App.UI.tabBar.handlePreSelectedTab();
	return fragment;
};

/**
 * @callback slaveFilterCallbackReasoned
 * @param {App.Entity.SlaveState} slave
 * @returns {string[]}
 */

/**
 * @callback slaveFilterCallbackSimple
 * @param {App.Entity.SlaveState} slave
 * @returns {boolean}
 */

App.UI.SlaveList.slaveSelectionList = function() {
	const selectionElementId = "slaveSelectionList";

	/**
	 * @property {slaveFilterCallbackReasoned|slaveFilterCallbackSimple} filter
	 * @property {slaveToElement} interactionLink
	 * @property {slaveTestCallback} [expCheck]
	 * @property {slaveToElement} [postNote]
	 */
	let options = null;

	return selection;

	/**
	 * @param {slaveFilterCallbackReasoned|slaveFilterCallbackSimple} filter
	 * @param {slaveToElement} interactionLink
	 * @param {slaveTestCallback} [experienceChecker]
	 * @param {slaveToElement} [postNote]
	 * @returns {HTMLElement}
	 */
	function selection(filter, interactionLink, experienceChecker, postNote) {
		if (experienceChecker === null) { experienceChecker = undefined; }
		options = {
			filter: filter,
			interactionLink: interactionLink,
			expCheck: experienceChecker,
			postNote: postNote
		};

		$(document).one(':passagedisplay', () => { _updateList('all'); });

		const div = document.createElement("div");
		div.append(_assignmentFilter(experienceChecker !== undefined));
		const selectionElement = App.UI.DOM.appendNewElement("div", div);
		selectionElement.id = selectionElementId;
		return div;
	}

	function _updateList(assignment) {
		const e = document.getElementById(selectionElementId);
		e.innerHTML = '';
		e.appendChild(_listSlaves(assignment));
	}

	/**
	 * Displays assignment filter links
	 * @param {boolean} includeExperienced
	 * @returns {HTMLElement}
	 */
	function _assignmentFilter(includeExperienced) {
		let filters = {
			all: "All"
		};
		let fNames = Object.keys(App.Entity.facilities);
		fNames.sort();
		for (const fn of fNames) {
			/** @type {App.Entity.Facilities.Facility} */
			const f = App.Entity.facilities[fn];
			if (f.established && f.hasEmployees) {
				filters[fn] = f.name;
			}
		}
		let links = [];
		for (const f in filters) {
			links.push(App.UI.DOM.link(filters[f], () => _updateList(f)));
		}
		if (includeExperienced) {
			links.push(App.UI.DOM.makeElement("span", App.UI.DOM.link('Experienced', () => _updateList('experienced')), "lime"));
		}

		return App.UI.DOM.generateLinksStrip(links);
	}

	/**
	 * @param {string} assignmentStr
	 * @returns {DocumentFragment}
	 */
	function _listSlaves(assignmentStr) {
		const slaves = V.slaves;
		/** @type {Array<number>} */
		let unfilteredIDs;
		switch (assignmentStr) {
			case 'all':
				unfilteredIDs = slaves.map(s => s.ID);
				break;
			case 'experienced':
				unfilteredIDs = slaves.reduce((acc, s) => {
					if (options.expCheck(s)) {
						acc.push(s.ID);
					}
					return acc;
				}, []);
				break;
			default:
				unfilteredIDs = [...App.Entity.facilities[assignmentStr].employeesIDs()]; // set to array
				break;
		}
		SlaveSort.IDs(unfilteredIDs);
		let passingIDs = [];
		let rejects = [];

		unfilteredIDs.forEach(id => {
			const fr = options.filter(slaveStateById(id));
			if (fr === true || (Array.isArray(fr) && fr.length === 0)) {
				passingIDs.push(id);
			} else {
				if (Array.isArray(fr)) { rejects.push({id: id, rejects: fr}); }
			}
		});

		// clamsi fragment to create a function which combines results of two optional tests
		// done this way to test for tests presence only once
		const listPostNote = options.expCheck ?
			(options.postNote ?
				s => options.expCheck(s) ? App.UI.DOM.combineNodes(
					App.UI.DOM.makeElement("span", "Has applicable career experience.", "lime"),
					document.createElement("br"),
					options.postNote(s)
				) : options.postNote(s) :
				s => options.expCheck(s) ? App.UI.DOM.makeElement("span", "Has applicable career experience.", "lime") : null) :
			options.postNote ?
				s => options.postNote(s) :
				() => null;

		return App.UI.SlaveList.render(passingIDs, rejects, options.interactionLink, listPostNote);
	}
}();

/**
 * @param {App.Entity.Facilities.Facility} facility
 * @param {string} passage go here after the new facility manager is selected
 * @returns {HTMLElement}
 */
App.UI.SlaveList.facilityManagerSelection = function(facility, passage) {
	return this.slaveSelectionList(slave => facility.manager.canEmploy(slave),
		(slave) => App.UI.DOM.passageLink(SlaveFullName(slave), passage,
			() => { assignJob(slave, facility.manager.desc.assignment); }),
		slave => facility.manager.slaveHasExperience(slave));
};