// ==UserScript==
// @name infinite craft tweaks
// @namespace https://github.com/adrianmgg
// @version 3.3.3
// @description recipe tracking + other various tweaks for infinite craft
// @author amgg
// @match https://neal.fun/infinite-craft/
// @icon https://neal.fun/favicons/infinite-craft.png
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @compatible chrome
// @compatible firefox
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const elhelper = (function() { /* via https://github.com/adrianmgg/elhelper */
function setup(elem, { style: { vars: styleVars = {}, ...style } = {}, attrs = {}, dataset = {}, events = {}, classList = [], children = [], parent = null, insertBefore = null, ...props }) {
for (const k in style) elem.style[k] = style[k];
for (const k in styleVars) elem.style.setProperty(k, styleVars[k]);
for (const k in attrs) elem.setAttribute(k, attrs[k]);
for (const k in dataset) elem.dataset[k] = dataset[k];
for (const k in events) elem.addEventListener(k, events[k]);
for (const c of classList) elem.classList.add(c);
for (const k in props) elem[k] = props[k];
for (const c of children) elem.appendChild(c);
if (parent !== null) {
if (insertBefore !== null) parent.insertBefore(elem, insertBefore);
else parent.appendChild(elem);
}
return elem;
}
function create(tagName, options = {}) { return setup(document.createElement(tagName), options); }
function createNS(namespace, tagName, options = {}) { return setup(document.createElementNS(namespace, tagName), options); }
return {setup, create, createNS};
})();
class GMValue {
constructor(key, defaultValue) {
this._key = key;
this._defaultValue = defaultValue;
}
set(value) {
GM_setValue(this._key, value);
}
get() {
return GM_getValue(this._key, this._defaultValue);
}
}
const VAL_COMBOS = new GMValue('infinitecraft_observed_combos', {});
const VAL_PINNED_ELEMENTS = new GMValue('infinitecraft_pinned_elements', []);
const VAL_DATA_VERSION = new GMValue('infinitecraft_data_version', 0);
// TODO rename this?
const GM_DATAVERSION_LATEST = 1;
// TODO this should probably use the async versions of getvalue/setvalue since we're already only calling it from async code
function saveCombo(lhs, rhs, result) {
console.log(`crafted ${lhs} + ${rhs} -> ${result}`);
const data = getCombos();
if(!(result in data)) data[result] = [];
const sortedLhsRhs = sortRecipeIngredients([lhs, rhs]);
for(const existingPair of data[result]) {
if(sortedLhsRhs[0] === existingPair[0] && sortedLhsRhs[1] === existingPair[1]) return;
}
const pair = [lhs, rhs];
pair.sort();
data[result].push(pair);
VAL_COMBOS.set(data);
VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
}
// !! this sorts in-place !!
function sortRecipeIngredients(components) {
// internally the site uses localeCompare() but that being locale-specific could cause some problems in our use case
// it shouldn't matter though, since as long as we give these *some* consistent order it'll avoid duplicates,
// that order doesn't need to be the same as the one the site uses
return components.sort();
}
function getCombos() {
const data = VAL_COMBOS.get();
const dataVersion = VAL_DATA_VERSION.get();
if(dataVersion > GM_DATAVERSION_LATEST) {
// uh oh
// not gonna even try to handle this case, just toss up an error alert
const msg = `infinite craft tweaks userscript's internal save data was marked as version ${dataVersion}, but the highest expected version was ${GM_DATAVERSION_LATEST}.
if you've downgraded the userscript or copied save data from someone else, update the userscript and try again. otherwise, please file a bug report at https://github.amgg.gg/userscripts/issues`;
alert(msg);
throw new Error(msg);
}
if(dataVersion < GM_DATAVERSION_LATEST) {
// confirm that user wants to update save data
const updateConfirm = confirm(`infinite craft tweaks userscript's internal save data is from an earlier version, and needs to be upgraded. (if you select cancel, userscript will be non-functional, so this choice is mostly for if you want to take a moment to manually back up the data just in case.)
proceed with upgrading save data?`);
if(!updateConfirm) {
throw new Error('user chose not to update save data');
}
// upgrade the data
if(dataVersion <= 0) {
// recipes in this version weren't sorted, and may contain duplicates once sorting has been applied
for(const result in data) {
// sort the recipes (just do it in place, since we're not gonna use the old data again
for(const recipe of data[result]) {
sortRecipeIngredients(recipe);
}
// build new list with just the ones that remain not duplicate
const newRecipesList = [];
for(const recipe of data[result]) {
if(!(newRecipesList.some(r => recipe[0] === r[0] && recipe[1] === r[1]))) {
newRecipesList.push(recipe);
}
}
data[result] = newRecipesList;
}
}
// now that it's upgraded, save the upgraded data & update the version
VAL_COMBOS.set(data);
VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
// (fall through to retun below)
}
// the data is definitely current now
return data;
}
function main() {
const _getCraftResponse = icMain.getCraftResponse;
const _selectElement = icMain.selectElement;
const _selectInstance = icMain.selectInstance;
icMain.getCraftResponse = async function(lhs, rhs) {
const resp = await _getCraftResponse.apply(this, arguments);
saveCombo(lhs.text, rhs.text, resp.result);
return resp;
};
// random element thing
document.documentElement.addEventListener('mousedown', e => {
if(e.buttons === 1 && e.altKey && !e.shiftKey) { // left mouse + alt
e.preventDefault();
e.stopPropagation();
const elements = icMain._data.elements;
const randomElement = elements[Math.floor(Math.random() * elements.length)];
_selectElement(e, randomElement);
} else if(e.buttons === 1 && !e.altKey && e.shiftKey) { // lmb + shift
e.preventDefault();
e.stopPropagation();
const instances = icMain._data.instances;
const lastInstance = instances[instances.length - 1];
const lastInstanceElement = icMain._data.elements.filter(e => e.text === lastInstance.text)[0];
_selectElement(e, lastInstanceElement);
}
}, {capture: false});
// special search handlers
const searchHandlers = {
'regex:': (txt) => {
const pattern = new RegExp(txt);
return (element) => pattern.test(element.text);
},
'regexi:': (txt) => {
const pattern = new RegExp(txt, 'i');
return (element) => pattern.test(element.text);
},
'full:': (txt) => {
return (element) => element.text === txt;
},
'fulli:': (txt) => {
const lower = txt.toLowerCase();
return (element) => element.text.toLowerCase() === lower;
},
};
const _searchResults__get = icMain?._computedWatchers?.searchResults?.getter;
// if that wasn't where we expected it to be, don't try to patch it
if(_searchResults__get !== null && _searchResults__get !== undefined) {
icMain._computedWatchers.searchResults.getter = function() {
for(const handlerPrefix in searchHandlers) {
if(this.searchQuery && this.searchQuery.startsWith(handlerPrefix)) {
try {
const filter = searchHandlers[handlerPrefix](this.searchQuery.substr(handlerPrefix.length));
let elements = this.elements;
// discovered filter is cheap, do it early to avoid unnecessary work later
if(this.showDiscoveredOnly) { elements = elements.filter(el => el.discovered); }
// do the actual filter
elements = elements.filter(filter);
// sort after all the filtering is done - doesn't change the result but there's no use sorting what we're not gonna see
// `elements` is already the result of a `.filter()` call, so sorting `elements` in-place here shouldn't modify the underlying data
// (sort functions are grabbed directly from the minified page source)
if(this?.sortBy === 'name') {
elements = elements.sort((function(a,b){return a.text.localeCompare(b.text)}));
} else if(this?.sortBy === 'emoji') {
elements = elements.sort((function(a,b){var e=a.emoji||"⬜",t=b.emoji||"⬜";return e.localeCompare(t)}));
}
return elements;
} catch(err) {
console.error(`error during search handler '${handlerPrefix}'`, err);
return [];
}
}
}
return _searchResults__get.apply(this, arguments);
}
}
// get the dataset thing they use for scoping css stuff
// TODO add some better handling for if there's zero/multiple dataset attrs on that element in future
const cssScopeDatasetThing = Object.keys(icMain.$el.dataset)[0];
function mkElementItem(element) {
return elhelper.create('div', {
classList: ['item'],
dataset: {[cssScopeDatasetThing]: ''},
children: [
elhelper.create('span', {
classList: ['item-emoji'],
dataset: {[cssScopeDatasetThing]: ''},
textContent: element.emoji,
style: {
pointerEvents: 'none',
},
}),
document.createTextNode(` ${element.text} `),
],
});
}
/* this will call genFn and iterate all the way through it,
but taking a break every chunkSize iterations to allow rendering and stuff to happen.
returns a promise. */
function nonBlockingChunked(chunkSize, genFn, timeout = 0) {
return new Promise((resolve, reject) => {
const gen = genFn();
(function doChunk() {
for(let i = 0; i < chunkSize; i++) {
const next = gen.next();
if(next.done) {
resolve();
return;
}
}
setTimeout(doChunk, timeout);
})();
});
}
// recipes popup
const recipesListContainer = elhelper.create('div', {
});
function clearRecipesDialog() {
while(recipesListContainer.firstChild !== null) recipesListContainer.removeChild(recipesListContainer.firstChild);
}
const recipesDialog = elhelper.create('dialog', {
// needs to be added to this element or a child of it, since the color scheme css is scoped to there
parent: document.querySelector('.container'),
children: [
// close button
elhelper.create('button', {
textContent: 'x',
events: {
click: (evt) => recipesDialog.close(),
},
}),
// the main content
recipesListContainer,
],
style: {
// make it work with dark mode
background: 'var(--sidebar-bg)',
// need to unset this one thing from the page css
margin: 'auto',
// unset default dialog style `color: canvastext` since it prevents the dark mode css from cascading down to our dialog
color: 'unset',
},
events: {
close: (e) => {
clearRecipesDialog();
},
},
});
async function openRecipesDialog(childGenerator) {
clearRecipesDialog();
// create a child to add to for just this call,
// as a lazy fix for the bug we'd otherwise have where opening a menu, quickly closing it, then opening it again
// would lead to the old menu's task still adding stuff to the new menu.
// (this doesn't actually stop any unnecessary work, but it at least prevents the possible visual bugs)
const container = elhelper.create('div', {parent: recipesListContainer});
// show the dialog
recipesDialog.showModal();
// populate the dialog
await nonBlockingChunked(512, function*() {
for(const child of childGenerator()) {
container.appendChild(child);
yield;
}
});
}
// recipes button
function addControlsButton(label, handler) {
elhelper.create('div', {
parent: document.querySelector('.side-controls'),
textContent: label,
style: {
cursor: 'pointer',
// because they invert the section containing these elements for dark mode, we need to explicitly NOT change color for dark mode
color: '#040404',
},
events: {
click: handler,
},
});
}
addControlsButton('recipes', () => {
// build a name -> element map
const byName = {};
const byNameLower = {}; // for fallback stuff
for(const element of icMain._data.elements) {
byName[element.text] = element;
byNameLower[element.text.toLowerCase()] = element;
}
function getByName(name) {
// first, try grabbing it by its exact name
const fromNormal = byName[name];
if(fromNormal !== undefined) {
return byName[name];
}
// if that doesn't do it, try that but ignoring case.
// i think it doesn't accept new elements if they're case-insensitive equal to an element the user already has? or something like that at least
const fromLower = byNameLower[name.toLowerCase()];
if(fromLower !== undefined) {
return fromLower;
}
// worst case, we have neither
return {emoji: "❌", text: `[userscript encountered an error trying to look up element '${name}']`};
}
const combos = getCombos();
function listItemClick(evt) {
const elementName = evt.target.dataset.comboviewerElement;
document.querySelector(`[data-comboviewer-section="${CSS.escape(elementName)}"]`).scrollIntoView({block: 'nearest'});
}
function mkLinkedElementItem(element) {
return elhelper.setup(mkElementItem(element), {
events: { click: listItemClick },
dataset: { comboviewerElement: element.text },
});
}
openRecipesDialog(function*(){
for(const comboResult in combos) {
if(comboResult === 'Nothing') continue;
// anchor for jumping to
yield elhelper.create('div', {
dataset: { comboviewerSection: comboResult },
});
for(const [lhs, rhs] of combos[comboResult]) {
yield elhelper.create('div', {
children: [
mkLinkedElementItem(getByName(comboResult)),
document.createTextNode(' = '),
mkLinkedElementItem(getByName(lhs)),
document.createTextNode(' + '),
mkLinkedElementItem(getByName(rhs)),
],
});
}
}
});
});
// first discoveries list (just gonna hijack the recipes popup for simplicity)
addControlsButton('discoveries', () => {
openRecipesDialog(function*() {
for(const element of icMain._data.elements) {
if(element.discovered) {
yield mkElementItem(element);
}
}
});
});
// pinned combos thing
const sidebar = document.querySelector('.container > .sidebar');
const pinnedCombos = elhelper.create('div', {
parent: sidebar,
insertBefore: sidebar.firstChild,
style: {
position: 'sticky',
top: '0',
background: 'var(--sidebar-bg)',
width: '100%',
maxHeight: '50%',
overflowY: 'auto',
borderBottom: '1px solid var(--border-color)',
},
});
// !! does NOT save it to pins list
function addPinnedElementInternal(element) {
// this isnt a good variable name but it's slightly funny and sometimes that's all that matters
const elementElement = mkElementItem(element);
const txt = element.text;
elhelper.setup(elementElement, {
parent: pinnedCombos,
events: {
mousedown: (e) => {
if(e.buttons === 4 || (e.buttons === 1 && e.altKey && !e.shiftKey)) {
pinnedCombos.removeChild(elementElement);
const pins = VAL_PINNED_ELEMENTS.get();
VAL_PINNED_ELEMENTS.set(pins.filter(p => p !== txt));
return;
}
icMain.selectElement(e, element);
},
},
});
}
// does save it to pins list also
function addPinnedElement(element) {
const pins = VAL_PINNED_ELEMENTS.get();
if(!(pins.some(p => p === element.text))) { // no duplicates
addPinnedElementInternal(element);
pins.push(element.text);
VAL_PINNED_ELEMENTS.set(pins);
}
}
icMain.selectElement = function(mouseEvent, element) {
if(mouseEvent.buttons === 4 || (mouseEvent.buttons === 1 && mouseEvent.altKey && !mouseEvent.shiftKey)) {
// this won't actually stop it since what gets passed into this is a mousedown event
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
addPinnedElement(element);
return;
}
return _selectElement.apply(this, arguments);
};
icMain.selectInstance = function(mouseEvent, instance) {
// specifically don't do alt-lmb alias for instances, since it ends up being accidentally set off a bunch by the alt-drag random element feature
if(mouseEvent.buttons === 4) {
// this won't actually stop it since what gets passed into this is a mousedown event
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
addPinnedElement({text: instance.text, emoji: instance.emoji});
return;
}
return _selectInstance.apply(this, arguments);
};
// load initial pinned elements
(() => {
const existingPins = VAL_PINNED_ELEMENTS.get();
for(const pin of existingPins) {
const pinElement = icMain._data.elements.find(e => e.text === pin);
if(pinElement !== undefined) {
addPinnedElementInternal(pinElement);
}
}
})();
}
// stores the object where most of the infinite craft functions live.
// can be assumed to be set by the time main is called
let icMain = null;
// need to wait for stuff to be actually initialized.
// might be an actual thing we can hook into to detect that
// but for now just waiting until the function we want exists works well enough
(function waitForReady(){
icMain = unsafeWindow?.$nuxt?._route?.matched?.[0]?.instances?.default;
if(icMain !== undefined && icMain !== null) main();
else setTimeout(waitForReady, 10);
})();
})();