NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @namespace https://openuserjs.org/users/SB100 // @name GGn Equipment Loadout Manager // @description Set loadouts for your equipment so you can mass equip and unequip items // @updateURL https://openuserjs.org/meta/SB100/GGn_Equipment_Loadout_Manager.meta.js // @version 1.4.1 // @author SB100 // @copyright 2021, SB100 (https://openuserjs.org/users/SB100) // @license MIT // @include https://gazellegames.net/user.php?action=equipment // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // ==/UserScript== // ==OpenUserJS== // @author SB100 // ==/OpenUserJS== /* jshint esversion: 6 */ /** * ============================= * ADVANCED OPTIONS * ============================= */ // If you want the Equipment Loadout Manager to be opened by default when you visit the equipment page const DEFAULT_OPENED = true; // If you want to bypass the confirm dialog when clicking Equip Items const BYPASS_CONFIRM = false; // If you want to show the quick equip buttons when the equipment loadout manager form is closed const SHOW_QUICK_BUTTONS = true; /** * ============================= * END ADVANCED OPTIONS * DO NOT MODIFY BELOW THIS LINE * ============================= */ /** * A cache of all the items we've analyzed on the page */ const ITEM_CACHE = {}; /** * Wait a specific amount of time. * Use this in an async function via: `await wait(ms)` */ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Shallow compare 2 objects for equality */ function objShallowEqual(obj1, obj2) { return Object.keys(obj1).length === Object.keys(obj2).length && Object.keys(obj1).every(key => obj1[key] === obj2[key]); } /** * Get the current user id */ function getUserId() { return unsafeWindow.userid; } /** * Get the users auth key */ function getAuthKey() { return unsafeWindow.authkey; } /** * Turn a slot id into a slot name */ function getSlotName(slotId) { switch (slotId) { case 1: return 'Helmet'; case 2: return 'Upper Body'; case 3: return 'Arms'; case 4: return 'Legs'; case 5: return 'Hands'; case 6: return 'Feet'; case 7: return 'Left Hand'; case 8: return 'Right Hand'; case 9: return 'Necklace'; case 10: return 'Left Ring' case 11: return 'Right Ring' case 12: return 'Backpack'; case 13: return 'Clothes'; case 14: return 'Pet 1'; case 15: return 'Pet 2'; default: return 'Unknown'; } } /** * Turn a slot name into a slot id */ function getSlotId(slotName) { switch (slotName) { case 'Helmet': return 1; case 'Upper Body': return 2; case 'Arms': return 3; case 'Legs': return 4; case 'Hands': return 5; case 'Feet': return 6; case 'Left Hand': return 7; case 'Right Hand': return 8; case 'Necklace': return 9; case 'Left Ring': return 10; case 'Right Ring': return 11; case 'Backpack': return 12; case 'Clothes': return 13; case 'Pet 1': return 14; case 'Pet 2': return 15; default: return 0; } }; /** * Get all items currently available to the user. * If wantOnlyEquip is true, this will only return items that are currently equipped */ function getItems(wantOnlyEquip = false) { const selector = wantOnlyEquip ? '.itemslot .item' : '.itemslot .item, #items-wrapper .item'; return Array.from(document.querySelectorAll(selector)).reduce((result, elem) => { const slots = elem.dataset.slots.split(','); const conjunction = Array.isArray(slots) && slots.pop(); slots.forEach(s => { const slot = parseInt(s, 10); const slotName = getSlotName(slot); const id = parseInt(elem.dataset.id, 10); if (!result[slotName]) { result[slotName] = []; } ITEM_CACHE[id] = { id, name: decodeURI(elem.dataset.itemName), slots: slots.map(slot => parseInt(slot, 10)), conjunction: conjunction } result[slotName].push(ITEM_CACHE[id]); }); return result; }, {}); } /** * Get an item's details by an item id */ function getItem(itemId) { if (ITEM_CACHE[itemId]) { return ITEM_CACHE[itemId]; } const elems = Array.from(document.querySelectorAll('.itemslot .item, #items-wrapper .item')); for (let i = 0, len = elems.length; i < len; i += 1) { const elem = elems[i]; const id = parseInt(elem.dataset.id, 10) if (id === itemId) { const slots = elem.dataset.slots.split(','); const conjunction = Array.isArray(slots) && slots.pop(); ITEM_CACHE[id] = { id, name: decodeURI(elem.dataset.itemName), slots: slots.map(slot => parseInt(slot, 10)), conjunction: conjunction } return ITEM_CACHE[id]; } } return {} } /** * Group items by their item slots and conjunction. * We need this so that we can [un]equip rings and pets one at a time to avoid a race condition */ function groupItems(itemIds) { const seen = { 0: [] }; const results = { 0: [] }; for (let i = 0, iLen = itemIds.length; i < iLen; i += 1) { const item = getItem(itemIds[i]); const key = `${item.slots.sort().join(':')}:${item.conjunction}`; let pushed = false; for (let j = 0, jLen = Object.keys(seen).length; j < jLen; j += 1) { if (seen[j].includes(key) === false) { results[j].push(item.id); seen[j].push(key); pushed = true; break; } } if (pushed === false) { results[Object.keys(results).length] = [item.id]; seen[Object.keys(seen).length] = [key]; } } return results; } /** * Get all saved loadouts */ function getLoadouts() { const existing = GM_getValue('loadouts', '{}'); return JSON.parse(existing); } /** * Send a request to the server to equip or unequip an item */ function sendEquipRequest(isEquip, itemId, userId, authKey) { let resolver; let rejecter; const p = new Promise((resolveFn, rejectFn) => { resolver = resolveFn; rejecter = rejectFn; }); const url = `/user.php?action=ajax_equip_item&auth=${authKey}&itemid=${itemId}&userid=${userId}&equiptype=${isEquip ? 'equip' : 'unequip'}`; GM_xmlhttpRequest({ method: 'get', url: url, timeout: 5000, onloadstart: function () { }, onload: function () { resolver(); }, onerror: function () { rejecter('Error') }, ontimeout: function () { rejecter('Timeout') }, }); return p; } /** * Convenience method for bulk equipping or unequipping items */ function equipAllItemIds(itemIds, userId, authKey, isEquip = true) { const promises = itemIds.map(itemId => sendEquipRequest(isEquip, itemId, userId, authKey)); return Promise.all(promises); } /** * Turn a form into a JSON object, without the loadout name */ function formToJson(formData) { const result = {}; for (const [key, val] of formData.entries()) { if (key === 'loadout-name') continue; result[key] = parseInt(val, 10); } return result; } // --------------------------------------------------------------------------------------------- Handlers /** * Handler for when the "Equip Items" button is clicked. This will: * - Check that the current loadout is up to date (i.e. all changes have been saved) * - Calculates what needs to be unequipped, and what needs to be equipped * - Confirms the changes with the user * - Sends requests to the server to equip and unequip items * - Reloads the page */ async function handleEquip() { // currently equip const currentlyEquipObj = getItems(true); const currentlyEquipIds = Object.keys(currentlyEquipObj).reduce((result, key) => { const values = currentlyEquipObj[key]; values.forEach(value => result.push(value.id)); return result; }, []); const uniqueEquipIds = [...new Set(currentlyEquipIds)] // loadout items const uniqueLoadoutIds = []; const formData = new FormData(document.querySelector('.loadout-manager__form')); for (const [key, val] of formData.entries()) { if (key === 'loadout-name') continue; uniqueLoadoutIds.push(parseInt(val, 10)); } // check if there have been any changes in the loadout const loadouts = getLoadouts(); const loadout = loadouts[formData.get('loadout-name')]; const form = formToJson(formData); if (!loadout || !objShallowEqual(loadout, form)) { alert(`Changes detected in this loadout\rPlease verify your loadout, save it, and then try again`); return; } // calculate difference for what we have to unequip const unequip = uniqueEquipIds.filter(x => !uniqueLoadoutIds.includes(x)); // calculate difference for what we have to equip const equip = uniqueLoadoutIds.filter(x => !uniqueEquipIds.includes(x)); // check if we acutally have to make changes if (unequip.length === 0 && equip.length === 0) { window.alert('Nothing to do!'); return; } if (!BYPASS_CONFIRM) { // get names of equipment we will equip and unequip const unequipNames = unequip.map(itemId => getItem(itemId).name); const equipNames = equip.map(itemId => getItem(itemId).name); // confirm the changes we're about to make with the user const message = `About to run "${formData.get('loadout-name')}" loadout. This will result in the following changes:\r\r${unequipNames.length > 0 ? `To Unequip:\r${unequipNames.map(s => ` - ${s}`).join("\r")}\r\r` : ''}${equip.length > 0 ? `To Equip:\r${equipNames.map(s => ` - ${s}`).join("\r")}\r\r` : ''}Is this ok?`; if (!window.confirm(message)) { return; } } // user has confirmed changes - let's make them! const loading = document.querySelector('.loadout-manager__loading'); loading && loading.classList.add('loadout-manager__loading--showing'); try { // group items so that we can avoid race conditions const unequipGroups = groupItems(unequip); const equipGroups = groupItems(equip); // first unequip what we need to loading.innerHTML = 'Unequipping unneeded items'; for (let i = 0, len = Object.keys(unequipGroups).length; i < len; i += 1) { await equipAllItemIds(unequipGroups[i], getUserId(), getAuthKey(), false); await wait(250); } // then equip what we need to loading.innerHTML = 'Equipping missing items'; for (let i = 0, len = Object.keys(equipGroups).length; i < len; i += 1) { await equipAllItemIds(equipGroups[i], getUserId(), getAuthKey(), true); await wait(250); } // then reload the page to see new changes loading.innerHTML = 'Reloading page'; window.location.reload(true); return true; } catch (e) { // if there was an error, show the user, and do nothing more loading.innerHTML = 'An error occurred whilst processing the loadout'; console.error(e); return false; } } /** * Handler for when the "Save" button is clicked. * This saves a loadout for future selection */ function handleSave(event, loadoutManager) { const formData = new FormData(document.querySelector('.loadout-manager__form')); const name = formData.get('loadout-name'); if (name === '') { window.alert('You must enter a loadout name'); return; } const loadouts = getLoadouts(); loadouts[name] = formToJson(formData); GM_setValue('loadouts', JSON.stringify(loadouts)); // recreate the dropdown, and have this value now selected createLoadoutDropdown(loadoutManager, name); } /** * Handler for when the "Remove" button is clicked * This removes a currently stored loadout */ function handleRemove(name, loadoutManager) { const loadouts = getLoadouts(); delete loadouts[name]; GM_setValue('loadouts', JSON.stringify(loadouts)); // remove the form createLoadoutForm('-1', loadoutManager); // recreate the dropdown with the deleted value now gone createLoadoutDropdown(loadoutManager); } /** * Handler for when an item is selected. * This enables / disables other checkboxes in the form for a better user experience */ function handleItemSelect(target, items) { const isChecked = target.checked; const itemId = parseInt(target.dataset.itemId, 10); const currentSlotId = parseInt(target.parentNode.parentNode.dataset.slotId, 10); const item = items[getSlotName(currentSlotId)].find(item => item.id === itemId); const itemSlots = item && item.slots; const itemConjunction = item.conjunction; if (!currentSlotId || !itemSlots) { console.log('Couldnt find item slot data - script needs updating!'); return; } // disable/enable all other checkboxes in the current fieldset Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${currentSlotId}"] input[type="checkbox"]`)).forEach(option => { // don't act on the checkbox we just ticked if (parseInt(option.dataset.itemId, 10) === itemId) { return; } if (isChecked) { option.disabled = true; option.dataset[`disabled${itemId}`] = 1; return; } delete option.dataset[`disabled${itemId}`]; if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) { option.disabled = false; } }); // disable/enable the same item that might exist in other fieldsets - it can't be equip twice itemSlots .filter(slot => slot !== currentSlotId) .forEach(slot => { Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${slot}"] input[type="checkbox"]`)).forEach(option => { // don't act on anything which isn't the same item in another slot if (parseInt(option.dataset.itemId, 10) !== itemId) { return; } if (isChecked) { option.disabled = true; option.dataset[`disabled${itemId}`] = 1; return; } delete option.dataset[`disabled${itemId}`]; if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) { option.disabled = false; } }); }); // If the conjunctive is AND, uncheck and disable all checkboxes from this slot, and other slots in the AND if (itemConjunction === 'AND') { itemSlots .forEach(slot => { Array.from(document.querySelectorAll(`.loadout-manager__form fieldset[data-slot-id="${slot}"] input[type="checkbox"]`)).forEach(option => { // don't act on the checkbox we just ticked if (parseInt(option.dataset.itemId, 10) === itemId && slot === currentSlotId) { return; } if (isChecked) { option.checked = false; option.disabled = true; // remove all disabled entries so we can start again Object.keys(option.dataset).filter(key => key.startsWith('disabled')).forEach(key => delete option.dataset[key]); option.dataset[`disabled${itemId}`] = 1; return; } delete option.dataset[`disabled${itemId}`]; if (Object.keys(option.dataset).filter(key => key.startsWith('disabled')).length === 0) { option.disabled = false; } }); }); } } /** * Handler for when you hover over a checkbox in the form. * This highlights the item in your inventory, or on your avatars loadout, for a better user experience */ function handleLabelHover(event, isHover) { const itemId = parseInt(event.target.dataset.itemId || event.target.querySelector('input[type="checkbox"]').dataset.itemId, 10); const itemNode = document.querySelector(`.item[data-id="${itemId}"]`); isHover ? itemNode.classList.add('highlight') : itemNode.classList.remove('highlight'); } // --------------------------------------------------------------------------------------------- UI /** * All the custom styling that powers the loadout. BEM FTW. */ function createStyleTag() { const css = `.loadout-manager { position: absolute; top: 320px; right: 0; min-width: 250px; padding: 10px; background-color: rgba(0, 0, 0, 0.7); transform: translateX(100%); } .loadout-manager.loadout-manager--opened { transform: translateX(0); } .loadout-manager__arrow { display: inline-block; position: absolute; left: -25px; top: 0; padding: 10px; background-color: rgba(0, 0, 0, 0.85); cursor: pointer; font-size: 16px; font-weight: bold; } .loadout-manager__select { display: block; margin: 15px 0; padding: 5px; height: auto; } .loadout-manager__form { overflow-y: scroll; max-height: 700px; } .loadout-manager__form input[type='text'] { display: block; } .loadout-manager__form .loadout-manager__buttons input:first-child { margin-left: 0; } .loadout-manager__form .loadout-manager__buttons input { cursor: pointer; } .loadout-manager__form .loadout-manager__buttons input:disabled { background-color: rgba(53.0, 43.0, 7.0, 0.5); cursor: not-allowed; } .loadout-manager__form fieldset { margin-bottom: 5px; } .loadout-manager__form label { display: block; margin-top: 2px; } .loadout-manager__form input[type='checkbox'] { margin: 0 5px; } .loadout-manager__loading { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.7); align-items: center; justify-content: center; padding: 15px; text-align: center; } .loadout-manager__loading.loadout-manager__loading--showing { display: flex; } @keyframes marquee { 0% { transform: translate(0, 0); } 50% { transform: translate(calc(50px - 100%), 0); } 100% { transform: translate(0, 0); } } .loadout-quick { position: absolute; right: 0; top: 350px; width: 240px; padding: 15px; display: flex; flex-direction: row-reverse; flex-wrap: wrap; } .loadout-manager--opened + .loadout-quick { top: 410px; } .loadout-quick__item { display: block; position: relative; overflow: hidden; padding: 10px; width: 70px; height: 50px; margin: 5px; border-radius: 10px; cursor: pointer; } .loadout-quick__item::after { display: block; position: absolute; top: 0; right: 0; bottom: 0; left: 0; content: ''; overflow: hidden; background: linear-gradient(to right, var(--mediumBlue) 0%, transparent 15%, transparent 85%, var(--mediumBlue) 100%); } .loadout-quick__marquee { display: inline-block; white-space: nowrap; min-width: 100%; } .loadout-quick__item:hover .loadout-quick__marquee { animation: marquee 2s linear infinite; }`; const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(css)); document.head.appendChild(style); } /** * Create the Quick Equip Buttons */ function createQuickLoadout(selectedValue, loadoutManager) { // remove any existing buttons const existing = document.querySelector('.loadout-quick'); existing && existing.remove(); // only create these buttons when nothing is selected. if (selectedValue !== '-1' || SHOW_QUICK_BUTTONS === false) { return; } // create all the options from the saved values const loadouts = getLoadouts(); const fragment = document.createDocumentFragment(); Object.keys(loadouts).forEach(loadout => { const btn = document.createElement('button'); btn.classList.add('loadout-quick__item'); btn.innerHTML = `<span class='loadout-quick__marquee'>${loadout}</span>`; // when we click a button, create the form, equip whats in the form, and then destroy the form. Winner! btn.onclick = () => { createLoadoutDropdown(loadoutManager, loadout); handleEquip(); createLoadoutDropdown(loadoutManager); } fragment.appendChild(btn); }); const loadoutQuick = document.createElement('div'); loadoutQuick.classList.add('loadout-quick'); loadoutQuick.appendChild(fragment); const wrapper = document.getElementById('wrapper'); wrapper.appendChild(loadoutQuick); } /** * This creates a loadout form and prepopulates the checkboxes from a saved loadout */ function createLoadoutForm(selectId, loadoutManager) { // remove an existing form const existingForm = loadoutManager.querySelector('.loadout-manager__form'); existingForm && existingForm.remove(); // create the quick loadout buttons createQuickLoadout(selectId, loadoutManager); // "select an option" if (selectId === '-1') { return; } // find data on loadout that has been selected const loadouts = getLoadouts(); const loadout = loadouts[selectId] || {}; // turn all items into fieldsets containing checkboxes const items = getItems(); const fieldsets = Object .keys(items) .sort((a, b) => getSlotId(a) > getSlotId(b) ? 1 : -1) .map(sectionName => { const sectionItems = items[sectionName]; const fragment = document.createDocumentFragment(); sectionItems.forEach(item => { const section = `loadout-items-${sectionName.toLowerCase().replace(/\s/, '')}`; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.name = section; checkbox.value = item.id; checkbox.dataset.itemId = item.id; checkbox.dataset.conjunction = item.conjunction; checkbox.onchange = (event) => handleItemSelect(event.target, items); if (loadout[section] === item.id) { delete loadout[section]; checkbox.checked = true; } const text = document.createTextNode(item.name); const label = document.createElement('label'); label.onmouseover = (event) => handleLabelHover(event, true); label.onmouseout = (event) => handleLabelHover(event, false); label.appendChild(checkbox); label.appendChild(text); fragment.appendChild(label); }); const fieldset = document.createElement('fieldset'); fieldset.dataset.slotId = getSlotId(sectionName); const legend = document.createElement('legend'); legend.innerHTML = sectionName; fieldset.appendChild(legend); fieldset.appendChild(fragment); return fieldset; }); // create the form const form = document.createElement('form'); form.classList.add('loadout-manager__form'); form.action = '#!loadout'; form.method = 'get'; form.onsubmit = (e) => { e.preventDefault(); return false; } // loadout name input const input = document.createElement('input'); input.type = 'text'; input.name = 'loadout-name'; input.placeholder = 'Loadout Name'; if (selectId !== '-1' && selectId !== '0') input.value = selectId; // buttons const equip = document.createElement('input'); equip.type = 'button'; equip.value = 'Equip Items'; if (selectId === '-1' || selectId === '0') equip.disabled = true; equip.onclick = () => handleEquip(); const save = document.createElement('input'); save.type = 'button'; save.value = 'Save'; save.onclick = (event) => handleSave(event, loadoutManager); const remove = document.createElement('input'); remove.type = 'button'; remove.value = 'Delete'; if (selectId === '-1' || selectId === '0') remove.disabled = true; remove.onclick = () => handleRemove(selectId, loadoutManager); const buttonContainer = document.createElement('div'); buttonContainer.classList.add('loadout-manager__buttons'); buttonContainer.appendChild(equip); buttonContainer.appendChild(save); buttonContainer.appendChild(remove); // construct loadout manager form form.appendChild(input); form.appendChild(buttonContainer); fieldsets.forEach(fieldset => form.appendChild(fieldset)); // append to the loadout manager div loadoutManager.appendChild(form); // check if we need to disable any options, that are not NOT conjunctions Array.from(document.querySelectorAll(`.loadout-manager__form input[type="checkbox"]:not([data-conjunction="AND"])`)).forEach(option => { handleItemSelect(option, items); }); // now run through the AND conjunctions and disable any options that shouldn't be available Array.from(document.querySelectorAll(`.loadout-manager__form input[type="checkbox"][data-conjunction="AND"]:checked`)).forEach(option => { handleItemSelect(option, items); }); // verify all parts of the loadout were used. We've been deleting from this object as keys have been used up, so any left means that items are missing const missingKeys = Object.keys(loadout); if (missingKeys.length > 0) { alert(`This loadout contains items which are now missing from your inventory for the following: ${missingKeys.map(missing => ` - ${missing}`).join("\r")} Please verify the loadout and resave it to remove this message`); } } /** * This creates the loadout dropdown with the saved loadouts in it */ function createLoadoutDropdown(loadoutManager, selectedValue = '-1') { // remove any existing dropdown const existing = document.querySelector('.loadout-manager__select'); existing && existing.remove(); // create all the options from the saved values const loadouts = getLoadouts(); const fragment = document.createDocumentFragment(); Object.keys(loadouts).forEach(loadout => { const option = document.createElement('option'); option.value = loadout; option.innerText = loadout; if (loadout === selectedValue) option.selected = true; fragment.appendChild(option); }); // create the select and add some default options const loadoutManagerSelect = document.createElement('select'); loadoutManagerSelect.classList.add('loadout-manager__select'); loadoutManagerSelect.innerHTML = ` <option value="-1"> -- Select an option</option> <option value="0"> -- Create new loadout</option> `; loadoutManagerSelect.onchange = (event) => { const selectId = event.target.value; createLoadoutForm(selectId, loadoutManager); }; loadoutManagerSelect.appendChild(fragment); // append to the loadout manager itself loadoutManager.appendChild(loadoutManagerSelect); // create the loadout form createLoadoutForm(selectedValue, loadoutManager); } /** * This creates the main loadout manager */ function createLoadoutManager() { const wrapper = document.getElementById('wrapper'); // create the loadout manager wrapper const loadoutManager = document.createElement('div'); loadoutManager.classList.add('loadout-manager'); if (DEFAULT_OPENED) loadoutManager.classList.add('loadout-manager--opened'); loadoutManager.innerHTML = `<strong>Equipment Loadout Manager</strong>`; // arrow to expand and collapse the manager const loadoutManagerArrow = document.createElement('div'); loadoutManagerArrow.classList.add('loadout-manager__arrow'); loadoutManagerArrow.innerHTML = `‹`; loadoutManagerArrow.onclick = () => { loadoutManager.classList.toggle('loadout-manager--opened'); } // create the loading overload. The handleEquip function will populate the innerHTML const loadoutManagerLoading = document.createElement('div'); loadoutManagerLoading.classList.add('loadout-manager__loading'); // DOM manipulation loadoutManager.appendChild(loadoutManagerArrow); loadoutManager.appendChild(loadoutManagerLoading); wrapper.appendChild(loadoutManager); // this should always be loaded last in the loadoutManager createLoadoutDropdown(loadoutManager); } // --------------------------------------------------------------------------------------------- The main function runner (function () { 'use strict'; createStyleTag(); createLoadoutManager(); })();