infinite craft tweaks

recipe tracking + other various tweaks for infinite craft

  1. // ==UserScript==
  2. // @name infinite craft tweaks
  3. // @namespace https://github.com/adrianmgg
  4. // @version 3.3.7
  5. // @description recipe tracking + other various tweaks for infinite craft
  6. // @author amgg
  7. // @match https://neal.fun/infinite-craft/
  8. // @icon https://neal.fun/favicons/infinite-craft.png
  9. // @grant unsafeWindow
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @run-at document-idle
  13. // @compatible chrome
  14. // @compatible firefox
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. 'use strict';
  20. const elhelper = (function() { /* via https://github.com/adrianmgg/elhelper */
  21. function setup(elem, { style: { vars: styleVars = {}, ...style } = {}, attrs = {}, dataset = {}, events = {}, classList = [], children = [], parent = null, insertBefore = null, ...props }) {
  22. for (const k in style) elem.style[k] = style[k];
  23. for (const k in styleVars) elem.style.setProperty(k, styleVars[k]);
  24. for (const k in attrs) elem.setAttribute(k, attrs[k]);
  25. for (const k in dataset) elem.dataset[k] = dataset[k];
  26. for (const k in events) elem.addEventListener(k, events[k]);
  27. for (const c of classList) elem.classList.add(c);
  28. for (const k in props) elem[k] = props[k];
  29. for (const c of children) elem.appendChild(c);
  30. if (parent !== null) {
  31. if (insertBefore !== null) parent.insertBefore(elem, insertBefore);
  32. else parent.appendChild(elem);
  33. }
  34. return elem;
  35. }
  36. function create(tagName, options = {}) { return setup(document.createElement(tagName), options); }
  37. function createNS(namespace, tagName, options = {}) { return setup(document.createElementNS(namespace, tagName), options); }
  38. return {setup, create, createNS};
  39. })();
  40.  
  41. class GMValue {
  42. constructor(key, defaultValue) {
  43. this._key = key;
  44. this._defaultValue = defaultValue;
  45. }
  46. set(value) {
  47. GM_setValue(this._key, value);
  48. }
  49. get() {
  50. return GM_getValue(this._key, this._defaultValue);
  51. }
  52. }
  53.  
  54. const GM_DATAVERSION_LATEST = 1;
  55. const VAL_COMBOS = new GMValue('infinitecraft_observed_combos', {});
  56. const VAL_PINNED_ELEMENTS = new GMValue('infinitecraft_pinned_elements', []);
  57. const VAL_DATA_VERSION = new GMValue('infinitecraft_data_version', GM_DATAVERSION_LATEST);
  58. // TODO this should probably use the async versions of getvalue/setvalue since we're already only calling it from async code
  59. function saveCombo(lhs, rhs, result) {
  60. console.log(`crafted ${lhs} + ${rhs} -> ${result}`);
  61. const data = getCombos();
  62. if(!(result in data)) data[result] = [];
  63. const sortedLhsRhs = sortRecipeIngredients([lhs, rhs]);
  64. for(const existingPair of data[result]) {
  65. if(sortedLhsRhs[0] === existingPair[0] && sortedLhsRhs[1] === existingPair[1]) return;
  66. }
  67. const pair = [lhs, rhs];
  68. pair.sort();
  69. data[result].push(pair);
  70. VAL_COMBOS.set(data);
  71. VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
  72. }
  73. // !! this sorts in-place !!
  74. function sortRecipeIngredients(components) {
  75. // internally the site uses localeCompare() but that being locale-specific could cause some problems in our use case
  76. // it shouldn't matter though, since as long as we give these *some* consistent order it'll avoid duplicates,
  77. // that order doesn't need to be the same as the one the site uses
  78. return components.sort();
  79. }
  80. function getCombos() {
  81. const data = VAL_COMBOS.get();
  82. const dataVersion = VAL_DATA_VERSION.get();
  83. if(dataVersion > GM_DATAVERSION_LATEST) {
  84. // uh oh
  85. // not gonna even try to handle this case, just toss up an error alert
  86. const msg = `infinite craft tweaks userscript's internal save data was marked as version ${dataVersion}, but the highest expected version was ${GM_DATAVERSION_LATEST}.
  87. 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`;
  88. alert(msg);
  89. throw new Error(msg);
  90. }
  91. if(dataVersion < GM_DATAVERSION_LATEST) {
  92. // confirm that user wants to update save data
  93. 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.)
  94.  
  95. proceed with upgrading save data?`);
  96. if(!updateConfirm) {
  97. throw new Error('user chose not to update save data');
  98. }
  99. // upgrade the data
  100. if(dataVersion <= 0) {
  101. // recipes in this version weren't sorted, and may contain duplicates once sorting has been applied
  102. for(const result in data) {
  103. // sort the recipes (just do it in place, since we're not gonna use the old data again
  104. for(const recipe of data[result]) {
  105. sortRecipeIngredients(recipe);
  106. }
  107. // build new list with just the ones that remain not duplicate
  108. const newRecipesList = [];
  109. for(const recipe of data[result]) {
  110. if(!(newRecipesList.some(r => recipe[0] === r[0] && recipe[1] === r[1]))) {
  111. newRecipesList.push(recipe);
  112. }
  113. }
  114. data[result] = newRecipesList;
  115. }
  116. }
  117. // now that it's upgraded, save the upgraded data & update the version
  118. VAL_COMBOS.set(data);
  119. VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
  120. // (fall through to retun below)
  121. }
  122. // the data is definitely current now
  123. return data;
  124. }
  125.  
  126. elhelper.create('style', {
  127. parent: document.head,
  128. textContent: `
  129. .tweaks-faux-item {
  130. display: inline-block;
  131. margin: 4px;
  132. border: 1px solid var(--border-color);
  133. padding: 9px 10px 8px;
  134. border-radius: 5px;
  135. box-sizing: border-box;
  136. line-height: 1em;
  137. }
  138.  
  139. .tweaks-faux-item-emoji {
  140. }
  141. `,
  142. });
  143.  
  144. function main() {
  145. const icSidebar = icMain.$children.find(o => o.$el.id === 'sidebar');
  146.  
  147. const _craftApi = icMain.craftApi;
  148. icMain.craftApi = async function(lhs, rhs) {
  149. const resp = await _craftApi.apply(this, arguments);
  150. if(resp !== null) {
  151. saveCombo(lhs, rhs, resp.text);
  152. }
  153. return resp;
  154. };
  155.  
  156. function draggingCreateInstance(mouseEvent, element) {
  157. const instance = unsafeWindow.IC.createInstance({
  158. text: element.text,
  159. emoji: element.emoji,
  160. itemId: element.id,
  161. x: mouseEvent.clientX,
  162. y: mouseEvent.clientY,
  163. topLayer: true,
  164. animate: false,
  165. discovery: false,
  166. });
  167. icMain.setSelectedInstance(instance);
  168. instance.element.dispatchEvent(new MouseEvent('mousedown', {
  169. view: unsafeWindow,
  170. bubbles: true,
  171. cancelable: true,
  172. clientX: mouseEvent.clientX,
  173. clientY: mouseEvent.clientY,
  174. buttons: 1,
  175. button: 0,
  176. altKey: false,
  177. shiftKey: false,
  178. }));
  179. }
  180.  
  181. // random element thing
  182. document.documentElement.addEventListener('mousedown', e => {
  183. const isItem = e.target.hasAttribute('data-item');
  184. const isInstance = e.target.dataset?.instance === 'true';
  185.  
  186. let itemToInstance = null;
  187. if(e.buttons === 1 && e.altKey && !e.shiftKey) { // left mouse + alt
  188. const elements = icMain.items;
  189. itemToInstance = elements[Math.floor(Math.random() * elements.length)];
  190. }
  191. if(e.buttons === 1 && !e.altKey && e.shiftKey) { // lmb + shift
  192. const instances = unsafeWindow.IC.getInstances();
  193. const lastInstance = instances[instances.length - 1];
  194. if(lastInstance === undefined) {
  195. console.warn("skipping shift-drag behavior because there were no instances");
  196. return;
  197. }
  198. itemToInstance = icMain.items.filter(e => e.text === lastInstance.text)[0];
  199. }
  200. if(itemToInstance !== null && itemToInstance !== undefined) {
  201. e.preventDefault();
  202. e.stopPropagation();
  203. draggingCreateInstance(e, itemToInstance);
  204. return;
  205. }
  206.  
  207. let itemToPin = null;
  208. if(isItem && (e.buttons === 4 || (e.buttons === 1 && e.altKey && !e.shiftKey))) {
  209. itemToPin = icMain.items[e.target.dataset.itemId];
  210. }
  211. // (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)
  212. if(isInstance && e.buttons === 4) {
  213. const instanceText = e.target.querySelector('.instance-text').textContent;
  214. itemToPin = icMain.items.find(item => item.text === instanceText) ?? null;
  215. }
  216. if(itemToPin !== null && itemToPin !== undefined) {
  217. e.preventDefault();
  218. e.stopPropagation();
  219. addPinnedElement(itemToPin);
  220. return;
  221. }
  222. }, {capture: false});
  223.  
  224. // special search handlers
  225. const searchHandlers = {
  226. 'regex:': (txt) => {
  227. const pattern = new RegExp(txt);
  228. return (element) => pattern.test(element.text);
  229. },
  230. 'regexi:': (txt) => {
  231. const pattern = new RegExp(txt, 'i');
  232. return (element) => pattern.test(element.text);
  233. },
  234. 'full:': (txt) => {
  235. return (element) => element.text === txt;
  236. },
  237. 'fulli:': (txt) => {
  238. const lower = txt.toLowerCase();
  239. return (element) => element.text.toLowerCase() === lower;
  240. },
  241. };
  242. const _searchResults__get = icSidebar?._computedWatchers?.searchResults?.getter;
  243. // if that wasn't where we expected it to be, don't try to patch it
  244. if(_searchResults__get !== null && _searchResults__get !== undefined) {
  245. icSidebar._computedWatchers.searchResults.getter = function() {
  246. for(const handlerPrefix in searchHandlers) {
  247. if(this.searchQuery && this.searchQuery.startsWith(handlerPrefix)) {
  248. try {
  249. const filter = searchHandlers[handlerPrefix](this.searchQuery.substr(handlerPrefix.length));
  250. return this.filteredElements.filter(filter);
  251. } catch(err) {
  252. console.error(`error during search handler '${handlerPrefix}'`, err);
  253. return [];
  254. }
  255. }
  256. }
  257. return _searchResults__get.apply(this, arguments);
  258. }
  259. }
  260.  
  261. function mkElementItem(element) {
  262. return elhelper.create('div', {
  263. classList: ['tweaks-faux-item'],
  264. // dataset: {[cssScopeDatasetThing]: ''},
  265. children: [
  266. elhelper.create('span', {
  267. classList: ['tweaks-faux-item-emoji'],
  268. // dataset: {[cssScopeDatasetThing]: ''},
  269. textContent: element.emoji,
  270. style: {
  271. pointerEvents: 'none',
  272. },
  273. }),
  274. document.createTextNode(` ${element.text} `),
  275. ],
  276. });
  277. }
  278.  
  279. /* this will call genFn and iterate all the way through it,
  280. but taking a break every chunkSize iterations to allow rendering and stuff to happen.
  281. returns a promise. */
  282. function nonBlockingChunked(chunkSize, genFn, timeout = 0) {
  283. return new Promise((resolve, reject) => {
  284. const gen = genFn();
  285. (function doChunk() {
  286. for(let i = 0; i < chunkSize; i++) {
  287. const next = gen.next();
  288. if(next.done) {
  289. resolve();
  290. return;
  291. }
  292. }
  293. setTimeout(doChunk, timeout);
  294. })();
  295. });
  296. }
  297.  
  298. // recipes popup
  299. const recipesListContainer = elhelper.create('div', {
  300. });
  301. function clearRecipesDialog() {
  302. while(recipesListContainer.firstChild !== null) recipesListContainer.removeChild(recipesListContainer.firstChild);
  303. }
  304. const recipesDialog = elhelper.create('dialog', {
  305. // needs to be added to this element or a child of it, since the color scheme css is scoped to there
  306. parent: document.querySelector('.container'),
  307. children: [
  308. // close button
  309. elhelper.create('button', {
  310. textContent: 'x',
  311. events: {
  312. click: (evt) => recipesDialog.close(),
  313. },
  314. }),
  315. // the main content
  316. recipesListContainer,
  317. ],
  318. style: {
  319. // make it work with dark mode
  320. background: 'var(--sidebar-bg)',
  321. // need to unset this one thing from the page css
  322. margin: 'auto',
  323. // unset default dialog style `color: canvastext` since it prevents the dark mode css from cascading down to our dialog
  324. color: 'unset',
  325. },
  326. events: {
  327. close: (e) => {
  328. clearRecipesDialog();
  329. },
  330. },
  331. });
  332. async function openRecipesDialog(childGenerator) {
  333. clearRecipesDialog();
  334. // create a child to add to for just this call,
  335. // as a lazy fix for the bug we'd otherwise have where opening a menu, quickly closing it, then opening it again
  336. // would lead to the old menu's task still adding stuff to the new menu.
  337. // (this doesn't actually stop any unnecessary work, but it at least prevents the possible visual bugs)
  338. const container = elhelper.create('div', {parent: recipesListContainer});
  339. // show the dialog
  340. recipesDialog.showModal();
  341. // populate the dialog
  342. await nonBlockingChunked(512, function*() {
  343. for(const child of childGenerator()) {
  344. container.appendChild(child);
  345. yield;
  346. }
  347. });
  348. }
  349.  
  350. // recipes button
  351. function addControlsButton(label, handler) {
  352. elhelper.create('div', {
  353. parent: document.querySelector('.side-controls'),
  354. textContent: label,
  355. style: {
  356. cursor: 'pointer',
  357. // because they invert the section containing these elements for dark mode, we need to explicitly NOT change color for dark mode
  358. color: '#040404',
  359. },
  360. events: {
  361. click: handler,
  362. },
  363. });
  364. }
  365.  
  366. addControlsButton('recipes', () => {
  367. // build a name -> element map
  368. const byName = {};
  369. const byNameLower = {}; // for fallback stuff
  370. for(const element of unsafeWindow.IC.getItems()) {
  371. byName[element.text] = element;
  372. byNameLower[element.text.toLowerCase()] = element;
  373. }
  374. function getByName(name) {
  375. // first, try grabbing it by its exact name
  376. const fromNormal = byName[name];
  377. if(fromNormal !== undefined) {
  378. return byName[name];
  379. }
  380. // if that doesn't do it, try that but ignoring case.
  381. // 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
  382. const fromLower = byNameLower[name.toLowerCase()];
  383. if(fromLower !== undefined) {
  384. return fromLower;
  385. }
  386. // worst case, we have neither
  387. return {emoji: "❌", text: `[userscript encountered an error trying to look up element '${name}']`};
  388. }
  389. const combos = getCombos();
  390. function listItemClick(evt) {
  391. const elementName = evt.target.dataset.comboviewerElement;
  392. document.querySelector(`[data-comboviewer-section="${CSS.escape(elementName)}"]`).scrollIntoView({block: 'nearest'});
  393. }
  394. function mkLinkedElementItem(element) {
  395. return elhelper.setup(mkElementItem(element), {
  396. events: { click: listItemClick },
  397. dataset: { comboviewerElement: element.text },
  398. });
  399. }
  400. openRecipesDialog(function*(){
  401. for(const comboResult in combos) {
  402. if(comboResult === 'Nothing') continue;
  403. // anchor for jumping to
  404. yield elhelper.create('div', {
  405. dataset: { comboviewerSection: comboResult },
  406. });
  407. for(const [lhs, rhs] of combos[comboResult]) {
  408. yield elhelper.create('div', {
  409. children: [
  410. mkLinkedElementItem(getByName(comboResult)),
  411. document.createTextNode(' = '),
  412. mkLinkedElementItem(getByName(lhs)),
  413. document.createTextNode(' + '),
  414. mkLinkedElementItem(getByName(rhs)),
  415. ],
  416. });
  417. }
  418. }
  419. });
  420. });
  421.  
  422. // first discoveries list (just gonna hijack the recipes popup for simplicity)
  423. addControlsButton('discoveries', () => {
  424. openRecipesDialog(function*() {
  425. for(const element of icMain.items) {
  426. if(element.discovered) {
  427. yield mkElementItem(element);
  428. }
  429. }
  430. });
  431. });
  432.  
  433. // pinned combos thing
  434. const sidebar = document.querySelector('#sidebar');
  435. const pinnedCombos = elhelper.create('div', {
  436. parent: sidebar,
  437. insertBefore: sidebar.firstChild,
  438. style: {
  439. position: 'sticky',
  440. top: '0',
  441. background: 'var(--sidebar-bg)',
  442. width: '100%',
  443. maxHeight: '50%',
  444. overflowY: 'auto',
  445. borderBottom: '1px solid var(--border-color)',
  446. zIndex: '200',
  447. },
  448. });
  449. // !! does NOT save it to pins list
  450. function addPinnedElementInternal(element) {
  451. // this isnt a good variable name but it's slightly funny and sometimes that's all that matters
  452. const elementElement = mkElementItem(element);
  453. const txt = element.text;
  454. elhelper.setup(elementElement, {
  455. parent: pinnedCombos,
  456. events: {
  457. mousedown: (e) => {
  458. if(e.buttons === 4 || (e.buttons === 1 && e.altKey && !e.shiftKey)) {
  459. pinnedCombos.removeChild(elementElement);
  460. const pins = VAL_PINNED_ELEMENTS.get();
  461. VAL_PINNED_ELEMENTS.set(pins.filter(p => p !== txt));
  462. return;
  463. }
  464. draggingCreateInstance(e, element);
  465. },
  466. },
  467. });
  468. }
  469. // does save it to pins list also
  470. function addPinnedElement(element) {
  471. const pins = VAL_PINNED_ELEMENTS.get();
  472. if(!(pins.some(p => p === element.text))) { // no duplicates
  473. addPinnedElementInternal(element);
  474. pins.push(element.text);
  475. VAL_PINNED_ELEMENTS.set(pins);
  476. }
  477. }
  478. // load initial pinned elements
  479. (() => {
  480. const existingPins = VAL_PINNED_ELEMENTS.get();
  481. for(const pin of existingPins) {
  482. const pinElement = icMain.items.find(e => e.text === pin);
  483. if(pinElement === undefined) {
  484. console.warn(`failed to find info for pinned item '${pin}'`);
  485. } else {
  486. addPinnedElementInternal(pinElement);
  487. }
  488. }
  489. })();
  490. }
  491. // stores the object where most of the infinite craft functions live.
  492. // can be assumed to be set by the time main is called
  493. let icMain = null;
  494. // need to wait for stuff to be actually initialized.
  495. // might be an actual thing we can hook into to detect that
  496. // but for now just waiting until the function we want exists works well enough
  497. (function waitForReady(){
  498. icMain = unsafeWindow?.$nuxt?._route?.matched?.[0]?.instances?.default;
  499. // for debugging convenience
  500. unsafeWindow.__infiniteCraftTweaks__icMain = icMain;
  501. if(icMain !== undefined && icMain !== null && unsafeWindow.IC !== undefined) main();
  502. else setTimeout(waitForReady, 10);
  503. })();
  504. })();