Greasy Fork

Greasy Fork is available in English.

MouseHunt Enhanced Search (Beta)

Improve the search logic of search bars in the game

当前为 2024-03-21 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MouseHunt Enhanced Search (Beta)
// @description  Improve the search logic of search bars in the game
// @author       LethalVision
// @version      1.2.0
// @match        https://www.mousehuntgame.com/*
// @match        https://apps.facebook.com/mousehunt/*
// @icon         https://www.google.com/s2/favicons?domain=mousehuntgame.com
// @grant        none
// @license      MIT
// @namespace    http://greasyfork.icu/en/users/683695-lethalvision
// ==/UserScript==

// reference dictionaries in the format of <item name>:<search tags> - item names *must* be exact
// search tags are case insensitive, set tags to empty to indicate that the item should be skipped
// only exceptions/special cases need to be defined here, the script automatically generates the acronyms otherwise
const CHEESE = {
    "Bonefort Cheese":"BF","Checkmate Cheese":"CMC","Cloud Cheesecake":"CCC","Dragonvine Cheese":"DVC","Empowered SUPER|brie+":"ESB","First Draft Derby Cheese":"FDDC#1D",
    "Fusion Fondue":"M400#FF ","Second Draft Derby Cheese":"SDDC#2D ","Galleon Gouda":"GGC","Limelight Cheese":"LLC","Nian Gao'da Cheese":"NGD","Polter-Geitost":"PG",
    "SUPER|brie+":"SB","Wildfire Queso":"WF"}
const CHARMS = {
    "Baitkeep Charm":"BKC","Festive Anchor Charm":"FAC#EAC","EMP400 Charm":"M400#EC","Timesplit Charm":"TSC","Dragonbane Charm":"DBC","Super Dragonbane Charm":"SDBC",
    "Extreme Dragonbane Charm":"EDBC","Ultimate Dragonbane Charm":"UDBC","Party Charm":"PaC","Super Party Charm":"SPaC","Extreme Party Charm":"EPaC",
    "Ultimate Party Charm":"UPaC","Rift Vacuum Charm":"RVC#Calcified Rift Mist#CRM","Rift Super Vacuum Charm":"RSVC#Calcified Rift Mist#CRM"
}
const CRAFTING = {
    "Chrome Celestial Dissonance Upgrade Kit":"CCDT","Chrome Circlet of Pursuing Upgrade Kit":"CCOP#CA2","Chrome MonstroBot Upgrade Kit":"CMBT",
    "Chrome Oasis Upgrade Kit":"COWNT#CPOT","Chrome School of Sharks Upgrade Kit":"CSOS","Chrome Sphynx Wrath Upgrade Kit":"CSW",
    "Chrome Storm Wrought Ballista Upgrade Kit":"CSWBT","Chrome Temporal Turbine Upgrade Kit":"CTT","Chrome Thought Obliterator Upgrade Kit":"CTOT#CTHOT#CF2",
    "Ful'Mina's Tooth":"Fulmina","Sandblasted Metal":"SBM","Stale SUPER brie+":"stale SB"
}
const SPECIAL = {
    "Ful'Mina's Gift":"fulmina","Ful'mina's Charged Toothlet":"fulmina","SUPER|brie+ Supply Pack":"SB","Timesplit Rune":"TSR","Sky Sprocket":"HAL#high altitude loot",
    "Skysoft Silk":"HAL#high altitude loot","Enchanted Wing":"HAL#high altitude loot","Cloudstone Bangle":"HAL#high altitude loot","Sky Glass":"glore","Sky Ore":"glore"
}
const WEAPON = {
    "Biomolecular Re-atomizer Trap":"BRAT#BRT","Birthday Party Piñata Bonanza":"BPPB#Pinata","Blackstone Pass Trap":"BPT#BSP","Brain Extractor":"BE #Brain Bits",
    "Charming PrinceBot":"CPB","Christmas Crystalabra Trap":"CCT#Calcified Rift Mist#CRM","Chrome Circlet of Pursuing Trap":"CCOP#CA2","Chrome MonstroBot":"CMB",
    "Chrome RhinoBot":"CRB","Chrome Thought Obliterator Trap":"CTOT#CTHOT#CF2","Circlet of Pursuing Trap":"A2","Circlet of Seeking Trap":"A1","Enraged RhinoBot":"ERB",
    "Glacier Gatler":"GG#Charm","Ice Blaster":"IB#Charm","Icy RhinoBot":"IRB","Interdimensional Crossbow":"IDCT#ICT","Maniacal Brain Extractor":"MBE#Brain Bits",
    "Multi-Crystal Laser":"MCL","Mysteriously unYielding Null-Onyx Rampart of Cascading Amperes":"MYNORCA","RhinoBot":"RB ","Rift Glacier Gatler":"RGG#Charm",
    "S.A.M. F.E.D. DN-5":"SAMFED DN5#SAM FED DN5","S.L.A.C.":"SLAC","S.L.A.C. II":"SLAC2#SLAC 2","S.S. Scoundrel Sleigher Trap":"SSSST#S4T","S.T.I.N.G. Trap":"STING#L1",
    "S.T.I.N.G.E.R. Trap":"STINGER#L2","Sandstorm MonstroBot":"SMB","Sleeping Stone Trap":"SST#T1","Slumbering Boulder Trap":"SBT#T2","Snow Barrage":"SNOB",
    "Steam Laser Mk. I":"SLM1#SLMK1#SLMK 1#MARK 1","Steam Laser Mk. II":"SLM2#SLMK2#SLMK 2#MARK 2","Steam Laser Mk. II (Broken!)":"SLM2#SLMK2#SLMK 2#MARK 2",
    "Steam Laser Mk. III":"SLM3#SLMK3#SLMK 3#MARK 3","Surprise Party Trap":"SPT#Party Charm","Tarannosaurus Rex Trap":"TREX","The Forgotten Art of Dance":"TFAOD#FART#FAD",
    "The Haunted Manor Trap":"THMT#Charm","The Holiday Express Trap":"THET#Charm","Thought Manipulator Trap":"TMT#F1","Thought Obliterator Trap":"TOT#THOT#F2",
    "Timesplit Dissonance Trap":"TDT#TDW#TSD#TSW"
}
const BASE = {
    "10 Layer Birthday Cake Base":"Ten Layer#Charm","Clockwork Base":"CWB#CB ","Condemned Base":"CB #Charm","Cupcake Birthday Base":"CBB#Charm",
    "Extra Sweet Cupcake Birthday Base":"ESCBB#Charm","Rift Mist Diffuser Base":"RMDB#Charm","Skello-ton Base":"SB #Brain Bits",
    "Sprinkly Sweet Cupcake Birthday Base":"SSCBB#Charm","All Season Express Track Base":"ASETB#Charm"
}

// special merged list - potion outputs, needs both cheese and charms
const POTION = Object.assign({}, CHEESE, CHARMS);

// tag identifier - tags generated by this script have this identifier so that the script can remove it during rendering
const TAG_ID = '#';

// piggyback on Mousehunt's jQuery
// this is a smart thing to do that will never backfire on me
var $ = $ || window.$;

// one-time init functions go here
function init() {
    initMp();
    initTrap();
    initInv();
    initSupplyTransfer();
}

// == Marketplace Search ==
function initMp() {
    // extend templateutil.render to inject desired search terms
    const _parentRender = hg.utils.TemplateUtil.render;
    hg.utils.TemplateUtil.render = function(templateType, templateData) {
        if (templateType == 'ViewMarketplace_search') {
            // only edit marketplace search
            templateData.search_terms.forEach(category => updateMpSearch(category));
        }
        return _parentRender(templateType, templateData);
    }
}

function updateMpSearch(category) {
    var refDict = getDict(category.name);
    if (!refDict) { // invalid name
        return;
    }
    category.terms.forEach(function(listing) {
        var tag = getInitials(listing.value, refDict);
        if (tag && !listing.value.includes('hidden')) {
            // skip if the listing already has "hidden" tag added
            listing.value += `<span class="hidden">${tag}</span>`;
        }
    });
}

// == Trap Setup Search ==
function initTrap() {
    // hook into ajax calls to inject search tags into trap data
    // trap data is refreshed on trap change, hook into changetrap calls to reinject tags
    var callback = function(options, originalOptions, jqXHR) {
        const componentUrl = 'managers/ajax/users/gettrapcomponents.php';
        const changeTrapUrl = 'managers/ajax/users/changetrap.php';
        if (options.url.includes(componentUrl) || options.url.includes(changeTrapUrl)) {
            var _parentSuccess = options.success || originalOptions.success;
            var _parentAjax = options.ajax;
            var _extendedSuccess = function (data) {
                if (data.components && data.components.length > 0) { // components
                    data.components.forEach(component => updateTrapSearch(component));
                } else if (data.inventory) { // changetrap
                    for (var item in data.inventory) {
                        updateTrapSearch(data.inventory[item]);
                    }
                }
                if (_parentAjax) { // changetrap
                    // changetrap uses some unhinged wrapper for successhandler that doesn't resolve properly
                    // so this calls the success function (ondone) directly
                    return _parentAjax.ondone(data);
                } else if (typeof _parentSuccess === 'function') { // components
                    return _parentSuccess(data);
                }
            };
            options.success = originalOptions.success = _extendedSuccess;
        }
    }
    $.ajaxPrefilter(callback);

    // hook into renderFromFile to hide the injected search tags
    const _parentRenderFile = hg.utils.TemplateUtil.renderFromFile;
    hg.utils.TemplateUtil.renderFromFile = function(TemplateGroupSource,templateType,templateData) {
        if (TemplateGroupSource == 'TrapSelectorView' && templateType == 'tag_groups') {
            modifyTemplateData(templateData);
            // ugly timeout delay here because cleanup should only run after renderFromFile returns
            setTimeout(cleanTrapSelector, 5);
        }
        return _parentRenderFile(TemplateGroupSource,templateType,templateData);
    }
}

// inject search tags into trap data
function updateTrapSearch(component) {
    var refDict = getDict(component.classification);
    if (!refDict) { // invalid name
        return;
    }
    var tag = getInitials(component.name, refDict);
    if (!tag) {
        return; // item not in refDict and getInitials fails to generate an acronym for it
    }
    tag += TAG_ID;
    // Tag the trap component with its acronym
    if (!component.tag_types) {
        component.tag_types = [];
    }
    // count number of non-hidden tags
    var count = component.tag_types.length;
    const hiddenTagList = ['draconic', 'tactical', 'shadow', 'physical', 'rift', 'hydro', 'arcane', 'forgotten', 'law',
                           'charm_weak', 'charm_strong', 'charm_epic', 'charm_rare', 'trap_parts', 'bait_standard', 'event'];
    for (var i=0; i<hiddenTagList.length; i++) {
        if (component.tag_types.includes(hiddenTagList[i])) {
            count--;
        }
        if (count == 0) break;
    }
    if (count == 0) {
        // add default tag if all tags are hidden
        component.tag_types.push('default');
    }
    component.tag_types.push(tag);
}

// remove the search tags before they are rendered
function modifyTemplateData(templateData) {
    if (!templateData.tag_groups || templateData.tag_groups.length == 0) {
        return; // skip if it's empty
    }
    templateData.tag_groups = templateData.tag_groups.filter(function(tagGroup) {
        var tagName = tagGroup.type;
        // if tagName is invalid or includes the TAG_ID, reject it - otherwise render
        return (tagName && !tagName.includes(TAG_ID));
    });
}

// unfortunately the tag selector filter doesn't use renderFromFile so we have to
// remove the search tags from the HTML directly
function cleanTrapSelector() {
    var count = 0;
    // clean up the tag dropdown
    var selectQuery = document.querySelectorAll('select[data-filter=tag]');
    if (selectQuery.length > 0) {
        var selectElem = selectQuery[0];
        // remove the custom tags we added for the enhanced search
        for (var i=0; i<selectElem.length; i++) {
            var optValue = selectElem.options[i].value;
            if (optValue.includes(TAG_ID)) {
                selectElem.remove(i);
                i--; // options now has one less element
                count++;
            }
        }
    }
}

// == Inventory Search ==
// TODO: break this up for the different tabs? (cheese, crafting, special, etc.)
function initInv() {
    const inventoryUrl = 'managers/ajax/pages/page.php';
    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function() {
        this.addEventListener("load", function() {
            if ( this.responseURL.includes(inventoryUrl)) {
                updateInventory();
            }
        });
        originalOpen.apply(this, arguments);
    };
    // in case the window is refreshed in inventory.php
    if (window.location.href.includes("inventory.php")) {
        updateInventory();
    }
}

// inject search tags into the HTML element
function updateInventory() {
    var query = document.querySelectorAll('.inventoryPage-item:not(.tagged)');
    query.forEach(function(item){
        item.classList.add("tagged");
        var itemType = item.getAttribute('data-item-classification');
        const dataName = item.getAttribute('data-name'); // original name
        var searchName = dataName;
        if (itemType == 'recipe') {
            itemType = item.parentElement.getAttribute('data-tag'); // get the type of the recipe output item instead
            searchName = sanitizeRecipeName(dataName); // restore sanity
        } else if (itemType == 'potion') {
            updatePotions(item); // potions are handled using different logic
            return;
        }
        var refDict = getDict(itemType);
        if (!refDict) { // invalid classification
            return;
        }
        var tag = getInitials(searchName, refDict);
        if (!tag) {
            return;
        }
        item.setAttribute('data-name', `${dataName}_${tag}`); // preserve original name for normal search
    });
}

// this function exists as witness and testament to the raw insanity that is recipe names
function sanitizeRecipeName(name) {
    // start with removing stuff from the name
    var output = name.replace(/ \(.*\)/g, '') // everything between brackets
    .replace(/Rebuild /g, '') // trap/base rebuilds
    .replace(/the /g, '') // F2 rebuild recipe specifically has an extra 'the' in the name
    .replace(/ Recipe/g, ''); // CMC (charm) specifically has an extra 'Recipe' in the name
    if (name.match(/brie/i)) {
        output = output.replace(/ Cheese/g, ''); // ESB & emp brie recipe has an extra 'cheese' at the end
    }
    // add missing stuff
    if (name.includes('Asiago') || name.includes('Stilton') || name.includes('Havarti') || name.includes('Cheddar')) {
        output += ' Cheese'; // some cheese recipes exclude the 'cheese'
    } else if (name.includes('Chrome') &&
               (name.includes('Celestial') || name.includes('School') || name.includes('Storm') || name.includes('Thought'))) {
        output += ' Trap'; // some weapon recipes exclude the 'trap'
    } else if (name.includes('A.C.R.O.N.Y.M.')) {
        output = 'Arcane Capturing Rod Of Never Yielding Mystery';
    }
    return output;
}

// add tags to potions based on output items from their recipe options
function updatePotions(item) {
    const query = item.querySelectorAll('li');
    const outputSet = new Set();
    query.forEach(function(listItem){
        // grab all bolded items minus the quantities
        const matchList = Array.from(listItem.getInnerHTML().matchAll(/<b>[0-9]+ (.*?)<\/b>/g));
        if (matchList.length != 2) { // should have exactly 2 items
            return;
        }
        outputSet.add(matchList[1][1]); // add the second bolded item (the output) to the set
    });
    const parentTag = item.parentElement.getAttribute('data-tag');
    var tag = '';
    outputSet.forEach((output) => {
        // use the merged potion output list
        var initials = getInitials(output, POTION);
        if (initials) {
            tag += '_' + initials;
        }
    });
    if (!tag) {
        return;
    }
    item.setAttribute('data-name', `${item.getAttribute('data-name')}${tag}`);
}

// == supplyTransfer search ==
function initSupplyTransfer() {
    // the supplyTransfer page causes a full refresh, just check the URL upon init
    if (window.location.href.includes('supplytransfer.php')) {
        updateSupplyTransfer();
    }
    // retag items when the categories are changed
    const observer = new MutationObserver(updateSupplyTransfer);
    const listContainer = document.querySelector('#supplytransfer .categoryContent.itemList.listContainer');
    if (listContainer) {
        observer.observe(listContainer, {childList: true});
    }
}

function updateSupplyTransfer() {
    const supplyTransferPage = document.getElementById('supplytransfer');
    if (!supplyTransferPage) {
        return;
    }
    // add search bar
    const supplyHeader = supplyTransferPage.querySelector('.tabContent.item h2:not(.tagged)');
    if (supplyHeader) {
        supplyHeader.classList.add("tagged");
        supplyHeader.style.display = 'flex';
        supplyHeader.style.justifyContent = 'space-between';
        supplyHeader.innerHTML = `<div>${supplyHeader.innerHTML}</div>`;
        const searchDiv = document.createElement('div');
        searchDiv.style.position = 'relative';
        // search bar
        const searchInput = document.createElement('input');
        searchInput.classList.add('searchInput');
        searchInput.placeholder = "Search";
        searchInput.onkeyup = filterSupplyItems;
        searchDiv.appendChild(searchInput);
        // clear button
        const searchClear = document.createElement('a');
        searchClear.classList.add('searchClear');
        searchClear.href = '#';
        searchClear.innerText = 'x';
        searchClear.style.position = 'absolute';
        searchClear.style.bottom = '3px';
        searchClear.style.right = '5px';
        searchClear.onclick = clearSearch;
        searchDiv.appendChild(searchClear);
        // append div to header
        supplyHeader.appendChild(searchDiv);
    }
    // add "no items" text
    const listContainer = supplyTransferPage.querySelector('.categoryContent.itemList.listContainer');
    var noItemDiv = supplyTransferPage.querySelector('.noItem');
    if (!noItemDiv && listContainer) {
        noItemDiv = document.createElement('div');
        noItemDiv.classList.add('noItem');
        noItemDiv.style.display = 'none';
        noItemDiv.style.position = 'absolute';
        noItemDiv.style.top = '190px';
        noItemDiv.style.left = '335px';
        noItemDiv.innerText = 'No items found.';
        listContainer.appendChild(noItemDiv);
    }
    // tag items
    const query = supplyTransferPage.querySelectorAll('.element.item:not(.tagged)');
    query.forEach((item) => {
        item.classList.add("tagged");
        // pry the item data out from JQuery's cold, dead hands
        const name = $(item).data()?.item?.name;
        const classification = $(item).data()?.item?.classification;
        if (!name || !classification) {
            return;
        }
        var tag;
        const refDict = getDict(classification);
        if (refDict) {
            tag = getInitials(name, refDict);
        }
        const dataName = tag ? `${name}_${tag}` : name;
        item.setAttribute('data-name', dataName);
    });
    filterSupplyItems();
}

function filterSupplyItems() {
    const searchInput = document.querySelector('#supplytransfer .searchInput');
    if (!searchInput) {
        return;
    }
    const searchText = searchInput.value.toLowerCase();
    const query = document.querySelectorAll('#supplytransfer .element.item');
    var count = 0;
    query.forEach((item) => {
        const itemName = item.getAttribute('data-name').toLowerCase();
        if (itemName.includes(searchText)) {
            item.style.display = '';
            count++;
        } else {
            item.style.display = 'none';
        }
    });
    // show hide message for no items found
    const noItemDiv = document.querySelector('#supplytransfer .noItem');
    if (noItemDiv) {
        noItemDiv.style.display = count ? 'none' : '';
    }
    // show/hide search clear button
    const searchClear = document.querySelector('#supplytransfer .searchClear');
    if (searchClear) {
        searchClear.style.display = searchText ? '' : 'none';
    }
}

function clearSearch() {
    const searchInput = document.querySelector('#supplytransfer .searchInput');
    if (searchInput) {
        searchInput.value = '';
        filterSupplyItems();
    }
    // this is used as onclick, return false to prevent screen movement
    return false;
}

// == helpers ==
// returns the appropriate reference dict given a classification string
// returns undefined if there is no match
function getDict(classification) {
    if (typeof classification === 'string') {
        switch(classification.toLowerCase()) {
            case 'cheese':
            case 'bait':
                return CHEESE;
            case 'charms':
            case 'trinket':
                return CHARMS;
            case 'crafting':
            case 'crafting_item':
                return CRAFTING;
            case 'special':
            case 'stat':
                return SPECIAL;
            case 'weapon':
                return WEAPON;
            case 'base':
                return BASE;
        }
    }
    // no match
    return undefined;
}

// get acronym from a given item name and refDict
// this generates an acronym if item name is valid but not in the refDict, but returns undefined otherwise
function getInitials(itemName, refDict) {
    // check refDict first
    if (refDict[itemName] != undefined) {
        return TAG_ID + refDict[itemName];
    }
    var wordList = itemName.split(' ');
    if (wordList.length < 2) { // 0-1 letter acronyms are useless
        return undefined;
    } else if (refDict == CHARMS && itemName.includes('Rift 20')) {
        return TAG_ID + 'R' + wordList[1]; // special provision for rift 20xx charms
    } else if (refDict == SPECIAL && itemName.includes('Airship')) {
        return undefined; // exclude all airship parts
    } else if (refDict == CRAFTING && itemName.includes('Theme Scrap')) {
        return undefined; // exclude all theme scraps
    } else if (refDict == CRAFTING && itemName.includes('Blueprint')) {
        return undefined; // exclude all blueprints
    }
    var acronym = '';
    for (var i=0; i < wordList.length; i++) {
        if (wordList[i].length == 0) {
            // empty word, skip
            continue;
        }
        acronym += wordList[i].charAt(0); // first letter of word
    }
    return TAG_ID + acronym.toUpperCase();
}

// This just checks if there are filters active in the trap selector
// ported wholesale from source code because "hasFilter" is public but this isn't?? Hitgrab why
function hasActiveFilters() {
    var campPage = app.pages.CampPage;
    return campPage.hasFilter('componentName') || campPage.hasFilter('componentPowerType') || campPage.hasFilter('componentTagType');
}

// start script
init();