infinite craft tweaks

recipe tracking + other various tweaks for infinite craft

当前为 2024-02-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name infinite craft tweaks
  3. // @namespace https://github.com/adrianmgg
  4. // @version 3.3.0
  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 VAL_COMBOS = new GMValue('infinitecraft_observed_combos', {});
  55. const VAL_PINNED_ELEMENTS = new GMValue('infinitecraft_pinned_elements', []);
  56. const VAL_DATA_VERSION = new GMValue('infinitecraft_data_version', 0);
  57. // TODO rename this?
  58. const GM_DATAVERSION_LATEST = 1;
  59. // TODO this should probably use the async versions of getvalue/setvalue since we're already only calling it from async code
  60. function saveCombo(lhs, rhs, result) {
  61. console.log(`crafted ${lhs} + ${rhs} -> ${result}`);
  62. const data = getCombos();
  63. if(!(result in data)) data[result] = [];
  64. const sortedLhsRhs = sortRecipeIngredients([lhs, rhs]);
  65. for(const existingPair of data[result]) {
  66. if(sortedLhsRhs[0] === existingPair[0] && sortedLhsRhs[1] === existingPair[1]) return;
  67. }
  68. const pair = [lhs, rhs];
  69. pair.sort();
  70. data[result].push(pair);
  71. VAL_COMBOS.set(data);
  72. VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
  73. }
  74. // !! this sorts in-place !!
  75. function sortRecipeIngredients(components) {
  76. // internally the site uses localeCompare() but that being locale-specific could cause some problems in our use case
  77. // it shouldn't matter though, since as long as we give these *some* consistent order it'll avoid duplicates,
  78. // that order doesn't need to be the same as the one the site uses
  79. return components.sort();
  80. }
  81. function getCombos() {
  82. const data = VAL_COMBOS.get();
  83. const dataVersion = VAL_DATA_VERSION.get();
  84. if(dataVersion > GM_DATAVERSION_LATEST) {
  85. // uh oh
  86. // not gonna even try to handle this case, just toss up an error alert
  87. const msg = `infinite craft tweaks userscript's internal save data was marked as version ${dataVersion}, but the highest expected version was ${GM_DATAVERSION_LATEST}.
  88. 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`;
  89. alert(msg);
  90. throw new Error(msg);
  91. }
  92. if(dataVersion < GM_DATAVERSION_LATEST) {
  93. // confirm that user wants to update save data
  94. 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.)
  95.  
  96. proceed with upgrading save data?`);
  97. if(!updateConfirm) {
  98. throw new Error('user chose not to update save data');
  99. }
  100. // upgrade the data
  101. if(dataVersion <= 0) {
  102. // recipes in this version weren't sorted, and may contain duplicates once sorting has been applied
  103. for(const result in data) {
  104. // sort the recipes (just do it in place, since we're not gonna use the old data again
  105. for(const recipe of data[result]) {
  106. sortRecipeIngredients(recipe);
  107. }
  108. // build new list with just the ones that remain not duplicate
  109. const newRecipesList = [];
  110. for(const recipe of data[result]) {
  111. if(!(newRecipesList.some(r => recipe[0] === r[0] && recipe[1] === r[1]))) {
  112. newRecipesList.push(recipe);
  113. }
  114. }
  115. data[result] = newRecipesList;
  116. }
  117. }
  118. // now that it's upgraded, save the upgraded data & update the version
  119. VAL_COMBOS.set(data);
  120. VAL_DATA_VERSION.set(GM_DATAVERSION_LATEST);
  121. // (fall through to retun below)
  122. }
  123. // the data is definitely current now
  124. return data;
  125. }
  126. function main() {
  127. const _getCraftResponse = icMain.getCraftResponse;
  128. const _selectElement = icMain.selectElement;
  129. const _selectInstance = icMain.selectInstance;
  130. icMain.getCraftResponse = async function(lhs, rhs) {
  131. const resp = await _getCraftResponse.apply(this, arguments);
  132. saveCombo(lhs.text, rhs.text, resp.result);
  133. return resp;
  134. };
  135.  
  136. // random element thing
  137. document.documentElement.addEventListener('mousedown', e => {
  138. if(e.buttons === 1 && e.altKey && !e.shiftKey) { // left mouse + alt
  139. e.preventDefault();
  140. e.stopPropagation();
  141. const elements = icMain._data.elements;
  142. const randomElement = elements[Math.floor(Math.random() * elements.length)];
  143. _selectElement(e, randomElement);
  144. } else if(e.buttons === 1 && !e.altKey && e.shiftKey) { // lmb + shift
  145. e.preventDefault();
  146. e.stopPropagation();
  147. const instances = icMain._data.instances;
  148. const lastInstance = instances[instances.length - 1];
  149. const lastInstanceElement = icMain._data.elements.filter(e => e.text === lastInstance.text)[0];
  150. _selectElement(e, lastInstanceElement);
  151. }
  152. }, {capture: false});
  153.  
  154. // special search handlers
  155. const searchHandlers = {
  156. 'regex:': (txt) => {
  157. const pattern = new RegExp(txt);
  158. return (element) => pattern.test(element.text);
  159. },
  160. 'regexi:': (txt) => {
  161. const pattern = new RegExp(txt, 'i');
  162. return (element) => pattern.test(element.text);
  163. },
  164. 'full:': (txt) => {
  165. return (element) => element.text === txt;
  166. },
  167. 'fulli:': (txt) => {
  168. const lower = txt.toLowerCase();
  169. return (element) => element.text.toLowerCase() === lower;
  170. },
  171. };
  172. const _sortedElements__get = icMain?._computedWatchers?.sortedElements?.getter;
  173. // if that wasn't where we expected it to be, don't try to patch it
  174. if(_sortedElements__get !== null && _sortedElements__get !== undefined) {
  175. icMain._computedWatchers.sortedElements.getter = function() {
  176. for(const handlerPrefix in searchHandlers) {
  177. if(this.searchQuery && this.searchQuery.startsWith(handlerPrefix)) {
  178. try {
  179. const filter = searchHandlers[handlerPrefix](this.searchQuery.substr(handlerPrefix.length));
  180. return this.elements.filter(filter);
  181. } catch(err) {
  182. console.error(`error during search handler '${handlerPrefix}'`, err);
  183. return [];
  184. }
  185. }
  186. }
  187. return _sortedElements__get.apply(this, arguments);
  188. }
  189. }
  190.  
  191. // get the dataset thing they use for scoping css stuff
  192. // TODO add some better handling for if there's zero/multiple dataset attrs on that element in future
  193. const cssScopeDatasetThing = Object.keys(icMain.$el.dataset)[0];
  194.  
  195. function mkElementItem(element) {
  196. return elhelper.create('div', {
  197. classList: ['item'],
  198. dataset: {[cssScopeDatasetThing]: ''},
  199. children: [
  200. elhelper.create('span', {
  201. classList: ['item-emoji'],
  202. dataset: {[cssScopeDatasetThing]: ''},
  203. textContent: element.emoji,
  204. style: {
  205. pointerEvents: 'none',
  206. },
  207. }),
  208. document.createTextNode(` ${element.text} `),
  209. ],
  210. });
  211. }
  212.  
  213. /* this will call genFn and iterate all the way through it,
  214. but taking a break every chunkSize iterations to allow rendering and stuff to happen.
  215. returns a promise. */
  216. function nonBlockingChunked(chunkSize, genFn, timeout = 0) {
  217. return new Promise((resolve, reject) => {
  218. const gen = genFn();
  219. (function doChunk() {
  220. for(let i = 0; i < chunkSize; i++) {
  221. const next = gen.next();
  222. if(next.done) {
  223. resolve();
  224. return;
  225. }
  226. }
  227. setTimeout(doChunk, timeout);
  228. })();
  229. });
  230. }
  231.  
  232. // recipes popup
  233. const recipesListContainer = elhelper.create('div', {
  234. });
  235. function clearRecipesDialog() {
  236. while(recipesListContainer.firstChild !== null) recipesListContainer.removeChild(recipesListContainer.firstChild);
  237. }
  238. const recipesDialog = elhelper.create('dialog', {
  239. parent: document.body,
  240. children: [
  241. // close button
  242. elhelper.create('button', {
  243. textContent: 'x',
  244. events: {
  245. click: (evt) => recipesDialog.close(),
  246. },
  247. }),
  248. // the main content
  249. recipesListContainer,
  250. ],
  251. style: {
  252. // need to unset this one thing from the page css
  253. margin: 'auto',
  254. },
  255. events: {
  256. close: (e) => {
  257. clearRecipesDialog();
  258. },
  259. },
  260. });
  261. async function openRecipesDialog(childGenerator) {
  262. clearRecipesDialog();
  263. // create a child to add to for just this call,
  264. // as a lazy fix for the bug we'd otherwise have where opening a menu, quickly closing it, then opening it again
  265. // would lead to the old menu's task still adding stuff to the new menu.
  266. // (this doesn't actually stop any unnecessary work, but it at least prevents the possible visual bugs)
  267. const container = elhelper.create('div', {parent: recipesListContainer});
  268. // show the dialog
  269. recipesDialog.showModal();
  270. // populate the dialog
  271. await nonBlockingChunked(512, function*() {
  272. for(const child of childGenerator()) {
  273. container.appendChild(child);
  274. yield;
  275. }
  276. });
  277. }
  278.  
  279. // recipes button
  280. function addControlsButton(label, handler) {
  281. elhelper.create('div', {
  282. parent: document.querySelector('.side-controls'),
  283. textContent: label,
  284. style: {
  285. cursor: 'pointer',
  286. },
  287. events: {
  288. click: handler,
  289. },
  290. });
  291. }
  292.  
  293. addControlsButton('recipes', () => {
  294. // build a name -> element map
  295. const byName = {};
  296. const byNameLower = {}; // for fallback stuff
  297. for(const element of icMain._data.elements) {
  298. byName[element.text] = element;
  299. byNameLower[element.text.toLowerCase()] = element;
  300. }
  301. function getByName(name) {
  302. // first, try grabbing it by its exact name
  303. const fromNormal = byName[name];
  304. if(fromNormal !== undefined) {
  305. return byName[name];
  306. }
  307. // if that doesn't do it, try that but ignoring case.
  308. // 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
  309. const fromLower = byNameLower[name.toLowerCase()];
  310. if(fromLower !== undefined) {
  311. return fromLower;
  312. }
  313. // worst case, we have neither
  314. return {emoji: "❌", text: `[userscript encountered an error trying to look up element '${name}']`};
  315. }
  316. const combos = getCombos();
  317. function listItemClick(evt) {
  318. const elementName = evt.target.dataset.comboviewerElement;
  319. document.querySelector(`[data-comboviewer-section="${CSS.escape(elementName)}"]`).scrollIntoView({block: 'nearest'});
  320. }
  321. function mkLinkedElementItem(element) {
  322. return elhelper.setup(mkElementItem(element), {
  323. events: { click: listItemClick },
  324. dataset: { comboviewerElement: element.text },
  325. });
  326. }
  327. openRecipesDialog(function*(){
  328. for(const comboResult in combos) {
  329. if(comboResult === 'Nothing') continue;
  330. // anchor for jumping to
  331. yield elhelper.create('div', {
  332. dataset: { comboviewerSection: comboResult },
  333. });
  334. for(const [lhs, rhs] of combos[comboResult]) {
  335. yield elhelper.create('div', {
  336. children: [
  337. mkLinkedElementItem(getByName(comboResult)),
  338. document.createTextNode(' = '),
  339. mkLinkedElementItem(getByName(lhs)),
  340. document.createTextNode(' + '),
  341. mkLinkedElementItem(getByName(rhs)),
  342. ],
  343. });
  344. }
  345. }
  346. });
  347. });
  348.  
  349. // first discoveries list (just gonna hijack the recipes popup for simplicity)
  350. addControlsButton('discoveries', () => {
  351. openRecipesDialog(function*() {
  352. for(const element of icMain._data.elements) {
  353. if(element.discovered) {
  354. yield mkElementItem(element);
  355. }
  356. }
  357. });
  358. });
  359.  
  360. // pinned combos thing
  361. const sidebar = document.querySelector('.container > .sidebar');
  362. const pinnedCombos = elhelper.create('div', {
  363. parent: sidebar,
  364. insertBefore: sidebar.firstChild,
  365. style: {
  366. position: 'sticky',
  367. top: '0',
  368. background: 'white',
  369. width: '100%',
  370. maxHeight: '50%',
  371. overflowY: 'auto',
  372. },
  373. });
  374. // !! does NOT save it to pins list
  375. function addPinnedElementInternal(element) {
  376. // this isnt a good variable name but it's slightly funny and sometimes that's all that matters
  377. const elementElement = mkElementItem(element);
  378. const txt = element.text;
  379. elhelper.setup(elementElement, {
  380. parent: pinnedCombos,
  381. events: {
  382. mousedown: (e) => {
  383. if(e.buttons === 4 || (e.buttons === 1 && e.altKey && !e.shiftKey)) {
  384. pinnedCombos.removeChild(elementElement);
  385. const pins = VAL_PINNED_ELEMENTS.get();
  386. VAL_PINNED_ELEMENTS.set(pins.filter(p => p !== txt));
  387. return;
  388. }
  389. icMain.selectElement(e, element);
  390. },
  391. },
  392. });
  393. }
  394. // does save it to pins list also
  395. function addPinnedElement(element) {
  396. const pins = VAL_PINNED_ELEMENTS.get();
  397. if(!(pins.some(p => p === element.text))) { // no duplicates
  398. addPinnedElementInternal(element);
  399. pins.push(element.text);
  400. VAL_PINNED_ELEMENTS.set(pins);
  401. }
  402. }
  403. icMain.selectElement = function(mouseEvent, element) {
  404. if(mouseEvent.buttons === 4 || (mouseEvent.buttons === 1 && mouseEvent.altKey && !mouseEvent.shiftKey)) {
  405. // this won't actually stop it since what gets passed into this is a mousedown event
  406. mouseEvent.preventDefault();
  407. mouseEvent.stopPropagation();
  408. addPinnedElement(element);
  409. return;
  410. }
  411. return _selectElement.apply(this, arguments);
  412. };
  413. icMain.selectInstance = function(mouseEvent, instance) {
  414. // 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
  415. if(mouseEvent.buttons === 4) {
  416. // this won't actually stop it since what gets passed into this is a mousedown event
  417. mouseEvent.preventDefault();
  418. mouseEvent.stopPropagation();
  419. addPinnedElement({text: instance.text, emoji: instance.emoji});
  420. return;
  421. }
  422. return _selectInstance.apply(this, arguments);
  423. };
  424. // load initial pinned elements
  425. (() => {
  426. const existingPins = VAL_PINNED_ELEMENTS.get();
  427. for(const pin of existingPins) {
  428. const pinElement = icMain._data.elements.find(e => e.text === pin);
  429. if(pinElement !== undefined) {
  430. addPinnedElementInternal(pinElement);
  431. }
  432. }
  433. })();
  434. }
  435. // stores the object where most of the infinite craft functions live.
  436. // can be assumed to be set by the time main is called
  437. let icMain = null;
  438. // need to wait for stuff to be actually initialized.
  439. // might be an actual thing we can hook into to detect that
  440. // but for now just waiting until the function we want exists works well enough
  441. (function waitForReady(){
  442. icMain = unsafeWindow?.$nuxt?._route?.matched?.[0]?.instances?.default;
  443. if(icMain !== undefined && icMain !== null) main();
  444. else setTimeout(waitForReady, 10);
  445. })();
  446. })();