infinite craft tweaks

recipe tracking + other various tweaks for infinite craft

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

  1. // ==UserScript==
  2. // @name infinite craft tweaks
  3. // @namespace https://github.com/adrianmgg
  4. // @version 3.3.1
  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. // needs to be added to this element or a child of it, since the color scheme css is scoped to there
  240. parent: document.querySelector('.container'),
  241. children: [
  242. // close button
  243. elhelper.create('button', {
  244. textContent: 'x',
  245. events: {
  246. click: (evt) => recipesDialog.close(),
  247. },
  248. }),
  249. // the main content
  250. recipesListContainer,
  251. ],
  252. style: {
  253. // make it work with dark mode
  254. background: 'var(--sidebar-bg)',
  255. // need to unset this one thing from the page css
  256. margin: 'auto',
  257. // unset default dialog style `color: canvastext` since it prevents the dark mode css from cascading down to our dialog
  258. color: 'unset',
  259. },
  260. events: {
  261. close: (e) => {
  262. clearRecipesDialog();
  263. },
  264. },
  265. });
  266. async function openRecipesDialog(childGenerator) {
  267. clearRecipesDialog();
  268. // create a child to add to for just this call,
  269. // as a lazy fix for the bug we'd otherwise have where opening a menu, quickly closing it, then opening it again
  270. // would lead to the old menu's task still adding stuff to the new menu.
  271. // (this doesn't actually stop any unnecessary work, but it at least prevents the possible visual bugs)
  272. const container = elhelper.create('div', {parent: recipesListContainer});
  273. // show the dialog
  274. recipesDialog.showModal();
  275. // populate the dialog
  276. await nonBlockingChunked(512, function*() {
  277. for(const child of childGenerator()) {
  278. container.appendChild(child);
  279. yield;
  280. }
  281. });
  282. }
  283.  
  284. // recipes button
  285. function addControlsButton(label, handler) {
  286. elhelper.create('div', {
  287. parent: document.querySelector('.side-controls'),
  288. textContent: label,
  289. style: {
  290. cursor: 'pointer',
  291. // because they invert the section containing these elements for dark mode, we need to explicitly NOT change color for dark mode
  292. color: '#040404',
  293. },
  294. events: {
  295. click: handler,
  296. },
  297. });
  298. }
  299.  
  300. addControlsButton('recipes', () => {
  301. // build a name -> element map
  302. const byName = {};
  303. const byNameLower = {}; // for fallback stuff
  304. for(const element of icMain._data.elements) {
  305. byName[element.text] = element;
  306. byNameLower[element.text.toLowerCase()] = element;
  307. }
  308. function getByName(name) {
  309. // first, try grabbing it by its exact name
  310. const fromNormal = byName[name];
  311. if(fromNormal !== undefined) {
  312. return byName[name];
  313. }
  314. // if that doesn't do it, try that but ignoring case.
  315. // 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
  316. const fromLower = byNameLower[name.toLowerCase()];
  317. if(fromLower !== undefined) {
  318. return fromLower;
  319. }
  320. // worst case, we have neither
  321. return {emoji: "❌", text: `[userscript encountered an error trying to look up element '${name}']`};
  322. }
  323. const combos = getCombos();
  324. function listItemClick(evt) {
  325. const elementName = evt.target.dataset.comboviewerElement;
  326. document.querySelector(`[data-comboviewer-section="${CSS.escape(elementName)}"]`).scrollIntoView({block: 'nearest'});
  327. }
  328. function mkLinkedElementItem(element) {
  329. return elhelper.setup(mkElementItem(element), {
  330. events: { click: listItemClick },
  331. dataset: { comboviewerElement: element.text },
  332. });
  333. }
  334. openRecipesDialog(function*(){
  335. for(const comboResult in combos) {
  336. if(comboResult === 'Nothing') continue;
  337. // anchor for jumping to
  338. yield elhelper.create('div', {
  339. dataset: { comboviewerSection: comboResult },
  340. });
  341. for(const [lhs, rhs] of combos[comboResult]) {
  342. yield elhelper.create('div', {
  343. children: [
  344. mkLinkedElementItem(getByName(comboResult)),
  345. document.createTextNode(' = '),
  346. mkLinkedElementItem(getByName(lhs)),
  347. document.createTextNode(' + '),
  348. mkLinkedElementItem(getByName(rhs)),
  349. ],
  350. });
  351. }
  352. }
  353. });
  354. });
  355.  
  356. // first discoveries list (just gonna hijack the recipes popup for simplicity)
  357. addControlsButton('discoveries', () => {
  358. openRecipesDialog(function*() {
  359. for(const element of icMain._data.elements) {
  360. if(element.discovered) {
  361. yield mkElementItem(element);
  362. }
  363. }
  364. });
  365. });
  366.  
  367. // pinned combos thing
  368. const sidebar = document.querySelector('.container > .sidebar');
  369. const pinnedCombos = elhelper.create('div', {
  370. parent: sidebar,
  371. insertBefore: sidebar.firstChild,
  372. style: {
  373. position: 'sticky',
  374. top: '0',
  375. background: 'var(--sidebar-bg)',
  376. width: '100%',
  377. maxHeight: '50%',
  378. overflowY: 'auto',
  379. borderBottom: '1px solid var(--border-color)',
  380. },
  381. });
  382. // !! does NOT save it to pins list
  383. function addPinnedElementInternal(element) {
  384. // this isnt a good variable name but it's slightly funny and sometimes that's all that matters
  385. const elementElement = mkElementItem(element);
  386. const txt = element.text;
  387. elhelper.setup(elementElement, {
  388. parent: pinnedCombos,
  389. events: {
  390. mousedown: (e) => {
  391. if(e.buttons === 4 || (e.buttons === 1 && e.altKey && !e.shiftKey)) {
  392. pinnedCombos.removeChild(elementElement);
  393. const pins = VAL_PINNED_ELEMENTS.get();
  394. VAL_PINNED_ELEMENTS.set(pins.filter(p => p !== txt));
  395. return;
  396. }
  397. icMain.selectElement(e, element);
  398. },
  399. },
  400. });
  401. }
  402. // does save it to pins list also
  403. function addPinnedElement(element) {
  404. const pins = VAL_PINNED_ELEMENTS.get();
  405. if(!(pins.some(p => p === element.text))) { // no duplicates
  406. addPinnedElementInternal(element);
  407. pins.push(element.text);
  408. VAL_PINNED_ELEMENTS.set(pins);
  409. }
  410. }
  411. icMain.selectElement = function(mouseEvent, element) {
  412. if(mouseEvent.buttons === 4 || (mouseEvent.buttons === 1 && mouseEvent.altKey && !mouseEvent.shiftKey)) {
  413. // this won't actually stop it since what gets passed into this is a mousedown event
  414. mouseEvent.preventDefault();
  415. mouseEvent.stopPropagation();
  416. addPinnedElement(element);
  417. return;
  418. }
  419. return _selectElement.apply(this, arguments);
  420. };
  421. icMain.selectInstance = function(mouseEvent, instance) {
  422. // 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
  423. if(mouseEvent.buttons === 4) {
  424. // this won't actually stop it since what gets passed into this is a mousedown event
  425. mouseEvent.preventDefault();
  426. mouseEvent.stopPropagation();
  427. addPinnedElement({text: instance.text, emoji: instance.emoji});
  428. return;
  429. }
  430. return _selectInstance.apply(this, arguments);
  431. };
  432. // load initial pinned elements
  433. (() => {
  434. const existingPins = VAL_PINNED_ELEMENTS.get();
  435. for(const pin of existingPins) {
  436. const pinElement = icMain._data.elements.find(e => e.text === pin);
  437. if(pinElement !== undefined) {
  438. addPinnedElementInternal(pinElement);
  439. }
  440. }
  441. })();
  442. }
  443. // stores the object where most of the infinite craft functions live.
  444. // can be assumed to be set by the time main is called
  445. let icMain = null;
  446. // need to wait for stuff to be actually initialized.
  447. // might be an actual thing we can hook into to detect that
  448. // but for now just waiting until the function we want exists works well enough
  449. (function waitForReady(){
  450. icMain = unsafeWindow?.$nuxt?._route?.matched?.[0]?.instances?.default;
  451. if(icMain !== undefined && icMain !== null) main();
  452. else setTimeout(waitForReady, 10);
  453. })();
  454. })();