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.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==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();
	}
})();