GazelleGames pet leveling info

Adds pet leveling info to your own profile page

// ==UserScript==
// @name        GazelleGames pet leveling info
// @namespace   v3rrrr82xk1c96vvo1c6
// @match       https://gazellegames.net/user.php?id=*
// @grant       GM.getValue
// @grant       GM.setValue
// @grant       GM.deleteValue
// @version     1.3.4
// @description Adds pet leveling info to your own profile page
// @author      lunboks
// @run-at      document-start
// @inject-into content
// @license     MIT
// ==/UserScript==

(async function () {
	"use strict";


	const theirUserID = new URLSearchParams(location.search).get("id");
	const ownUserID = await GM.getValue("you").then((yourID) => {
		// Own ID is cached
		if (yourID) {
			return yourID;
		}

		// Not cached, get it from page once it's loaded
		return new Promise((resolve) => {
			window.addEventListener("DOMContentLoaded", () => {
				yourID = new URLSearchParams(document.body.querySelector("#nav_userinfo a.username").search).get("id");
				GM.setValue("you", yourID);
				resolve(yourID);
			});
		});
	});


	// Only runs on your own user page
	if (theirUserID !== ownUserID) {
		return;
	}


	let apiKey = await GM.getValue("apiKey");

	if (!apiKey) {
		if (!(apiKey = prompt("Please enter an API key with the 'Items' permission to use this script.")?.trim())) {
			return;
		}
		GM.setValue("apiKey", apiKey);
	}


	const endpoint = "https://gazellegames.net/api.php?request=items&type=users_equipped&include_info=true";
	const options = {
		method: "GET",
		mode: "same-origin",
		credentials: "omit",
		redirect: "error",
		referrerPolicy: "no-referrer",
		headers: {
			"X-API-Key": apiKey
		}
	};


	const equipment = await (await fetch(endpoint, options)).json();

	if (equipment.status !== "success") {
		if (equipment.status === 401) {
			GM.deleteValue("apiKey");
		}
		return;
	}


	function toInt(value) {
		return (typeof value === "number") ? value : parseInt(value, 10);
	}


	// Hardcoded IDs of XP-gaining pets.
	// We display these even if they have 0 XP, which we otherwise
	// cannot distinguish from non-leveling pets.
	const levelingPetIDs = new Set(["2509","2510","2511","2512","2513","2514","2515","2521","2522","2523","2524","2525","2529","2583","2927","2928","2929","2933","3215","3216","3237","3322","3323","3324","3369","3370","3371","3373"]);

	const pets = [];

	for (const equip of equipment.response) {
		const type = equip.item.equipType;

		if (type && String(type) === "18" && (levelingPetIDs.has(equip.itemid) || equip.experience > 0)) {
			pets.push({
				name: equip.item.name,
				xp: toInt(equip.experience),
				lv: toInt(equip.level),
				id: String(equip.itemid),
				slot: toInt(equip.slotid)
			});
		}
	}

	if (!pets.length) return;


	// Sort by slot ID for consistent ordering.
	pets.sort((first, second) => first.slot - second.slot);


	// Build our HTML structure.
	// CSS classes copied from the "Personal" box
	const box = document.createElement("div");
	const innerBox = document.createElement("div");
	const list = document.createElement("ul");
	const heading = document.createElement("div");

	box.className = "box_personal_history";
	innerBox.className = "box";
	heading.className = "head colhead_dark";
	list.className = "stats nobullet";
	list.style.lineHeight = "1.5";

	heading.append("Pet Leveling");
	innerBox.append(heading, list);
	box.append(innerBox);


	function totalXP(lv) {
		return Math.ceil((lv * lv * 625) / 9);
	}


	function xpToTimeString(xp) {
		const days = Math.floor(xp / 24);
		const hours = xp % 24;
		let timeString = "";

		if (days) {
			const s = (days === 1) ? "" : "s";
			timeString = `${days} day${s}`;
		}
		if (hours) {
			if (timeString) {
				timeString += " ";
			}
			const s = (hours === 1) ? "" : "s";
			timeString += `${hours} hour${s}`;
		} else if (!timeString) {
			timeString = "0 hours"; // ???
		}

		return timeString;
	}


	const listItems = [];

	pets.forEach((pet, index) => {
		const liItem = document.createElement("li");
		const liLevelInput = document.createElement("li");
		const liTimeOutput = document.createElement("li");
		const shopLink = document.createElement("a");

		// Spacing between pets
		if (index > 0) {
			liItem.style.marginTop = "0.6em";
		}

		// Indent the level/time lines
		liLevelInput.style.paddingLeft = "10px";
		liTimeOutput.style.paddingLeft = "10px";


		shopLink.style.fontWeight = "bold";
		shopLink.href = `/shop.php?ItemID=${pet.id}`;
		shopLink.referrerPolicy = "no-referrer";
		shopLink.title = "Shop for this pet";


		const nextLevel = pet.lv + 1;

		const targetLevelInput = document.createElement("input");
		targetLevelInput.type = "number";
		targetLevelInput.required = true;
		targetLevelInput.inputmode = "numeric";
		targetLevelInput.style.width = "3em";
		targetLevelInput.min = nextLevel;
		targetLevelInput.max = Math.max(999, nextLevel);
		targetLevelInput.value = nextLevel;

		const displayTimeDifference = (toLevel) => {
			const missingXP = totalXP(toLevel) - pet.xp;
			liTimeOutput.textContent = xpToTimeString(missingXP);
		};


		// Display initial info
		displayTimeDifference(nextLevel);

		// When the user types anything, update if it's valid.
		targetLevelInput.addEventListener("input", function () {
			if (this.checkValidity()) {
				displayTimeDifference(parseInt(this.value, 10));
			}
		});

		// When the user commits a new value, complain if it's invalid.
		// No idea why setTimeout is needed tbh
		targetLevelInput.addEventListener("change", function () {
			setTimeout(() => {
				if (!this.reportValidity()) {
					liTimeOutput.textContent = "";
				}
			});
		});


		shopLink.append(pet.name);
		liItem.append(shopLink);
		liLevelInput.append(`Level ${pet.lv} → `, targetLevelInput);


		listItems.push(liItem, liLevelInput, liTimeOutput);
	});


	list.append(...listItems);


	// Try to insert our UI
	function insert() {
		document.getElementsByName("user_info")[0]?.after(box);
		return box.isConnected;

	}

	if (!insert()) {
		window.addEventListener("DOMContentLoaded", insert);
	}
})();