Created
February 6, 2024 00:15
-
-
Save Eklei/0fd9e348c901463900f73a1d146a2675 to your computer and use it in GitHub Desktop.
JS - Infinite Craft (neal.fun) BETTERIFIED - Tracks known combos, and allows exporting and importing your save with ctrl+c and ctrl+v.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Infinite Craft (neal.fun) BETTERIFIED | |
// @version 2024.02.05 | |
// @author Eklei | |
// @namespace https://github.com/Eklei | |
// @description Tracks known combos, and allows exporting and importing your save with ctrl+c and ctrl+v. | |
// @match https://neal.fun/infinite-craft/* | |
// @grant none | |
// @run-at document-start | |
// ==/UserScript== | |
'use strict'; | |
const validSaveInterval = 100; //Defensive programming so that it doesn't save dozens of times per second while the user is moving an item around with the mouse. Turns out to be unnecessary because MutationObserver doesn't respond to style changes. Which is weird and probably unintended because it does respond to class changes. MDN says that with default arguments it should either respond to all attribute changes or none. | |
const useFastHoverSearch = true; //If something goes wrong (because I screwed something up), set this to false to use the simpler foolproof code that just checks every item. | |
const tipMargin = 24; //How far the tooltip should be from the mouse. Also affects its horizontal position. | |
let nextValidSaveTime = 0; | |
let deferredSaveTimeout = 0; | |
let tooltip = document.createElement('div'); | |
window.$knownCombosDict = {}; | |
window.fetch_OG = window.fetch; | |
window.fetch = async (...args) => { | |
let [resource, config ] = args; | |
const response = await fetch_OG(resource, config); | |
response.clone().json().then(data=>{ | |
if (!data.emoji || !data.result) return; | |
let hash = data.emoji + data.result; | |
if (typeof hash != "string") return; | |
let combo = ('' + resource).replace(/^.+\/pair\?first=(.+?)\&second=(.+?)$/, '$1 + $2'); | |
if (!window.$knownCombosDict[hash]) window.$knownCombosDict[hash] = []; | |
if (window.$knownCombosDict[hash].indexOf(combo) == -1) window.$knownCombosDict[hash].push(combo); | |
}); | |
return response; | |
}; | |
function isWithin(x, y, rect) { | |
return (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom); | |
} | |
function cobbleSelectionRanges() { //Polyfill for Firefox where window.getSelection().toString() is broken | |
let selection = window.getSelection(); | |
for (var i=0, n=selection.rangeCount, r=''; i<n; i++) | |
r += selection.getRangeAt(i).toString(); | |
return r; | |
} | |
function cobbleInputSelection(merefocus=false) { //More polyfill for Firefox that is unnecessary in Chrome | |
let el = document.activeElement; | |
if (el.tagName == 'TEXTAREA' || (el.tagName == "INPUT" && el.type == "text")) | |
return merefocus || el.value.substring(el.selectionStart, el.selectionEnd); | |
return ''; | |
} | |
document.addEventListener('DOMContentLoaded', ()=>{ | |
tooltip.id = 'known-combos-tooltip'; | |
const cssDefault = 'position: fixed; z-index: 9999; pointer-events: none; user-select: none; background: #fffe; color: #000; padding: 5px; border-radius: 5px; border: 1px solid #c8c8c8; transition: opacity 0.1s;' | |
const cssHidden = ' opacity: 0;'; | |
tooltip.style.cssText = cssDefault + cssHidden; | |
document.body.prepend(tooltip); | |
setTimeout( ()=>{ | |
document.addEventListener('mousemove', e=>{ | |
let mouseX = e.clientX; | |
let mouseY = e.clientY; | |
let items = document.querySelectorAll('.sidebar .item'); | |
let numItems = items.length; | |
let viewportW = document.documentElement.clientWidth; | |
let maxItemsPerLine = Math.ceil(1 + 0.5*viewportW/60); | |
let nudge = Math.min(viewportW - mouseX, 3*tipMargin); | |
let offset = (mouseX + nudge - tipMargin - tooltip.offsetWidth); | |
let cssCombined = cssDefault + ' left: ' + offset + 'px; top: ' + (mouseY + tipMargin) + 'px;' + cssHidden; | |
let wrapCount = 0; | |
let wrapHTML = ""; | |
let doBoundsCheckClosure = function(min, max) { | |
for (var i=min; i<max; i++) { | |
let el = items[i]; | |
if ( isWithin(mouseX, mouseY, el.getBoundingClientRect()) ) { | |
let hash = el.innerText.replace(/^\s*(.+?) (.+?)\s*$/, '$1$2'); | |
tooltip.innerHTML = '<b>Known combos for ' + hash + ':</b>'; | |
offset = (mouseX + nudge - tipMargin - tooltip.offsetWidth); | |
cssCombined = cssDefault + ' left: ' + offset + 'px; top: ' + (mouseY + tipMargin) + 'px;'; | |
if (window.$knownCombosDict[hash]) | |
for (const combo of window.$knownCombosDict[hash]) { | |
wrapHTML += '<div>• ' + combo; | |
wrapCount++; | |
} | |
for (var i=0; i<wrapCount; i++) | |
wrapHTML += '</div>' | |
tooltip.innerHTML += wrapHTML; | |
break; | |
} | |
} | |
} | |
if (!useFastHoverSearch) { //Checking every item is fine when you only have a few dozen... | |
doBoundsCheckClosure(0, numItems); | |
} else { //But that starts to chug when you have thousands... | |
let binarySplitter = 0.5*numItems; | |
let mid = Math.round(binarySplitter); | |
while (binarySplitter > 0.5) { | |
binarySplitter *= 0.5; | |
let rect = items[mid - 1].getBoundingClientRect(); | |
if (mouseY < rect.top - 1) { | |
mid = Math.round(mid - binarySplitter); | |
} else if (mouseY > rect.bottom + 1) { | |
mid = Math.round(mid + binarySplitter); | |
} else { | |
let min = Math.max(0, mid - maxItemsPerLine); | |
let max = Math.min(mid + maxItemsPerLine, numItems); | |
doBoundsCheckClosure(min, max); | |
break; | |
} | |
} | |
} | |
tooltip.style.cssText = cssCombined; | |
}); | |
document.addEventListener('keydown', e=>{ | |
if (e.ctrlKey && e.key == 'f') { | |
let searchInput = document.querySelector('.sidebar-controls .sidebar-input'); | |
searchInput.focus(); | |
searchInput.select(); | |
event.preventDefault(); | |
} | |
}); | |
document.addEventListener('copy', (event) => { | |
let selectedText = window.getSelection().toString(); | |
if (!selectedText) selectedText = cobbleSelectionRanges(); | |
if (!selectedText) selectedText = cobbleInputSelection(); | |
if (selectedText) return; | |
let clipboardData = event.clipboardData || window.clipboardData || event.originalEvent.clipboardData; | |
clipboardData.setData('text/plain', JSON.stringify(localStorage)); | |
event.preventDefault(); | |
}); | |
document.addEventListener('paste', (event) => { | |
let selectedText = window.getSelection().toString(); | |
if (!selectedText) selectedText = cobbleSelectionRanges(); | |
if (!selectedText) selectedText = cobbleInputSelection(true); | |
if (selectedText) return; | |
let paste = (event.clipboardData || window.clipboardData).getData("text"); | |
try { | |
let parsedObject = JSON.parse(paste); | |
if (window.confirm('You seem to have pasted valid JSON. Do you want to import a save, overwriting your current one?')) { | |
for (const [key, value] of Object.entries(parsedObject)) { | |
localStorage.setItem(key, value); | |
} | |
event.preventDefault(); | |
document.location.reload(); | |
} | |
} catch (error) { | |
window.alert('You have attempted to paste something, but I did not understand it.\nError code: ' + error); | |
} | |
}); | |
//Adapted from code by madacol: https://news.ycombinator.com/item?id=39234921 | |
//Autosave was added to the base game while I was working on this, so now it's stripped down just to save the combos. | |
const exportState = () => { | |
nextValidSaveTime = Date.now() + validSaveInterval; | |
localStorage.setItem('knownCombos', JSON.stringify(window.$knownCombosDict)); | |
} | |
const importState = (state) => { | |
const knownCombos = JSON.parse(state); | |
if (knownCombos) window.$knownCombosDict = knownCombos; | |
}; | |
//Set up a MutationObserver to listen for changes in the DOM and automatically export the current state. | |
const observer = new MutationObserver((mutations) => { | |
if (Date.now() < nextValidSaveTime) { | |
clearTimeout(deferredSaveTimeout); | |
deferredSaveTimeout = setTimeout(exportState, validSaveInterval); | |
} else { | |
exportState(); | |
} | |
}); | |
//Start observing DOM changes to auto-save the game state. | |
const startObserving = () => { | |
const targetNode = document.querySelector('.container'); //This was originally .sidebar, but the sidebar doesn't change when new combos are found for existing items, so we need to observe more. | |
observer.observe(targetNode, { childList: true, subtree: true }); | |
}; | |
//Check for a saved state in localStorage and import it if available. | |
const savedState = localStorage.getItem('knownCombos'); | |
if (savedState) importState(savedState); | |
else exportState(); | |
//Finally, start running the MutationObserver. | |
startObserving(); | |
}, 100); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment