Dead Frontier - Fast Services

Adds buttons to quickly search services at the Marketplace.

// ==UserScript==
// @name        Dead Frontier - Fast Services
// @namespace   Dead Frontier - Shrike00
// @match       *://fairview.deadfrontier.com/onlinezombiemmo/index.php?page=35
// @grant       none
// @version     0.0.7
// @author      Shrike00
// @description Adds buttons to quickly search services at the Marketplace.
// @require https://update.greasyfork.org/scripts/441829/1573182/Dead%20Frontier%20-%20API.js
// @license      MIT
// ==/UserScript==

// Changelog
// 0.0.7 - May 10, 2025
// - Change: Replaced multi-step armor repair with direct repair from equipment.
// 0.0.6 - April 18, 2025
// - Bugfix: Fixed for JS update to website.
// 0.0.5 - April 18, 2025
// - Bugfix: Fixed for JS update to website.
// 0.0.4 - December 16, 2024
// - Change: Updated with new API version for webCall change.
// 0.0.3 - March 12, 2024
// - Change: Added case for no engineers available.
// 0.0.2 - August 10, 2023
// - Change: Now confirms pop-up with enter key.

(function() {
	'use strict';

	// User Options
	// This only affects market search for services, not armour repair.
	const service_level = 75; // Valid values are 5, 15, 25, 35, 45, and 75.

	const max_repair_cost_no_confirm = 0;
	const max_repair_cost_popup = 25000;


	// Enumerations
	const ServiceType = {CHEF: "Chef", DOCTOR: "Doctor", ENGINEER: "Engineer"};
	const ServiceName =  {[ServiceType.CHEF]: "Cooking", [ServiceType.DOCTOR]: "Medical", [ServiceType.ENGINEER]: "Repair"};

	const UiUpdate = DeadFrontier.UiUpdate;

	// Helpers

	function dummy() {
		// Function that does nothing.
	}

	function nearest(n, pivot) {
		// Rounds to closest pivot value. nearest(154.29, 0.2) == 154.20, for example. Format using toFixed(n) if
		// displaying or converting to string.
		return Math.round(n/pivot)*pivot;
	}

	function waitForId(id, timeout) {
		const promise = new Promise(function(resolve, reject) {
			const start = performance.now();
			const check = setInterval(function() {
				const e = document.getElementById(id);
				if (e !== null) {
					clearInterval(check);
					resolve(e);
				}
				if (performance.now() - start >= timeout) {
					clearInterval(check);
					reject(e);
				}
			}, 100);
		});
		return promise;
	}

	function rawResponseToObject(response) {
		// Converts raw string response from trade_search.php to JS object<string, string>.
		const object = {};
		const pairs = response.split("&");
		for (let i = 0; i < pairs.length; i++) {
			const [key, value] = pairs[i].split("=");
			object[key] = value;
		}
		delete object[""]; // Removes undefined key, since response leads with an ampersand (&).
		return object;
	}

	function wearingArmour() {
		const uservars = userVars;
		const armour_type = uservars["DFSTATS_df_armourtype"];
		return armour_type !== "";
	}

	function canRemoveArmour() {
		const inventory_slot_available = findFirstEmptyGenericSlot("inv") !== false;
		return wearingArmour() && inventory_slot_available;
	}

	function swapItems(primary_data, secondary_data, callback) {
		// Data [slot_index, item_type, inventory_group]. If the slot being moved to does not contain an item, use the
		// empty string.
		const uservars = userVars;
		const payload = [primary_data, secondary_data];
		// Taken from updateInventory. Stripped down so it only works for equip/un-equip.
		const dataArr = {};
		dataArr["pagetime"] = uservars["pagetime"];
		dataArr["templateID"] = uservars["template_ID"];
		dataArr["sc"] = uservars["sc"];
		dataArr["creditsnum"] = uservars["DFSTATS_df_credits"];
		dataArr["buynum"] = "0";
		dataArr["renameto"] = "undefined`undefined";
		dataArr["expected_itemprice"] = "-1";
		dataArr["expected_itemtype2"] = payload[1][1];
		dataArr["expected_itemtype"] = payload[0][1];
		dataArr["itemnum2"] = payload[1][0];
		dataArr["itemnum"] = payload[0][0];
		dataArr["price"] = getUpgradePrice();
		dataArr["userID"] = uservars["userID"];
		dataArr["password"] = uservars["password"];
		playSound("equip");
		dataArr["action"] = "newequip";
		if(payload[0][2] !== payload[1][2])
		{
			if(payload[0][2] === "character")
			{
				dataArr["expected_itemtype2"] = payload[0][1];
				dataArr["expected_itemtype"] = payload[1][1];
				dataArr["itemnum2"] = payload[0][0];
				dataArr["itemnum"] = payload[1][0];
			}
		}
		webCall("inventory_new", dataArr, callback, true);
	}

	function removeArmour() {
		// updateInventory takes an array with two elements. The first is a 3-array with the primary item data, and the
		// second is about where it's being moved to.
		// ItemData [slot_index, item_type, inventory_group(equipment, character, or inventory)]
		// MoveToData [slot_index, item_type, inventory_group]
		// Taken from shiftItem.
		const uservars = userVars;
		const armour_type = uservars["DFSTATS_df_armourtype"];
		const item_data = ["34", armour_type, "character"];
		const move_to_data = [findFirstEmptyGenericSlot("inv"), "", "inventory"];
		swapItems(item_data, move_to_data, function(data) {
			updateIntoArr(flshToArr(data, "DFSTATS_"), uservars);
			// 			$.each($(".characterRender"), function(key, val)
			// 			{
			// 				renderAvatarUpdate(val, uservars);
			// 			});
			populateInventory();
			populateCharacterInventory();
			updateAllFields();
			renderAvatarUpdate();
		});
	}

	function* slots() {
		// Generator that iterates through slots and yields [slot, type, quantity].
		const uservars = userVars;
		const nslots = parseInt(uservars["DFSTATS_df_invslots"]);
		for (let slot = 1; slot <= nslots; slot++) {
			const type_key = "DFSTATS_df_inv" + slot.toString() + "_type";
			const quantity_key = "DFSTATS_df_inv" + slot.toString() + "_quantity";
			const type = uservars[type_key];
			yield [slot, uservars[type_key], uservars[quantity_key]];
		}
	}

	function* items() {
		// Generator that iterates through filled slots and yields [slot, type, quantity].
		for (const [slot, type, quantity] of slots()) {
			const slot_filled = type !== "";
			if (slot_filled) {
				yield [slot, type, quantity];
			}
		}
	}

	function findItem(item_type) {
		for (const [slot, type, quantity] of items()) {
			if (type === item_type) {
				return slot;
			}
		}
		return null;
	}

	function countItem(item_type) {
		let output = 0;
		for (const [slot, type, quantity] of items()) {
			if (type === item_type) {
				output += 1;
			}
		}
		return output;
	}

	function reequipArmour(slot, armour_type) {
		// updateInventory takes an array with two elements. The first is a 3-array with the primary item data, and the
		// second is about where it's being moved to.
		// ItemData [slot_index, item_type, inventory_group(equipment, character, or inventory)]
		// MoveToData [slot_index, item_type, inventory_group]
		// Taken from shiftItem.
		const uservars = userVars;
		const move_to_data = ["34", "", "character"];
		const item_data = [slot, armour_type, "inventory"];
		swapItems(item_data, move_to_data, function(data) {
			updateIntoArr(flshToArr(data, "DFSTATS_"), uservars);
			// 			$.each($(".characterRender"), function(key, val)
			// 			{
			// 				renderAvatarUpdate(val, uservars);
			// 			});
			populateInventory();
			populateCharacterInventory();
			updateAllFields();
			renderAvatarUpdate();
		});
	}

	// Requests

	function setupRequestServicesMarketData(tradezone, service, level) {
		// Sets up POST request for searching up market data for given service, level, and tradezone.
		const request = new XMLHttpRequest();
		request.open("POST", "https://fairview.deadfrontier.com/onlinezombiemmo/trade_search.php");
		request.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
		// Setting up request payload.
		const request_parameters = new URLSearchParams();
		request_parameters.set("hash", "");
		request_parameters.set("pagetime", "");
		request_parameters.set("tradezone", tradezone.toString());
		request_parameters.set("searchname", level.toString());
		request_parameters.set("category", "");
		request_parameters.set("profession", service.toString());
		request_parameters.set("memID", "");
		request_parameters.set("searchtype", "buyinglist");
		request_parameters.set("search", "services");
		return [request, request_parameters];
	}

	function parseMarketServicesData(response, service, level) {
		const parsed = rawResponseToObject(response);
		parsed["services"] = true;
		parsed["searcheditem"] = level;
		document.getElementById("searchField").value = level;
		const dropdown = document.getElementById("categoryChoice");
		dropdown.dataset.catname = service;
		dropdown.dataset.cattype = "service";
		document.getElementById("cat").innerHTML = "Services - " + ServiceName[service];
		listMarket(parsed);
		const children = document.getElementById("itemDisplay").children;
		for (let i = 0; i < children.length; i++) {
			const child = children[i];
			const cls = child.className;
			if (cls !== "serviceItem") {
				continue;
			}
			const icon = child.getElementsByClassName("fakeSlot trueIcon")[0];
			if (service === ServiceType.CHEF) {
				icon.classList.replace("trueIcon", "ChefIcon");
			} else if (service === ServiceType.DOCTOR) {
				icon.classList.replace("trueIcon", "DoctorIcon");
			} else if (service === ServiceType.ENGINEER) {
				icon.classList.replace("trueIcon", "EngineerIcon");
			}
		}
		document.getElementById("makeSearch").removeAttribute("disabled");
		const prompt = document.getElementById("gamecontent");
		prompt.parentNode.style.display = "none";
		prompt.innerHTML = "";
	}

	function requestServicesMarketData(tradezone, service, level) {
		// Requests market data for given item and tradezone. Calls parseMarketServiceData to parse and store
		// information.
		const [request, parameters] = setupRequestServicesMarketData(tradezone, service, level);
		request.onreadystatechange = function() {
			const is_complete = this.readyState == 4;
			const response_ok = this.status == 200;
			const client_error = this.status >= 400 && this.status < 500;
			const server_error = this.status >= 500 && this.status < 600;
			if (is_complete && response_ok) {
				parseMarketServicesData(this.response, service, level);
			}
		}
		const prompt = document.getElementById("gamecontent");
		prompt.innerHTML = "<div style='text-align: center'>Loading, please wait...</div>";
		prompt.parentNode.style.display = "block";
		request.send(parameters);
	}

	// UI

	function setLoadingHidden() {
		const prompt = document.getElementById("prompt");
		const gamecontent = document.getElementById("gamecontent");
		gamecontent.innerHTML = "";
		prompt.style.setProperty("display", "none");
	}

	function setLoadingVisible() {
		const prompt = document.getElementById("prompt");
		const gamecontent = document.getElementById("gamecontent");
		gamecontent.style.setProperty("text-align", "center");
		gamecontent.innerHTML = "Loading...";
		prompt.style.setProperty("display", "block");
	}

	function makeCookingButton(parent, callback) {
		const button = document.createElement("button");
		button.className = "dffs-base";
		button.innerHTML = "cooking";
		button.style.position = "absolute";
		button.style.top = "55px";
		button.style.left = "610px";
		button.style.textAlign = "left";
		button.addEventListener("click", callback);
		parent.appendChild(button);
		return button;
	}

	function makeMedicalButton(parent, callback) {
		const button = document.createElement("button");
		button.className = "dffs-base";
		button.innerHTML = "medical";
		button.style.position = "absolute";
		button.style.top = "75px";
		button.style.left = "610px";
		button.style.textAlign = "left";
		button.addEventListener("click", callback);
		parent.appendChild(button);
		return button;
	}

	function makeRepairButton(parent, callback) {
		const button = document.createElement("button");
		button.className = "dffs-base";
		button.innerHTML = "repair";
		button.style.position = "absolute";
		button.style.top = "95px";
		button.style.left = "610px";
		button.style.textAlign = "left";
		button.addEventListener("click", callback);
		parent.appendChild(button);
		return button;
	}

	function makeRepairArmourButton(parent, callback) {
		const button = document.createElement("button");
		button.className = "dffs-base";
		button.style.position = "absolute";
		button.style.top = "15px";
		button.style.left = "610px";
		button.style.textAlign = "left";
		button.addEventListener("click", callback);
		parent.appendChild(button);
		return button;
	}

	function waitForItemDisplay(callback, timeout) {
		const start = performance.now();
		const check = setInterval(function() {
			if (document.getElementById("itemDisplay") !== null) {
				clearInterval(check);
				callback();
			} else if (performance.now() - start > timeout) {
				clearInterval(check);
			}
		}, 100);
	}

	function setWarningHidden() {
		// Hides pop-up warning.
		const prompt = document.getElementById("prompt");
		const gamecontent = document.getElementById("gamecontent");
		prompt.removeAttribute("style");
		gamecontent.removeAttribute("class");
	}

	function setWarningVisible(text, yesCallback, noCallback) {
		// Shows pop-up warning/prompt.
		const prompt = document.getElementById("prompt");
		const gamecontent = document.getElementById("gamecontent");
		// Warning message
		prompt.style.setProperty("display", "block");
		gamecontent.className = "warning";
		gamecontent.style.setProperty("font-family", "\"Courier New CE\", Arial");
		gamecontent.style.setProperty("font-weight", "bold");
		gamecontent.style.setProperty("color", "white");
		gamecontent.style.setProperty("text-align", "center");
		gamecontent.innerHTML = text;
		// Buttons
		const noButton = document.createElement("button");
		noButton.style.position = "absolute";
		noButton.style.top = "72px";
		noButton.style.left = "151px";
		noButton.innerHTML = "No";
		noButton.addEventListener("click", noCallback);
		gamecontent.appendChild(noButton);
		const yesButton = document.createElement("button");
		yesButton.style.position = "absolute";
		yesButton.style.left = "86px";
		yesButton.style.top = "72px";
		yesButton.innerHTML = "Yes";
		yesButton.addEventListener("click", yesCallback);
		gamecontent.appendChild(yesButton);
		// Enter hotkey for confirming.
		gamecontent.focus();
		let debounce = true; // Avoid sending request if one has already been sent.
		gamecontent.onkeydown = function(e) {
			const enter_pressed = e.code === "Enter" || e.code === "NumpadEnter";
			if (debounce && enter_pressed) {
				debounce = false;
				Promise.resolve(yesCallback())
				.then(function() {
					debounce = true;
				});
			}
		}
	}

	function setCancelWarningVisible(text, cancel_callback) {
		// Shows pop-up warning/prompt.
		const prompt = document.getElementById("prompt");
		const gamecontent = document.getElementById("gamecontent");
		// Warning message
		prompt.style.setProperty("display", "block");
		gamecontent.className = "warning";
		gamecontent.style.setProperty("font-family", "\"Courier New CE\", Arial");
		gamecontent.style.setProperty("font-weight", "bold");
		gamecontent.style.setProperty("color", "white");
		gamecontent.style.setProperty("text-align", "center");
		gamecontent.innerHTML = text;
		// Buttons
		const cancel_button = document.createElement("button");
		cancel_button.style.position = "absolute";
		cancel_button.style.top = "72px";
		cancel_button.style.left = "100px";
		cancel_button.innerHTML = "Cancel";
		cancel_button.addEventListener("click", cancel_callback);
		gamecontent.appendChild(cancel_button);
	}


	function buyRepair(player_items, market_items, engineer, slot, armour) {
		setLoadingVisible();
		return player_items.inventoryToArmour(slot, UiUpdate.NO).then(function() {
			return market_items.buyRepairFromMarketEntry(engineer, slot, armour, UiUpdate.NO);
		}).then(function() {
			player_items.inventoryToArmour(slot, UiUpdate.YES);
			playSound("repair");
			setLoadingHidden();
		});
	}

	function buyRepairArmor(player_items, market_items, engineer, armour) {
		setLoadingVisible();
		const slot = 34;
		return market_items.buyRepairFromMarketEntry(engineer, slot, armour, UiUpdate.YES)
		.then(function() {
			playSound("repair");
			setLoadingHidden();
		});
		// .then(function() {
		// 	return waitForId("statusBox", 1000);
		// })
		// .then(function(e) {
		// 	e.remove();
		// });
	}

	let armour_type = undefined;
	function addButtons() {
		const tradezone = userVars.DFSTATS_df_tradezone;
		const marketplace = document.getElementById("marketplace");
		makeCookingButton(marketplace, function() {requestServicesMarketData(tradezone, ServiceType.CHEF, service_level);});
		makeMedicalButton(marketplace, function() {requestServicesMarketData(tradezone, ServiceType.DOCTOR, service_level);});
		makeRepairButton(marketplace, function() {requestServicesMarketData(tradezone, ServiceType.ENGINEER, service_level);});
		const player_values = new DeadFrontier.PlayerValues();
		const player_items = new DeadFrontier.PlayerItems();
		const market_items = new DeadFrontier.MarketItems();
		const armour_button = makeRepairArmourButton(marketplace, dummy);
		armour_button.innerHTML = "repair armour";
		player_values.request().then(function(values) {
			const market_cache = new DeadFrontier.MarketCache(values.tradezone);
			return market_cache;
		}).then(function(cache) {
			armour_button.removeEventListener("click", dummy);
			armour_button.addEventListener("click", function() {
				// Notification and early return if repair is not needed or not possible.
				if (!wearingArmour()) {
					setCancelWarningVisible("You are not wearing armour!", setWarningHidden);
					return;
				}
				// if (!canRemoveArmour()) {
				// 	setCancelWarningVisible("You need an open inventory slot!", setWarningHidden);
				// 	return;
				// }
				const armour = player_items.armour();
				const repair_needed = armour.properties.get("durability") !== armour.properties.get("max_durability");
				const engineer_level = armour.properties.get("engineer_level");
				if (!repair_needed) {
					setCancelWarningVisible("Your armour is already at full durability!", setWarningHidden);
					return;
				}
				// Normal path.
				cache.requestServiceMarketEntriesByType(ServiceType.ENGINEER).then(function(cache) {
					const engineers = cache.getServiceMarketEntriesByType(ServiceType.ENGINEER);
					const valid_engineers = engineers.filter((e) => e.service.level >= engineer_level);
					const engineers_available = valid_engineers.length > 0;
					if (!engineers_available) {
						setCancelWarningVisible("No engineers avaiable.");
						return;
					}
					const cheapest_valid_engineer = valid_engineers[0];
					const enough_cash = cheapest_valid_engineer.price <= player_values.cash;
					if (!enough_cash) {
						setCancelWarningVisible("You need <br><span style=\"color: red;\">$" + cheapest_valid_engineer.price.toLocaleString() + "</span><br>to repair your armour.", setWarningHidden);
						return;
					}
					if (cheapest_valid_engineer.price <= max_repair_cost_no_confirm) {
						const slot = findFirstEmptyGenericSlot("inv");
						buyRepairArmor(player_items, market_items, cheapest_valid_engineer, armour);
						// buyRepair(player_items, market_items, cheapest_valid_engineer, slot, armour);
					} else if (cheapest_valid_engineer.price <= max_repair_cost_popup) {
						setWarningVisible("This repair will cost<br><span style=\"color: red;\">$" + cheapest_valid_engineer.price.toLocaleString() + "</span>.<br>Are you sure you want to repair your armour?",
							function() {
								const slot = findFirstEmptyGenericSlot("inv");
								buyRepairArmor(player_items, market_items, cheapest_valid_engineer, armour);
								// buyRepair(player_items, market_items, cheapest_valid_engineer, slot, armour);
								setWarningHidden();
							},
						setWarningHidden);
					}
				});
			});
		});

		// const armour_button = makeRemoveArmourButton(marketplace, function() {
		// 	if (canRemoveArmour()) {
		// 		armour_type = userVars["DFSTATS_df_armourtype"];
		// 		removeArmour();
		// 		armour_button.innerHTML = "re-equip armour";
		// 	} else if (armour_type !== undefined && findItem(armour_type) !== null && countItem(armour_type) === 1) {
		// 		const slot = findItem(armour_type);
		// 		armour_type = undefined;
		// 		reequipArmour(slot, armour_type);
		// 		armour_button.removeAttribute("disabled");
		// 		armour_button.innerHTML = "remove armour";
		// 	} else {
		// 		armour_button.setAttribute("disabled", "");
		// 	}
		// });
		// const armour_in_inventory = findItem(armour_type) !== null;
		// if (canRemoveArmour() || armour_in_inventory) {
		// 	if (canRemoveArmour()) {
		// 		armour_button.innerHTML = "remove armour";
		// 	} else {
		// 		armour_button.innerHTML = "re-equip armour";
		// 	}
		// } else {
		// 	armour_button.innerHTML = "remove armour";
		// 	armour_button.setAttribute("disabled", "");
		// }
	}

	// Main

	function main() {
		waitForItemDisplay(function() {
			// Initial addition of service buttons.
			addButtons();
			// Mutation observer to check for changes to the marketplace child nodes, re-adding the event listener
			// whenever the selectMarket element is re-added (which happens when the market tab is changed).
			const callback = function(mutationList, observer) {
				for (const record of mutationList) {
					const element = record.addedNodes[0];
					if (element !== undefined && element.id === "selectMarket") {
						const buying_button = document.getElementById("loadBuying");
						buying_button.addEventListener("click", addButtons);
					}
				}
			}
			const observer = new MutationObserver(callback);
			const marketplace = document.getElementById("marketplace");
			observer.observe(marketplace, {childList: true});
			const remove_statusbox = function(mutationList, observer) {
				for (const record of mutationList) {
					const element = record.addedNodes[0];
					if (element !== undefined && element.id === "statusBox") {
						element.remove();
					}
				}
			}
			const inventory_holder = document.getElementById("inventoryholder");
			const statusbox_observer = new MutationObserver(remove_statusbox);
			statusbox_observer.observe(inventory_holder, {childList: true});
		}, 5000);
	}

	main();
})();