MH - Bean Counter

Adds calculations to Bountiful Beanstalk HUD tooltips. Beanster quantities craftable with your resources, and the max possible loot & noise for your current/next room/zone and multiplier.

// ==UserScript==
// @name         MH - Bean Counter
// @author       squash
// @namespace    https://greasyfork.org/users/918578
// @description  Adds calculations to Bountiful Beanstalk HUD tooltips. Beanster quantities craftable with your resources, and the max possible loot & noise for your current/next room/zone and multiplier.
// @match        https://www.mousehuntgame.com/*
// @match        http://www.mousehuntgame.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=mousehuntgame.com
// @grant        none
// @version      0.1.11
// ==/UserScript==

(function () {
	'use strict';

	function init() {
		function calculateMaxCraftable(itemsOwned, recipe, ignoreItems = [], accounted = {}, itemsCrafted = {}, cheeseRecipes, settings) {
			let maxCanCraft = Infinity;
			let cost = {};

			if (settings) {
				// Calculate craftable of each ingredient recursively
				for (const item of recipe.items) {
					if (item.type in cheeseRecipes) {
						let subRecipe = cheeseRecipes[item.type][settings.recipe[item.type] ? 'upsell' : 'vanilla'];
						let subCraftable = calculateMaxCraftable(itemsOwned, subRecipe, ignoreItems, accounted, itemsCrafted, cheeseRecipes, settings);
						itemsCrafted[item.type] = subCraftable.quantity;
					}
				}
			}

			for (const item of recipe.items) {
				let quantityOwned = itemsOwned[item.type]?.quantity_unformatted || 0;

				// If tracking items that COULD be crafted, add them to available ingredients
				if (item.type in itemsCrafted) {
					quantityOwned += itemsCrafted[item.type];
				}

				let maxCanCraftThisIngredient = Math.floor(quantityOwned / item.required_quantity);

				if (!ignoreItems.includes(item.name)) {
					// || !settings) {
					maxCanCraft = Math.min(maxCanCraft, maxCanCraftThisIngredient);
				}
			}

			// Record crafting costs
			for (const item of recipe.items) {
				if (settings?.general.include_previous_costs) {
					// Calculate prior recipe costs based on maxCanCraft of current recipe
					let obj = {};
					obj[item.type] = maxCanCraft * item.required_quantity;
					cost = sumProperties(cost, obj);
					cost = recursiveCosts(item, cost, cheeseRecipes, settings, itemsOwned);
				} else {
					cost[item.type] = maxCanCraft * item.required_quantity;
				}

				// accounted properties updated by reference?
				accounted[item.type] = itemsOwned[item.type].quantity_unformatted + (itemsCrafted[item.type] || 0);
			}

			return {
				name: recipe.action.name,
				quantity: maxCanCraft * recipe.action.result_quantity,
				cost: cost,
				accounted: accounted,
			};
		}

		function recursiveCosts(item, cost, cheeseRecipes, settings, itemsOwned) {
			if (item.type in cheeseRecipes) {
				let quantity = cost[item.type];
				// Use configured recipe
				let subRecipe = cheeseRecipes[item.type][settings.recipe[item.type] ? 'upsell' : 'vanilla'];
				// Remove already owned qty to not account for ingredients already spent
				cost[item.type] -= itemsOwned[item.type]?.quantity_unformatted || 0;
				let maxCanCraft = cost[item.type] / subRecipe.action.result_quantity;
				for (const subItem of subRecipe.items) {
					let obj = {};
					obj[subItem.type] = maxCanCraft * subItem.required_quantity;
					cost = sumProperties(cost, obj);
					cost = recursiveCosts(subItem, cost, cheeseRecipes, settings, itemsOwned);
				}
				// Change cost back to actual qty of cheese needed
				cost[item.type] = quantity;
			}

			return cost;
		}

		function sumProperties(obj1, obj2) {
			const result = { ...obj1 }; // Copy object

			for (const key in obj2) {
				if (result.hasOwnProperty(key)) {
					result[key] += obj2[key];
				} else {
					// If the key doesn't exist in result, add it
					result[key] = obj2[key];
				}
			}

			return result;
		}

		function calculateAllCheeses(cheeseRecipes, itemsOwned, ignoreItems, settings) {
			let allTotals = {};
			const cheeseTypes = Object.keys(cheeseRecipes); // 'beanster_cheese', 'lavish_beanster_cheese', 'royal_beanster_cheese'

			for (const cheeseType of cheeseTypes) {
				let recipeTotals = {};

				let types = [];
				if (settings.recipe[cheeseType]) {
					types.push('upsell');
				} else {
					types.push('vanilla');
				}
				for (const recipeType of types) {
					let cheeseRecipe = cheeseRecipes[cheeseType][recipeType];
					let craftingResult = calculateMaxCraftable(itemsOwned, cheeseRecipe, ignoreItems);

					recipeTotals[recipeType] = {
						as_is: craftingResult,
					};

					recipeTotals[recipeType].with_crafted = calculateMaxCraftable(itemsOwned, cheeseRecipe, ignoreItems, {}, {}, cheeseRecipes, settings);
				}

				allTotals[cheeseType] = recipeTotals;
			}

			return allTotals;
		}

		function calculatePossibleLoot(data) {
			let totalLootMultiplier = data.loot_multipliers.total;
			let huntsPerRoom = 20; // Assuming a fixed number of hunts per room

			// Calculate total possible loot depending on in_castle status
			let currentRoomOrZone, nextRoomOrZone, huntsRemaining;
			if (data.in_castle) {
				currentRoomOrZone = data.castle.current_room;
				nextRoomOrZone = data.castle.next_room;
				huntsRemaining = data.castle.hunts_remaining;
			} else {
				currentRoomOrZone = data.beanstalk.current_zone;
				nextRoomOrZone = data.beanstalk.next_zone;
				huntsRemaining = data.beanstalk.hunts_remaining;
			}

			let nextRoomOrZoneMultiplier = (totalLootMultiplier / currentRoomOrZone.loot_multiplier) * nextRoomOrZone.loot_multiplier;

			return {
				currentRoomOrZoneLoot: {
					hunts: huntsRemaining,
					quantity: totalLootMultiplier * huntsRemaining,
				},
				nextRoomOrZoneLoot: {
					hunts: huntsPerRoom,
					quantity: nextRoomOrZoneMultiplier * huntsPerRoom,
				},
			};
		}

		function calculateCatchesUntilMaxNoise(data) {
			const noisePerCatch = data.loot_multipliers.total;
			const remainingNoise = data.castle.max_noise_level - data.castle.noise_level;
			const catchesNeeded = Math.ceil(remainingNoise / noisePerCatch);

			const mayWake = catchesNeeded <= data.castle.hunts_remaining;
			const possibleNoise = data.castle.hunts_remaining * noisePerCatch;
			const noiseDiff = mayWake ? possibleNoise - remainingNoise : remainingNoise - possibleNoise;
			const catchesShort = mayWake ? 0 : catchesNeeded - data.castle.hunts_remaining;

			// Noise short, or noise extra
			let content = `${catchesNeeded} catches until full noise. `;
			if (mayWake) {
				content += `May wake giant with extra ${noiseDiff} noise.`;
			} else {
				content += `${catchesShort} catches (${noiseDiff} noise) short of waking giant.`;
			}

			if (remainingNoise < 0) {
				content = ``;
			}

			return content;
		}

		function renderCraftable(output) {
			let allRenders = [];

			const recipeTypeMap = {
				upsell: 'Magic Essence',
				vanilla: 'Standard',
			};
			const calcTypeMap = {
				as_is: 'Current Ingredients',
				with_crafted: 'Craftable Ingredients',
			};

			const bait = document.querySelectorAll('.headsUpDisplayBountifulBeanstalkView__baitCraftableContainer');
			if (bait) {
				for (const el of bait) {
					let result = output[el.dataset.itemType];

					let tooltip = el.querySelector('.mousehuntTooltip');
					let extras = el.querySelector('.mousehuntTooltip__estimates') || document.createElement('div');
					extras.classList = 'mousehuntTooltip__estimates';

					let content = '';

					for (const recipeType in result) {
						content += `<br><b>${recipeTypeMap[recipeType]}</b>`;
						for (const calcType in result[recipeType]) {
							content += `<br>${calcTypeMap[calcType]}<br>`;
							content += `<div style="padding-left: 1.5ch;"><b>${result[recipeType][calcType].quantity.toLocaleString('en-US')}</b> ${result[recipeType][calcType].name}</div>`;
							content += `<div style="padding-left: 1.5ch;">${renderCraftableCost(result[recipeType][calcType].cost, result[recipeType][calcType].accounted)}</div>`;
						}
						content += `<br>`;
					}

					allRenders.push(content);

					extras.innerHTML = `
					---
					${content}
					`;

					tooltip.append(extras);
				}
			}

			return allRenders;
		}

		function renderTypeAsName(input) {
			// Remove undesired phrases
			input = input.replace(/_craft_item|_stat_item|_cheese/g, '');

			// Replace underscores with spaces
			input = input.replace(/_/g, ' ');

			// Uppercase the first letter of every word
			input = input.replace(/\b\w/g, function (letter) {
				return letter.toUpperCase();
			});

			return input;
		}

		function renderCraftableCost(items, accounted) {
			let out = '';
			for (const type in items) {
				let short = accounted[type] - items[type];
				out += `<i>(${items[type].toLocaleString('en-US')} ${renderTypeAsName(type)}`;
				if (short < 0) {
					out += `<br><span style="color: red;">${short.toLocaleString('en-US')}</span>`;
				}
				out += `)</i><br>`;
			}
			return out;
		}

		function renderTooltip(el, content) {
			if (el) {
				let tooltip = el.querySelector('.mousehuntTooltip');
				if (tooltip) {
					let extras = el.querySelector('.mousehuntTooltip__estimates') || document.createElement('div');
					extras.classList = 'mousehuntTooltip__estimates';
					extras.innerHTML = content;
					tooltip.append(extras);
				}
			}
		}

		function renderRoomLoot(loot, data) {
			let elCurrent, elNext;
			if (data.in_castle) {
				elCurrent = document.querySelector('.bountifulBeanstalkCastleView__plinthOverlay');
				elNext = document.querySelector('.headsUpDisplayBountifulBeanstalkView__castleChevronContainer');
			} else {
				elCurrent = document.querySelector('.bountifulBeanstalkClimbView__plinth');
				//elCurrent.style.zIndex = 'auto';
				//let chevron = document.querySelector('.bountifulBeanstalkClimbView__plinthChevron');
				//chevron.style.zIndex = 'auto';
				elNext = document.querySelector('.headsUpDisplayBountifulBeanstalkView__climbNextRoom');
			}

			let contentCurrent = '';
			let contentNext = '';

			// Loot for current room/zone
			if (loot.currentRoomOrZoneLoot.quantity > 0) {
				contentCurrent += `<b>${loot.currentRoomOrZoneLoot.quantity}</b> each with ${loot.currentRoomOrZoneLoot.hunts} catches.`;
			}

			// Loot for next room/zone
			if (data.castle.is_boss_chase == false && loot.nextRoomOrZoneLoot.quantity > 0) {
				contentNext += `<b>${loot.nextRoomOrZoneLoot.quantity}</b> each with ${loot.nextRoomOrZoneLoot.hunts} catches.`;
			}

			renderTooltip(elCurrent, contentCurrent);
			renderTooltip(elNext, contentNext);
		}

		function getSettings() {
			const defaults = {
				general: {
					enable_loot_estimate: true,
					enable_noise_estimate: true,
					include_previous_costs: false,
				},

				// Magic essence recipe?
				recipe: {
					beanster_cheese: true,
					lavish_beanster_cheese: true,
					leaping_lavish_beanster_cheese: true,
					royal_beanster_cheese: true,
				},
				// Ignore/Assume unlimited ingredients?
				ignore: {
					Gold: true,
					'Magic Essence': true,
					'Beanster Cheese': false,
					'Lavish Beanster Cheese': false,
					'Golden Harp String': false,
				},
			};

			let settings = Object.assign({}, defaults);
			let storage = JSON.parse(localStorage.getItem('squash-beans'));
			if (storage) {
				Object.assign(settings.general, storage.general ?? {});
				Object.assign(settings.recipe, storage.recipe ?? {});
				Object.assign(settings.ignore, storage.ignore ?? {});
			}

			return settings;
		}

		function setupSettingsDialog(renders) {
			// Configuration dialog
			const button = document.querySelector('.headsUpDisplayBountifulBeanstalkView__bean-counter-button') || document.createElement('a');
			button.style = `position: absolute; z-index: 21; left: 160px; top: 15px; font-size: 18px; font-weight: bold; color: #fa822d; text-shadow: 1px 1px #000;`;
			button.innerText = '⚙';
			button.classList = 'headsUpDisplayBountifulBeanstalkView__bean-counter-button';
			button.title = 'Bean Counter HUD Settings';
			button.onclick = () => {
				let dialog = new jsDialog();
				dialog.setTemplate('ajax');
				dialog.setIsModal(true);
				dialog.addToken('{*prefix*}', '<h2 class="title">Bean Counter HUD Settings</h2>');
				let settings = getSettings();

				// Save settings on checkbox change - also do interface refresh to update calculation preview
				let saveSettings = `localStorage.setItem('squash-beans', JSON.stringify(Array.from(document.querySelector('.jsDialog form').elements).filter(e=>e.type==='checkbox').reduce((d,e)=>(g=e.name.split('.'),d[g[0]]||(d[g[0]]={}),d[g[0]][g[1]]=e.checked,d),{})));  hg.utils.PageUtil.refresh();`;

				// Settings form
				let content = ``;
				content += `<form style="display: flex;">`;

				content += `<div style="padding: 1em;">`;
				content += `<h3>Use Magic Essence Recipe?</h3>`;
				for (const key in settings.recipe) {
					let label = key.replace(/_/g, ' ').replace(/\b\w/g, (match) => match.toUpperCase());
					content += `<p><label><input type="checkbox" name="recipe.${key}" value="1" ${settings.recipe[key] ? 'checked' : ''} onchange="${saveSettings}"> ${label} </label></p>`;
				}
				content += `</div>`;

				content += `<div style="padding: 1em;">`;
				content += `<h3>Assume Unlimited Ingredient?</h3>`;
				for (const key in settings.ignore) {
					content += `<p><label><input type="checkbox" name="ignore.${key}" value="1" ${settings.ignore[key] ? 'checked' : ''} onchange="${saveSettings}"> ${key} </label></p>`;
				}
				content += `</div>`;

				content += `<div style="padding: 1em;">`;
				content += `<h3>General</h3>`;
				content += `<p><label><input type="checkbox" name="general.enable_loot_estimate" value="1" ${settings.general.enable_loot_estimate ? 'checked' : ''} onchange="${saveSettings}"> Estimate max room/zone loot? </label></p>`;
				content += `<p><label><input type="checkbox" name="general.enable_noise_estimate" value="1" ${settings.general.enable_noise_estimate ? 'checked' : ''} onchange="${saveSettings}"> Estimate noise generated? </label></p>`;
				content += `<p><label><input type="checkbox" name="general.include_previous_costs" value="1" ${settings.general.include_previous_costs ? 'checked' : ''} onchange="${saveSettings}"> Tally crafting costs from prior recipes? </label></p>`;
				content += `</div>`;

				content += `</form>`;

				// Calculation preview
				content += `<div class="headsUpDisplayBountifulBeanstalkView__bean-counter-dialog-preview" style="display: flex; border-top: 1px solid lightgrey;">`;
				for (const render of renders) {
					content += `<div style="padding: 1em;">${render}</div>`;
				}
				content += `</div>`;

				content += `<div style="padding: 1em;">`;
				content += `<p>Checking unlimited ingredient will behave as though you have an unlimited amount for any recipes that use it and determine the max-craftable based on the other ingredients. </p>`;
				content += `<p>Checking tally prior crafting costs will add up the costs of any uncrafted ingredients from prior recipes in addition to the current recipe. </p>`;
				content += `<p>Ingredient numbers shown in red indicate how much of that ingredient you're missing and cannot craft using one of the prior cheese recipes. </p>`;
				content += `</div>`;

				content += `</form>`;

				dialog.addToken('{*content*}', content);
				dialog.addToken('{*suffix*}', `<input class="jsDialogClose" type="button" value="Close">`);
				dialog.show();
			};
			document.querySelector('.headsUpDisplayBountifulBeanstalkView').append(button);
		}

		function update(data) {
			const itemsOwned = data.items;
			const cheeseRecipes = {
				beanster_cheese: data.beanster_recipe,
				lavish_beanster_cheese: data.lavish_beanster_recipe,
				leaping_lavish_beanster_cheese: data.leaping_lavish_beanster_recipe,
				royal_beanster_cheese: data.royal_beanster_recipe,
			};

			let settings = getSettings();
			let ignoreItems = Object.entries(settings.ignore)
				.filter(([key, value]) => value === true)
				.map(([key]) => key); //['Magic Essence', 'Gold', 'Beanster Cheese'];

			// Add room/zone loot to tooltips
			if (settings.general.enable_loot_estimate) {
				renderRoomLoot(calculatePossibleLoot(data), data);
			}

			// Add cheese crafting calculations to tooltips
			let renders = renderCraftable(calculateAllCheeses(cheeseRecipes, itemsOwned, ignoreItems, settings));

			// Add projected noise to noise meter tooltip
			if (settings.general.enable_noise_estimate && data.in_castle && data.castle.is_boss_chase == false) {
				let el = document.querySelector('.bountifulBeanstalkCastleView__noiseMeter');
				if (el) {
					let content = calculateCatchesUntilMaxNoise(data);
					renderTooltip(el, content);
				}
			}

			// Add settings dialog/button
			setupSettingsDialog(renders);

			// If dialog is open, update the calculation preview
			let dialogPreview = document.querySelector('.headsUpDisplayBountifulBeanstalkView__bean-counter-dialog-preview');
			if (dialogPreview) {
				let content = ``;
				for (const render of renders) {
					content += `<div style="padding: 1em;">${render}</div>`;
				}
				dialogPreview.innerHTML = content;
			}
		}

		if (user?.environment_type == 'bountiful_beanstalk') {
			update(user.enviroment_atts);
		}

		eventRegistry.addEventListener(
			'ajax_response',
			(response) => {
				if (response?.user?.environment_type == 'bountiful_beanstalk') {
					update(response.user.enviroment_atts);
				}
			},
			null,
			false,
			1
		);
	}

	if (typeof eventRegistry === 'undefined') {
		// Workaround for GM
		const script = document.createElement('script');
		script.type = 'application/javascript';
		script.textContent = '(' + init + ')();';
		document.body.appendChild(script);
	} else {
		init();
	}
})();