');
+ inner.addClass('glyphicon glyphicon-random');
+ button.addClass('btn btn-sm btn-primary');
+ button.append(inner);
+ button.click(fillDeck);
+ eventManager.on('underscript:ready', () => {
+ button.hover(hover.show(
+ getTranslationArray('underscript.general.deck.fill')
+ .join(' '),
+ ), hover.hide);
+ });
+ const clearDeck = $('#yourCardList > button:last');
+ clearDeck.after(' ', button);
+});
+
+function getMode({ ctrlKey = false, shiftKey = false }) {
+ if (ctrlKey) return true;
+ if (shiftKey) return false;
+ return undefined;
+}
+
+function random(array = []) {
+ return array[rand(array.length)];
+}
diff --git a/src/base/library/deck/storage.js b/src/base/library/deck/storage.js
new file mode 100644
index 00000000..26f770ea
--- /dev/null
+++ b/src/base/library/deck/storage.js
@@ -0,0 +1,271 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import * as hover from 'src/utils/hover.js';
+import style from 'src/utils/style.js';
+import * as deckLoader from 'src/utils/loadDeck.js';
+import compound from 'src/utils/compoundEvent.js';
+import hasOwn from 'src/utils/hasOwn.js';
+import { cardName } from 'src/utils/cardHelper.js';
+import { translateText } from 'src/utils/translate';
+import { getTranslationArray } from 'src/base/underscript/translation';
+import Translation from 'src/structures/constants/translation.ts';
+
+// TODO: translation
+const setting = settings.register({
+ name: 'Disable Deck Storage',
+ key: 'underscript.storage.disable',
+ refresh: () => onPage('Decks'),
+ page: 'Library',
+});
+
+const rows = settings.register({
+ // TODO: translation
+ name: 'Deck Storage Rows',
+ key: 'underscript.storage.rows',
+ type: 'select',
+ // TODO: translation
+ options: ['1', '2', '3', '4', '5', '6'],
+ refresh: () => onPage('Decks'),
+ extraPrefix: 'underscript.deck.',
+ page: 'Library',
+});
+
+onPage('Decks', function deckStorage() {
+ if (setting.value()) return;
+ style.add('.btn-storage { margin-top: 5px; margin-right: 6px; width: 30px; padding: 5px 0; }');
+
+ function getFromLibrary(id, library = [], shiny = undefined) {
+ return library.find((card) => card.id === id && (shiny === undefined || card.shiny === shiny));
+ }
+ function getCardData(id, shiny) {
+ // TODO: Filter out cards with insufficient quantity
+ const library = global('deckCollections', 'collections');
+ return getFromLibrary(id, library[global('soul')], shiny);
+ }
+
+ eventManager.on('jQuery', () => {
+ const container = $('');
+ const buttons = [];
+
+ for (let i = 1, x = Math.max(parseInt(rows.value(), 10), 1) * 5; i <= x; i++) {
+ buttons.push($('')
+ .text(i)
+ .addClass('btn btn-sm btn-danger btn-storage'));
+ }
+
+ container.append(buttons);
+
+ $('#deckCardsCanvas').prev('br').remove();
+ $('#deckCardsCanvas').before(container);
+
+ eventManager.on('Deck:Soul', loadStorage);
+
+ function fixDeck(id) {
+ const key = getKey(id);
+ const deck = JSON.parse(localStorage.getItem(key));
+ if (!deck) return;
+ if (!hasOwn(deck, 'cards')) {
+ localStorage.setItem(key, JSON.stringify({
+ cards: deck,
+ artifacts: [],
+ }));
+ }
+ }
+
+ function saveDeck(i) {
+ const deck = {
+ cards: [],
+ artifacts: [],
+ };
+ const clazz = global('soul');
+ global('decks')[clazz].forEach(({ id, shiny }) => {
+ const card = { id };
+ if (shiny) {
+ card.shiny = true;
+ }
+ deck.cards.push(card);
+ });
+ global('decksArtifacts')[clazz].forEach(({ id }) => deck.artifacts.push(id));
+ if (!deck.cards.length && !deck.artifacts.length) return;
+ localStorage.setItem(getKey(i), JSON.stringify(deck));
+ }
+
+ function loadDeck(i) {
+ if (i === null) return;
+ deckLoader.load({
+ cards: getDeck(i, true),
+ artifacts: getArtifacts(i),
+ });
+ }
+
+ function getKey(id) {
+ return `underscript.deck.${global('selfId')}.${global('soul')}.${id}`;
+ }
+
+ function getDeck(deckId, trim) {
+ fixDeck(deckId);
+ const key = getKey(deckId);
+ const deck = JSON.parse(localStorage.getItem(key));
+ if (!deck) return null;
+ if (trim) {
+ // TODO: Filter out cards with insufficient quantity
+ return deck.cards.filter(({ id, shiny }) => getCardData(id, shiny));
+ }
+ return deck.cards;
+ }
+
+ function getArtifacts(id) {
+ fixDeck(id);
+ const key = getKey(id);
+ const deck = JSON.parse(localStorage.getItem(key));
+ if (!deck) return [];
+ const userArtifacts = global('userArtifacts');
+ const arts = (deck.artifacts || [])
+ .filter((art) => userArtifacts.some(({ id: artID }) => artID === art));
+ if (arts.length > 1) {
+ const legend = arts.find((art) => {
+ const artifact = userArtifacts.find(({ id: artID }) => artID === art);
+ if (artifact) {
+ return !!artifact.legendary;
+ }
+ return false;
+ });
+ if (legend) {
+ return [legend];
+ }
+ }
+ return arts;
+ }
+
+ function cards(list) {
+ const names = [];
+ list.forEach((card) => {
+ // TODO: Filter out cards with insufficient quantity
+ let data = getCardData(card.id, card.shiny);
+ const name = data ?
+ `${cardName(data)} ` :
+ // TODO: translation
+ `${(data = getFromLibrary(card.id, global('allCards'))) && cardName(data) || 'Deleted'} (Missing) `;
+ names.push(`- ${card.shiny ? 'S ' : ''}${name}`);
+ });
+ return names.join(' ');
+ }
+
+ function artifacts(id) {
+ const list = [];
+ const userArtifacts = global('userArtifacts');
+ getArtifacts(id).forEach((art) => {
+ const artifact = userArtifacts.find(({ id: artID }) => artID === art);
+ if (artifact) {
+ const name = translateText(`artifact-name-${artifact.id}`);
+ list.push(` ${name} `);
+ }
+ });
+ return list.join(', ');
+ }
+
+ function loadStorage() {
+ buttons.forEach(loadButton);
+ }
+
+ function loadButton(button, i) {
+ const soul = global('soul');
+ const deckKey = getKey(i);
+ const nameKey = `${deckKey}.name`;
+ button.off('.deckStorage'); // Remove any lingering events
+ function refreshHover() {
+ hover.hide();
+ button.trigger('mouseenter');
+ }
+ function saveButton() {
+ saveDeck(i);
+ refreshHover();
+ }
+ function fixClass(loaded = true) {
+ return button
+ .toggleClass('btn-danger', !loaded)
+ .toggleClass('btn-primary', loaded);
+ }
+ function hoverButton(e) {
+ let text = '';
+ if (e.type === 'mouseenter') {
+ const deck = getDeck(i);
+ fixClass(!!deck);
+ const SOUL = `${Translation.Vanilla(`soul-${soul}`)}-${i + 1}`;
+ if (deck) {
+ const note = Translation.General('storage.note').key;
+ text = `
+ ${localStorage.getItem(nameKey) || SOUL}
+
+ ${artifacts(i)}
+ ${Translation.General('storage.load').translate(deck.length)}
+ ${cards(deck)}
+
+ ${getTranslationArray(note).join(' ')}
+
+ `;
+ } else {
+ text = `
+ ${SOUL}
+ ${Translation.General('storage.save')}
`;
+ }
+ }
+ hover.show(text)(e);
+ }
+ fixClass(!!localStorage.getItem(deckKey))
+ .on('click.script.deckStorage', (e) => {
+ if (!localStorage.getItem(deckKey)) {
+ saveButton();
+ return;
+ }
+ if (e.ctrlKey && e.shiftKey) { // Crazy people...
+ return;
+ }
+ if (e.ctrlKey) { // ERASE
+ localStorage.removeItem(nameKey);
+ localStorage.removeItem(deckKey);
+ refreshHover(); // Update
+ } else if (e.shiftKey) { // Re-save
+ saveButton();
+ } else { // Load
+ loadDeck(i);
+ }
+ })
+ .on('contextmenu.script.deckStorage', (e) => {
+ e.preventDefault();
+ const input = $('#deckNameInput');
+ const display = $('#deckName');
+ function storeInput() {
+ localStorage.setItem(nameKey, input.val());
+ display.text(input.val()).show();
+ refreshHover();
+ }
+ display.hide();
+ input.show()
+ .focus()
+ .select()
+ // eslint-disable-next-line no-shadow
+ .on('keydown.script.deckStorage', (e) => {
+ if (e.key === 'Escape' || e.key === 'Enter') {
+ e.preventDefault();
+ storeInput();
+ }
+ })
+ .on('focusout.script.deckStorage', () => {
+ storeInput();
+ });
+ });
+ eventManager.on('underscript:ready', () => {
+ button.hover(hoverButton);
+ });
+ }
+
+ compound('Deck:Loaded', 'Chat:Connected', loadStorage);
+ $('#yourCardList > button[onclick="removeAllCards();"]').on('click', () => {
+ deckLoader.clear();
+ });
+ eventManager.on('Deck:Soul', () => deckLoader.clear());
+ });
+});
diff --git a/src/base/library/filter.js b/src/base/library/filter.js
new file mode 100644
index 00000000..ac38f179
--- /dev/null
+++ b/src/base/library/filter.js
@@ -0,0 +1,248 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global, globalSet } from 'src/utils/global.js';
+import style from 'src/utils/style.js';
+import onPage from 'src/utils/onPage.js';
+import { max } from 'src/utils/cardHelper.js';
+import Translation from 'src/structures/constants/translation.ts';
+import Priority from 'src/structures/constants/priority.js';
+import extractImageName from 'src/utils/extractImageName';
+import { getTranslationArray } from '../underscript/translation.js';
+
+export const crafting = onPage('Crafting');
+export const decks = onPage('Decks');
+export const filters = [
+ /**
+ * @param {Card} card
+ * @param {boolean} removed
+ * @returns {boolean | undefined}
+ */
+ function templateFilter(card, removed) {
+ return removed;
+ },
+];
+
+filters.shift(); // Remove template
+
+const base = {
+ onChange: () => applyLook(),
+ category: Translation.Setting('category.filter'),
+ page: 'Library',
+ default: true,
+};
+
+const setting = settings.register({
+ ...base,
+ default: false,
+ name: Translation.Setting('filter.disable'),
+ key: 'underscript.deck.filter.disable',
+});
+
+const tribe = settings.register({
+ ...base,
+ name: Translation.Setting('filter.tribe'),
+ key: 'underscript.deck.filter.tribe',
+});
+
+const owned = settings.register({
+ ...base,
+ name: Translation.Setting('filter.collection'),
+ key: 'underscript.deck.filter.collection',
+});
+
+const shiny = settings.register({
+ ...base,
+ name: Translation.Setting('filter.shiny'),
+ key: 'underscript.deck.filter.shiny',
+ options: () => {
+ const { key } = Translation.Setting('filter.shiny.option');
+ const options = getTranslationArray(key);
+ return ['Never (default)', 'Deck', 'Always'].map((val, i) => [
+ options[i],
+ val,
+ ]);
+ },
+ default: 'Deck',
+});
+
+style.add(
+ '#collectionType { margin-bottom: 10px; }',
+ '.filter input+* { opacity: 0.4; }',
+ '.filter input:checked+* { opacity: 1; }',
+ '.filter input:disabled, .filter input:disabled+* { display: none; }',
+);
+
+function applyLook(refresh = decks || crafting) {
+ $('input[onchange^="applyFilters();"]').parent().parent().toggleClass('filter', !setting.value());
+ if (crafting && !setting.value()) {
+ $('input[rarity]:checked').prop('checked', false);
+ }
+
+ // Tribe filter
+ if (setting.value()) {
+ $('#allTribeInput').parent().remove();
+ } else if (!$('#allTribeInput').length) {
+ $('#monsterInput').parent().before(allTribeButton(), ' ');
+ }
+ $('#allTribeInput').prop('disabled', !tribe.value());
+
+ const allCardsElement = $('[data-i18n="[html]crafting-all-cards"]');
+ if (setting.value() || !owned.value()) {
+ $('#collectionType').remove();
+ allCardsElement.removeClass('invisible');
+ } else if (!$('#collectionType').length) {
+ eventManager.on('underscript:ready', () => {
+ allCardsElement.addClass('invisible')
+ .after(ownSelect());
+ });
+ }
+
+ // Re-add shiny filter
+ if (!$('#shinyInput').length) {
+ $('#utyInput').parent().after(shinyButton());
+ }
+ $('#shinyInput').prop('disabled', mergeShiny());
+
+ if (refresh) {
+ global('applyFilters')();
+ global('showPage')(0);
+ }
+}
+
+eventManager.on(':preload:Decks :preload:Crafting', () => {
+ // Update filter visuals
+ applyLook(false);
+ globalSet('isRemoved', function newFilter(card) {
+ if (setting.value()) return this.super(card);
+ const results = new Map();
+ return filters.reduce((removed, func) => {
+ if (typeof func !== 'function') return removed;
+ const val = func.call(this, card, removed, Object.fromEntries(results));
+ const key = func.displayName || func.name;
+ if (typeof val === 'boolean') {
+ results.set(key, val !== removed);
+ return val;
+ }
+ results.set(key, null);
+ return removed;
+ }, false);
+ });
+});
+
+function mergeShiny() {
+ return shiny.value() === 'Always' || (decks && shiny.value() === 'Deck');
+}
+
+function allTribeButton() {
+ return $(`
+
+
+
+ `);
+}
+
+function shinyButton() {
+ return $(`
+
+
+ S
+ `);
+}
+
+function ownSelect() {
+ return $(`
+
+ ${Translation.Vanilla('crafting-all-cards')}
+ ${Translation.General('cards.owned')}
+ ${Translation.General('cards.unowned')}
+ ${Translation.General('cards.maxed')}
+ ${Translation.General('cards.surplus')}
+ ${Translation.General('cards.craftable')}
+
+ `);
+}
+
+filters.push(
+ Priority.FIRST,
+ // function isRemoved(card) {
+ // // Shiny, Rarity, Type, Extension, Search
+ // return this.super(card);
+ // },
+ // eslint-disable-next-line no-shadow
+ function shiny(card, removed) {
+ if (removed) return null;
+ if (mergeShiny()) return false;
+ return card.shiny !== $('#shinyInput').prop('checked');
+ },
+ function rarity(card, removed) {
+ if (removed) return null;
+ const rarities = $('.rarityInput:checked').map(function getRarity() {
+ return this.getAttribute('rarity');
+ }).get();
+ return rarities.length > 0 && !rarities.includes(card.rarity);
+ },
+ function type(card, removed) {
+ if (removed) return null;
+ const monster = $('#monsterInput').prop('checked');
+ const spell = $('#spellInput').prop('checked');
+ const cardType = monster ? 0 : 1;
+ return monster !== spell && card.typeCard !== cardType;
+ },
+ function extension(card, removed) {
+ if (removed) return null;
+ const extensions = [];
+ if ($('#undertaleInput').prop('checked')) {
+ extensions.push('BASE');
+ }
+ if ($('#deltaruneInput').prop('checked')) {
+ extensions.push('DELTARUNE');
+ }
+ if ($('#utyInput').prop('checked')) {
+ extensions.push('UTY');
+ }
+ return extensions.length > 0 && !extensions.includes(card.extension);
+ },
+ function search(card, removed) {
+ if (removed) return null;
+ const text = $('#searchInput').val().toLowerCase();
+ if (!text.length) return false;
+ function includes(dirty) {
+ return dirty.replace(/(<.*?>)/g, '').toLowerCase().includes(text);
+ }
+ extractImageName(true);
+ const result = (
+ !includes($.i18n(`card-name-${card.id}`, 1)) &&
+ !includes($.i18n(`card-${card.id}`)) &&
+ !(card.soul?.name && includes($.i18n(`soul-${card.soul.name.toLowerCase().replace(/_/g, '-')}`))) &&
+ !card.tribes.some((t) => includes($.i18n(`tribe-${t.toLowerCase().replace(/_/g, '-')}`)))
+ );
+ extractImageName(false);
+ return result;
+ },
+ Priority.HIGHEST,
+ Priority.HIGH,
+ crafting && function baseGenFilter(card, removed) {
+ if (removed || $('.rarityInput:checked').length) return null;
+ return ['BASE', 'TOKEN'].includes(card.rarity);
+ },
+ function tribeFilter(card, removed) {
+ if (removed || !tribe.value()) return null;
+ return $('#allTribeInput').prop('checked') && !card.tribes.length;
+ },
+ crafting && function ownedFilter(card, removed) {
+ if (removed || !owned.value()) return null;
+ switch ($('#collectionType').val()) {
+ case 'owned': return !card.quantity;
+ case 'unowned': return card.quantity > 0;
+ case 'maxed': return card.quantity < max(card.rarity);
+ case 'surplus': return card.quantity <= max(card.rarity);
+ case 'craftable': return card.quantity >= max(card.rarity);
+ case 'all':
+ default: return false;
+ }
+ },
+ Priority.NORMAL,
+ Priority.LOW,
+ Priority.LOWEST,
+ Priority.LAST,
+);
diff --git a/src/base/library/hideCheckboxes.js b/src/base/library/hideCheckboxes.js
new file mode 100644
index 00000000..09279ace
--- /dev/null
+++ b/src/base/library/hideCheckboxes.js
@@ -0,0 +1,47 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import style from 'src/utils/style.js';
+import Translation from 'src/structures/constants/translation.ts';
+import { crafting, decks } from './filter.js';
+import { getTranslationArray } from '../underscript/translation.js';
+
+const setting = settings.register({
+ key: 'underscript.library.hidebuttons',
+ name: Translation.Setting('filter.trim'),
+ options() {
+ const { key } = Translation.Setting('filter.trim.option');
+ const array = getTranslationArray(key);
+ return ['Always', 'Deck', 'Crafting', 'Never'].map(
+ (val, i) => [array[i], val],
+ );
+ },
+ page: 'Library',
+ category: Translation.Setting('category.filter'),
+ onChange: refresh,
+});
+
+const styles = style.add();
+
+function apply() {
+ switch (setting.value()) {
+ case 'Always': return decks || crafting;
+ case 'Deck': return decks;
+ case 'Crafting': return crafting;
+ case 'Never':
+ default: return false;
+ }
+}
+
+function refresh() {
+ if (apply()) {
+ styles.replace(
+ '.filter input { display: none; }',
+ '.filter input+* { margin: 0 2px; opacity: 0.2; }',
+ '.filter .rainbowText { padding: 0px 5px; font-size: 22px; }',
+ );
+ } else {
+ styles.remove();
+ }
+}
+
+eventManager.on(':preload:Decks :preload:Crafting', refresh);
diff --git a/src/base/library/scrollwheel.js b/src/base/library/scrollwheel.js
new file mode 100644
index 00000000..af4236b3
--- /dev/null
+++ b/src/base/library/scrollwheel.js
@@ -0,0 +1,20 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('library.scrollwheel'),
+ key: 'underscript.disable.scrolling',
+ refresh: onPage('Decks') || onPage('Crafting'),
+ page: 'Library',
+ category: Translation.CATEGORY_HOTKEYS,
+});
+
+eventManager.on(':preload:Decks :preload:Crafting', function scrollwheelLoaded() {
+ globalSet('onload', function onload() {
+ this.super?.();
+ if (setting.value()) $('#collection').off('mousewheel DOMMouseScroll');
+ });
+});
diff --git a/src/base/lobby/customFriendsOnly.js b/src/base/lobby/customFriendsOnly.js
new file mode 100644
index 00000000..a0103e7b
--- /dev/null
+++ b/src/base/lobby/customFriendsOnly.js
@@ -0,0 +1,46 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global } from 'src/utils/global.js';
+import { errorToast } from 'src/utils/2.toasts.js';
+import { debug } from 'src/utils/debug.js';
+import isFriend from 'src/utils/isFriend.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const name = Translation.Setting('custom.friends');
+const setting = settings.register({
+ name,
+ key: 'underscript.custom.friendsOnly',
+ note: Translation.Setting('custom.friends.note'),
+ page: 'Lobby',
+ category: Translation.CATEGORY_CUSTOM,
+});
+const container = document.createElement('span');
+let flag = setting.value();
+
+function init() {
+ $(container)
+ .append($(` `).prop('checked', flag).on('change', () => {
+ flag = !flag;
+ }))
+ .append(' ', $('').text('Friends only'));
+ // .hover(hover.show('Only allow friends to join'))
+ $('#state2 span.opponent').parent().after(container);
+ // hover.tip(`Only allow friends to join`, container);
+ eventManager.on('underscript:ready', () => {
+ $('label[for="friends"]').text(name);
+ });
+}
+
+function joined({ username }) {
+ if (this.canceled || !flag || isFriend(username)) return;
+ debug(`Kicked: ${username}`);
+ errorToast({
+ title: Translation.Toast('custom.ban'),
+ text: Translation.Toast('custom.ban.user').withArgs(username),
+ });
+ this.canceled = true;
+ global('banUser')();
+}
+
+eventManager.on('enterCustom', init);
+eventManager.on('preCustom:getPlayerJoined', joined);
diff --git a/src/base/lobby/disconnected.js b/src/base/lobby/disconnected.js
new file mode 100644
index 00000000..82046e13
--- /dev/null
+++ b/src/base/lobby/disconnected.js
@@ -0,0 +1,39 @@
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+import { errorToast } from 'src/utils/2.toasts.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('Play', setup);
+
+let waiting = true;
+
+function setup() {
+ eventManager.on('socketOpen', (socket) => {
+ socket.addEventListener('close', announce);
+ globalSet('onbeforeunload', function onbeforeunload() {
+ socket.removeEventListener('close', announce);
+ this.super();
+ });
+ });
+
+ eventManager.on('Play:Message', (data) => {
+ switch (data.action) {
+ case 'getLeaveQueue':
+ waiting = true;
+ break;
+ default:
+ waiting = false;
+ }
+ });
+}
+
+// TODO: translation
+function announce() {
+ if (waiting) {
+ eventManager.emit('closeQueues', 'Disconnected from queue. Please refresh page.');
+ }
+ errorToast({
+ name: 'An Error Occurred',
+ message: 'You have disconnected from the queue, please refresh the page.',
+ });
+}
diff --git a/src/base/lobby/enterOnCustom.js b/src/base/lobby/enterOnCustom.js
new file mode 100644
index 00000000..ed5f5faf
--- /dev/null
+++ b/src/base/lobby/enterOnCustom.js
@@ -0,0 +1,30 @@
+import eventManager from 'src/utils/eventManager.js';
+import { infoToast } from 'src/utils/2.toasts.js';
+import { window } from 'src/utils/1.variables.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+eventManager.on(':load:GamesList', () => {
+ let toast = infoToast({
+ text: Translation.Toast('custom.enter'),
+ onClose: (reason) => {
+ toast = null;
+ // return reason !== 'processed';
+ },
+ }, 'underscript.notice.customGame', '1');
+
+ $('#state1 button:contains(Create)').on('mouseup.script', () => {
+ // Wait for the dialog to show up...
+ $(window).one('shown.bs.modal', () => {
+ const input = $('.bootstrap-dialog-message input');
+ if (!input.length) return; // This is just to prevent errors... though this is an error in itself
+ $(input[0]).focus();
+ input.on('keydown.script', (e) => {
+ if (e.key === 'Enter') {
+ toast?.close('processed');
+ e.preventDefault();
+ $('.bootstrap-dialog-footer-buttons button:first').trigger('click');
+ }
+ });
+ });
+ });
+});
diff --git a/src/base/lobby/gameFound.js b/src/base/lobby/gameFound.js
new file mode 100644
index 00000000..8e47d1f1
--- /dev/null
+++ b/src/base/lobby/gameFound.js
@@ -0,0 +1,17 @@
+import Translation from 'src/structures/constants/translation.ts';
+import compound from 'src/utils/compoundEvent.js';
+import eventManager from 'src/utils/eventManager.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('Play', () => {
+ const title = document.title;
+ compound('getWaitingQueue', 'underscript:ready', function updateTitle() {
+ // Title has been modified
+ if (title !== document.title) return;
+ document.title = `Undercards - ${Translation.General('match.found')}`;
+ });
+ eventManager.on('getLeaveQueue', function restoreTitle() {
+ document.title = title;
+ });
+ // infoToast('The page title now changes when a match is found.', 'underscript.notice.play.title', '1');
+});
diff --git a/src/base/lobby/gameFoundNotification.js b/src/base/lobby/gameFoundNotification.js
new file mode 100644
index 00000000..0189f138
--- /dev/null
+++ b/src/base/lobby/gameFoundNotification.js
@@ -0,0 +1,13 @@
+import onPage from 'src/utils/onPage.js';
+import active from 'src/utils/active.js';
+import notify from 'src/utils/notifications.js';
+import compound from 'src/utils/compoundEvent.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+onPage('Play', () => {
+ compound('getWaitingQueue', 'underscript:ready', function gameFound() {
+ if (!active()) {
+ notify(Translation.General('match.found'));
+ }
+ });
+});
diff --git a/src/base/lobby/lowerVolume.js b/src/base/lobby/lowerVolume.js
new file mode 100644
index 00000000..39db4703
--- /dev/null
+++ b/src/base/lobby/lowerVolume.js
@@ -0,0 +1,20 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global } from 'src/utils/global.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const volume = settings.register({
+ name: Translation.Setting('volume.match.found'),
+ key: 'underscript.volume.gameFound',
+ type: 'slider',
+ default: 0.3,
+ max: 1,
+ step: 0.1,
+ page: 'Lobby',
+ reset: true,
+});
+
+eventManager.on('getWaitingQueue', function lowerVolume() {
+ // Lower the volume, the music changing is enough as is
+ global('audioQueue').volume = parseFloat(volume.value());
+});
diff --git a/src/base/lobby/noMinigame.js b/src/base/lobby/noMinigame.js
new file mode 100644
index 00000000..db8fe462
--- /dev/null
+++ b/src/base/lobby/noMinigame.js
@@ -0,0 +1,18 @@
+import * as settings from 'src/utils/settings/index.js';
+import { global, globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import compound from 'src/utils/compoundEvent.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+// TODO: translation
+const setting = settings.register({
+ name: Translation.Setting('minigame'),
+ key: 'underscript.minigames.disabled',
+ page: 'Lobby',
+ refresh: onPage('Play'),
+ category: Translation.CATEGORY_MINIGAMES,
+});
+
+compound(':preload:Play', 'pre:getJoinedQueue', () => {
+ if (setting.value() && global('miniGameLoaded')) globalSet('miniGameLoaded', false);
+});
diff --git a/src/base/lobby/pingCustom.js b/src/base/lobby/pingCustom.js
new file mode 100644
index 00000000..04764a28
--- /dev/null
+++ b/src/base/lobby/pingCustom.js
@@ -0,0 +1,10 @@
+import { global } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('GamesList', function keepAlive() {
+ setInterval(() => {
+ const socket = global('socket');
+ if (socket.readyState !== WebSocket.OPEN) return;
+ socket.send(JSON.stringify({ ping: 'pong' }));
+ }, 5000);
+});
diff --git a/src/base/lobby/playQueueButton.js b/src/base/lobby/playQueueButton.js
new file mode 100644
index 00000000..6c79a1f6
--- /dev/null
+++ b/src/base/lobby/playQueueButton.js
@@ -0,0 +1,49 @@
+import eventManager from 'src/utils/eventManager.js';
+import onPage from 'src/utils/onPage.js';
+import * as hover from 'src/utils/hover.js';
+
+onPage('Play', () => {
+ let queues;
+ let disable = true;
+ let restarting = false;
+
+ eventManager.on('jQuery', function onPlay() {
+ restarting = $('p.infoMessage[data-i18n-custom="header-info-restart"]').length !== 0;
+ if (disable || restarting) {
+ queues = $('#standard-mode, #ranked-mode, button.btn.btn-primary');
+ // TODO: translation
+ closeQueues(restarting ? 'Joining is disabled due to server restart.' : 'Waiting for connection to be established.');
+ }
+ });
+
+ eventManager.on('socketOpen', checkButton);
+
+ eventManager.on('closeQueues', closeQueues);
+
+ const timeout = setTimeout(() => {
+ checkButton();
+ // TODO: translation
+ applyMessage('Auto enabled buttons, connection was not detected.');
+ }, 10000);
+
+ function checkButton() {
+ disable = false;
+ clearTimeout(timeout);
+ if (queues && !restarting) {
+ queues.off('.script');
+ queues.toggleClass('closed', false);
+ hover.hide();
+ }
+ }
+
+ function closeQueues(message) {
+ queues.toggleClass('closed', true);
+ applyMessage(message);
+ }
+
+ function applyMessage(message) {
+ queues
+ .on('mouseenter.script', hover.show(message))
+ .on('mouseleave.script', () => hover.hide());
+ }
+});
diff --git a/src/base/lobby/unlockChat.js b/src/base/lobby/unlockChat.js
new file mode 100644
index 00000000..c2bd744e
--- /dev/null
+++ b/src/base/lobby/unlockChat.js
@@ -0,0 +1,39 @@
+import eventManager from 'src/utils/eventManager.js';
+import VarStore from 'src/utils/VarStore.js';
+import { global } from 'src/utils/global.js';
+
+const unpause = VarStore(false);
+
+eventManager.on('Chat:focused', () => {
+ const game = global('game', {
+ throws: false,
+ });
+ if (game && game.input) {
+ if (!game.paused) {
+ game.paused = unpause.set(true);
+ }
+ const keyboard = game.input.keyboard;
+ if (keyboard.disableGlobalCapture) {
+ keyboard.disableGlobalCapture();
+ } else {
+ keyboard.enabled = false;
+ }
+ }
+});
+
+eventManager.on('Chat:unfocused', () => {
+ const game = global('game', {
+ throws: false,
+ });
+ if (game && game.input) {
+ if (unpause.get()) {
+ game.paused = false;
+ }
+ const keyboard = game.input.keyboard;
+ if (keyboard.enableGlobalCapture) {
+ keyboard.enableGlobalCapture();
+ } else {
+ keyboard.enabled = true;
+ }
+ }
+});
diff --git a/src/base/lobby/wasd.js b/src/base/lobby/wasd.js
new file mode 100644
index 00000000..a7aa97a7
--- /dev/null
+++ b/src/base/lobby/wasd.js
@@ -0,0 +1,32 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global, globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('minigame.wasd'),
+ key: 'underscript.minigames.wasd',
+ page: 'Lobby',
+ category: Translation.CATEGORY_MINIGAMES,
+});
+
+function onCreate() {
+ this.super();
+ if (!setting.value()) return;
+ const game = global('game');
+ const KeyCode = global('Phaser').KeyCode;
+ const cursors = game.input.keyboard.addKeys({ up: KeyCode.W, down: KeyCode.S, left: KeyCode.A, right: KeyCode.D });
+ globalSet('cursors', cursors);
+}
+
+onPage('Play', () => {
+ let bound = false;
+ eventManager.on('pre:getJoinedQueue', () => {
+ if (bound) return;
+ const create = global('create', { throws: false });
+ if (!create) return;
+ globalSet('create', onCreate);
+ bound = true;
+ });
+});
diff --git a/src/base/plugin/.eslintrc.cjs b/src/base/plugin/.eslintrc.cjs
new file mode 100644
index 00000000..73b9626f
--- /dev/null
+++ b/src/base/plugin/.eslintrc.cjs
@@ -0,0 +1,7 @@
+const readonly = 'readonly';
+module.exports = {
+ globals: {
+ registerModule: readonly,
+ settings: readonly,
+ },
+};
diff --git a/src/base/plugin/addFilter.js b/src/base/plugin/addFilter.js
new file mode 100644
index 00000000..43de9b5c
--- /dev/null
+++ b/src/base/plugin/addFilter.js
@@ -0,0 +1,32 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import Priority from 'src/structures/constants/priority.js';
+import { crafting, decks, filters } from '../library/filter.js';
+
+wrap(() => {
+ if (!(crafting || decks)) return;
+ const name = 'addFilter';
+ let counter = 0;
+ function mod(plugin) {
+ return (filter, priority = Priority.NORMAL) => {
+ const fixedPriority = Priority.get(priority);
+ if (!fixedPriority) throw new Error('Must pass a valid priority');
+ if (typeof filter !== 'function') throw new Error('Must pass a function');
+ counter += 1;
+ const functionName = filter.displayName || filter.name || `filter${counter}`;
+ function customFilter(...args) {
+ try {
+ return filter.call(this, ...args);
+ } catch (e) {
+ plugin.logger.error(`Failed to apply filter [${functionName}]`, e);
+ }
+ return undefined;
+ }
+ customFilter.displayName = `${plugin.name}:${functionName}`;
+ const index = filters.indexOf(fixedPriority);
+ filters.splice(index, 0, customFilter);
+ };
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/addStyle.js b/src/base/plugin/addStyle.js
new file mode 100644
index 00000000..0dc48c85
--- /dev/null
+++ b/src/base/plugin/addStyle.js
@@ -0,0 +1,14 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { newStyle } from 'src/utils/style.js';
+import { registerModule } from 'src/utils/plugin.js';
+
+wrap(() => {
+ const name = 'addStyle';
+
+ function mod(plugin) {
+ const style = newStyle(plugin);
+ return (...styles) => style.add(...styles);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/enabled.local.js b/src/base/plugin/enabled.local.js
new file mode 100644
index 00000000..a07d17b1
--- /dev/null
+++ b/src/base/plugin/enabled.local.js
@@ -0,0 +1,74 @@
+import Translation from 'src/structures/constants/translation.ts';
+import { buttonCSS as css } from 'src/utils/1.variables.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import * as settings from 'src/utils/settings/index.js';
+
+// TODO: translation
+const enable = ['Enabled with toast', 'Enabled silently'];
+
+const setting = settings.register({
+ // TODO: translation
+ name: 'New plugin behavior',
+ key: 'underscript.plugins.init',
+ category: 'Plugins',
+ // TODO: translation
+ data: [...enable, 'Disabled with toast', 'Disabled silently'],
+ type: 'select',
+});
+
+wrap(() => {
+ const name = 'enabled';
+
+ function mod(plugin) {
+ if (!plugin.version) return;
+ const enabled = plugin.settings().add({
+ key: 'plugin.enabled',
+ // TODO: translation
+ name: 'Enabled',
+ default: enable.includes(setting.value()),
+ });
+
+ Object.defineProperty(plugin, name, {
+ get: () => enabled.value(),
+ });
+
+ const registered = plugin.settings().add({ key: 'plugin.registered', hidden: true });
+ if (registered.value()) return;
+ if (!setting.value().includes('toast')) {
+ registered.set(true);
+ return;
+ }
+
+ plugin.events.on(':load', () => {
+ const isEnabled = enabled.value();
+ plugin.toast({
+ // TODO: translation
+ title: 'New Plugin Detected',
+ text: `"${plugin.name}" has been ${isEnabled ? 'enabled' : 'disabled'} by default.`,
+ error: true, // Make it red.
+ className: {
+ toast: 'dismissable',
+ button: 'dismiss',
+ },
+ buttons: [{
+ text: Translation.General(isEnabled ? 'disable' : 'enable'),
+ css,
+ onclick() {
+ enabled.set(!isEnabled);
+ registered.set(true);
+ },
+ }, {
+ text: Translation.DISMISS,
+ css,
+ onclick() {
+ enabled.set(isEnabled);
+ registered.set(true);
+ },
+ }],
+ });
+ });
+ }
+
+ registerModule(name, mod, ['settings', 'events']);
+});
diff --git a/src/base/plugin/events.js b/src/base/plugin/events.js
new file mode 100644
index 00000000..0ceaa27e
--- /dev/null
+++ b/src/base/plugin/events.js
@@ -0,0 +1,77 @@
+import eventManager from 'src/utils/eventManager.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import { capturePluginError } from 'src/utils/sentry.js';
+import compoundEvent from 'src/utils/compoundEvent.js';
+
+wrap(() => {
+ const options = ['cancelable', 'canceled', 'singleton', 'async'];
+
+ const name = 'events';
+ function mod(plugin) {
+ function log(error, event, args, meta) {
+ capturePluginError(plugin, error, {
+ args,
+ ...meta,
+ });
+ plugin.logger.error(`Event error (${event}):\n`, error, '\n', JSON.stringify({
+ args,
+ event: meta,
+ }));
+ }
+ function wrapper(fn, event) {
+ function listener(...args) {
+ try {
+ const val = fn.call(this, ...args);
+ if (val instanceof Promise) {
+ return val.catch((error) => log(error, event, args, this));
+ }
+ return val;
+ } catch (e) {
+ log(e, event, args, this);
+ }
+ return undefined;
+ }
+ listener.plugin = plugin;
+ return listener;
+ }
+ const obj = {
+ ...eventManager,
+ compound(...events) {
+ const fn = events.pop();
+ if (typeof fn !== 'function') throw new Error('Must pass a function');
+ if (!events.length) throw new Error('Must pass events');
+ if (events.length === 1) throw new Error('Use `events.on` for single events');
+
+ compoundEvent(...events, wrapper(fn, `Compound[${events.join(';')}]`));
+ },
+ on(event, fn) {
+ if (typeof fn !== 'function') throw new Error('Must pass a function');
+
+ if (event.split(' ').includes(':loaded')) {
+ plugin.logger.warn('Event manager: `:loaded` is deprecated, ask author to update to `:preload`!');
+ }
+
+ eventManager.on.call(obj, event, wrapper(fn, event));
+ },
+ emit(...args) {
+ return eventManager.emit(...args);
+ },
+ };
+
+ options.forEach((key) => {
+ Object.defineProperty(obj.emit, key, {
+ get: () => {
+ // Toggle the event manager
+ eventManager[key]; // eslint-disable-line no-unused-expressions
+ // Return our object
+ return obj.emit;
+ },
+ });
+ });
+
+ return Object.freeze(obj);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/hotkey.js b/src/base/plugin/hotkey.js
new file mode 100644
index 00000000..0c6e5117
--- /dev/null
+++ b/src/base/plugin/hotkey.js
@@ -0,0 +1,66 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import Hotkey from 'src/utils/hotkey.class.js';
+import { hotkeys } from 'src/utils/1.variables.js';
+import { capturePluginError } from 'src/utils/sentry.js';
+
+class PluginHotkey extends Hotkey {
+ constructor(plugin, hotkey) {
+ super();
+ this.plugin = plugin;
+ this.hotkey = hotkey;
+ }
+
+ get name() {
+ return `[${this.hotkey.name}]`;
+ }
+
+ get keys() {
+ return this.hotkey.keys;
+ }
+
+ get clicks() {
+ return this.hotkey.clicks;
+ }
+
+ run(...args) {
+ try {
+ return this.hotkey.run(...args);
+ } catch (e) {
+ capturePluginError(this.plugin, e, {
+ hotkey: this.name,
+ });
+ this.plugin.logger.error(`Hotkey${this.name || ''}${e instanceof Error ? '' : ' Error:'}`, e);
+ }
+ return undefined;
+ }
+}
+
+wrap(() => {
+ const name = 'hotkey';
+ function mod(plugin) {
+ const registry = new Map();
+ return {
+ register(hotkey) {
+ validate(hotkey);
+ if (registry.has(hotkey)) return;
+ const wrapper = new PluginHotkey(plugin, hotkey);
+ registry.set(hotkey, wrapper);
+ hotkeys.push(wrapper);
+ },
+ unregister(hotkey) {
+ validate(hotkey);
+ const wrapper = registry.get(hotkey);
+ if (!wrapper) return;
+ registry.delete(hotkey);
+ hotkeys.splice(hotkeys.indexOf(wrapper), 1);
+ },
+ };
+ }
+
+ registerModule(name, mod);
+});
+
+function validate(hotkey) {
+ if (!(hotkey instanceof Hotkey)) throw new Error('Not valid hotkey');
+}
diff --git a/src/base/plugin/logger.js b/src/base/plugin/logger.js
new file mode 100644
index 00000000..e8fde583
--- /dev/null
+++ b/src/base/plugin/logger.js
@@ -0,0 +1,20 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+
+wrap(() => {
+ const name = 'logger';
+ function mod(plugin) {
+ const obj = {};
+ ['info', 'error', 'log', 'warn', 'debug'].forEach((key) => {
+ obj[key] = (...args) => console[key]( // eslint-disable-line no-console
+ `[%c${plugin.name}%c/${key}]`,
+ 'color: #436ad6;',
+ 'color: inherit;',
+ ...args,
+ );
+ });
+ return Object.freeze(obj);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/quests.js b/src/base/plugin/quests.js
new file mode 100644
index 00000000..5a7cb23c
--- /dev/null
+++ b/src/base/plugin/quests.js
@@ -0,0 +1,25 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import { getQuests, fetch } from 'src/utils/quests.js';
+
+wrap(() => {
+ const name = 'quests';
+ function mod(plugin) {
+ return {
+ getQuests,
+ update(event = false) {
+ return new Promise((res, rej) => {
+ fetch(({ error, ...rest }) => {
+ if (error) {
+ rej(error);
+ } else {
+ res({ ...rest });
+ }
+ }, event);
+ });
+ },
+ };
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/settings.js b/src/base/plugin/settings.js
new file mode 100644
index 00000000..7f08a1fb
--- /dev/null
+++ b/src/base/plugin/settings.js
@@ -0,0 +1,49 @@
+import * as settings from 'src/utils/settings/index.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import SettingType from 'src/utils/settings/types/setting.js';
+
+wrap(() => {
+ const name = 'settings';
+ function add(plugin) {
+ const prefix = `underscript.plugin.${plugin.name}`;
+
+ return (data = {}) => {
+ if (!data.key) throw new Error('Key must be provided');
+
+ const setting = {
+ ...data,
+ key: `${prefix}.${data.key}`,
+ name: data.name || data.key,
+ page: plugin,
+ };
+ return settings.register(setting);
+ };
+ }
+
+ function mod(plugin) {
+ const obj = {
+ add: add(plugin),
+ on: (...args) => settings.on(...args),
+ open: () => settings.open(plugin),
+ isOpen: () => settings.isOpen(),
+ addType(type) {
+ if (!(type instanceof SettingType)) {
+ plugin.logger.error('SettingType: Attempted to register object of:', typeof type);
+ return;
+ }
+ if (!type.name.startsWith(`${plugin.name}:`)) {
+ type.name = `${plugin.name}:${type.name}`;
+ }
+ settings.registerType(type, plugin.addStyle);
+ },
+ value(key) {
+ if (!settings.exists(key)) return undefined;
+ return settings.value(key);
+ },
+ };
+ return () => Object.freeze(obj);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/toast.js b/src/base/plugin/toast.js
new file mode 100644
index 00000000..e2fa365b
--- /dev/null
+++ b/src/base/plugin/toast.js
@@ -0,0 +1,19 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import { toast as basicToast, errorToast } from 'src/utils/2.toasts.js';
+
+wrap(() => {
+ const name = 'toast';
+ function pluginToast(plugin, data) {
+ const toast = typeof data === 'object' ? { ...data } : { text: data };
+ toast.footer = `${plugin.name} • via UnderScript`;
+ if (toast.error) return errorToast(toast);
+ return basicToast(toast);
+ }
+
+ function mod(plugin) {
+ return (data) => pluginToast(plugin, data);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/updater.js b/src/base/plugin/updater.js
new file mode 100644
index 00000000..dad79ee3
--- /dev/null
+++ b/src/base/plugin/updater.js
@@ -0,0 +1,49 @@
+import wrap from 'src/utils/2.pokemon.js';
+import { registerModule } from 'src/utils/plugin.js';
+import { registerPlugin } from 'src/hooks/updates.js';
+import * as settings from 'src/utils/settings/index.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const text = Translation.Setting('update.plugin');
+const setting = settings.register({
+ name: text,
+ key: 'underscript.disable.plugins.update',
+ category: 'Plugins',
+});
+
+wrap(() => {
+ const name = 'updater';
+ function mod(plugin) {
+ if (!plugin.version) return undefined;
+
+ let updater = false;
+ const update = plugin.settings().add({
+ name: text,
+ key: 'plugin.update',
+ default: () => setting.value(),
+ disabled: () => setting.value(),
+ hidden: () => !updater,
+ });
+ setting.on(() => update.refresh());
+
+ Object.defineProperty(plugin, 'canUpdate', {
+ get: () => updater && !setting.value() && !update.value(),
+ });
+
+ return (data = {}) => {
+ if (!['string', 'object'].includes(typeof data)) throw new Error();
+ if (updater) throw new Error('Already registered');
+ const {
+ downloadURL = typeof data === 'string' ? data : undefined,
+ updateURL,
+ } = data;
+ updater = registerPlugin(plugin, { downloadURL, updateURL });
+ return () => {
+ updater();
+ updater = false;
+ };
+ };
+ }
+
+ registerModule(name, mod, 'events', 'settings');
+});
diff --git a/src/base/quests/completed.js b/src/base/quests/completed.js
new file mode 100644
index 00000000..0693433e
--- /dev/null
+++ b/src/base/quests/completed.js
@@ -0,0 +1,42 @@
+import Translation from 'src/structures/constants/translation.ts';
+import wrap from 'src/utils/2.pokemon.js';
+import eventManager from 'src/utils/eventManager.js';
+
+wrap(() => {
+ const questSelector = 'input[type="submit"][value="Claim"]:not(:disabled)';
+
+ function collectQuests() {
+ const quests = document.querySelectorAll(questSelector);
+ if (quests.length) {
+ const block = getBlock();
+ const table = block.querySelector('.questTable tbody');
+ quests.forEach((quest) => {
+ const row = quest.parentElement.parentElement.parentElement.cloneNode(true);
+ if (row.childElementCount !== 4) {
+ row.firstElementChild.remove();
+ }
+ table.append(row);
+ });
+ document.querySelector('#event-list').after(block);
+ }
+ }
+
+ eventManager.on(':preload:Quests', collectQuests);
+ // style.add('progress::before { content: attr(value) " / " attr(max); float: right; color: white; }');
+
+ function getBlock() {
+ const block = document.createElement('div');
+ const h3 = document.createElement('h3');
+ h3.classList.add('event-title');
+ h3.textContent = 'Completed Quests';
+ eventManager.on('underscript:ready', () => {
+ h3.textContent = Translation.General('quest.pending');
+ });
+ const table = document.createElement('table');
+ table.classList.add('table', 'questTable');
+ const tbody = document.createElement('tbody');
+ table.append(tbody);
+ block.append(h3, table);
+ return block;
+ }
+});
diff --git a/src/base/quests/localTime.js b/src/base/quests/localTime.js
new file mode 100644
index 00000000..12c4538b
--- /dev/null
+++ b/src/base/quests/localTime.js
@@ -0,0 +1,16 @@
+import luxon from 'luxon';
+import Translation from 'src/structures/constants/translation.ts';
+import wrap from 'src/utils/2.pokemon.js';
+import compound from 'src/utils/compoundEvent.js';
+
+wrap(function localTime() {
+ compound(':load:Quests', 'underscript:ready', updateTime);
+
+ function updateTime() {
+ const time = luxon.DateTime.fromObject({ hour: 6, minute: 0, zone: 'Europe/Paris' })
+ .toLocal()
+ .toLocaleString(luxon.DateTime.TIME_SIMPLE);
+ const text = Translation.General('time.local').translate(time);
+ $('[data-i18n="[html]quests-reset"],[data-i18n="[html]quests-day"]').append(` (${text})`);
+ }
+});
diff --git a/src/base/quests/remainingTime.js b/src/base/quests/remainingTime.js
new file mode 100644
index 00000000..4253b629
--- /dev/null
+++ b/src/base/quests/remainingTime.js
@@ -0,0 +1,10 @@
+import luxon from 'luxon';
+import eventManager from 'src/utils/eventManager.js';
+import style from 'src/utils/style.js';
+
+eventManager.on(':preload:Quests', () => {
+ style.add('.dailyMissed { background: repeating-linear-gradient(45deg, red, black 0.488em); }');
+
+ const date = luxon.DateTime.now().setZone('Europe/Paris');
+ $('#viewDaily + table td:not(.dailyClaimed)').slice(date.daysInMonth - date.day).addClass('dailyMissed');
+});
diff --git a/src/base/settings/avatar.random.js b/src/base/settings/avatar.random.js
new file mode 100644
index 00000000..762befae
--- /dev/null
+++ b/src/base/settings/avatar.random.js
@@ -0,0 +1,24 @@
+import Translation from 'src/structures/constants/translation.ts';
+import wrap from 'src/utils/2.pokemon.js';
+import eventManager from 'src/utils/eventManager.js';
+import style from 'src/utils/style.js';
+import html from './random.html';
+import css from './random.css';
+
+wrap(() => {
+ function random() {
+ $('input[name="changeAvatar"]').random().click();
+ }
+
+ eventManager.on(':preload:Avatars', () => {
+ const wrapper = $(html);
+ $('.avatarsList').prepend(wrapper);
+ const button = wrapper.find('button');
+ button.click(random);
+ eventManager.on('underscript:ready', () => {
+ button.text(Translation.General('random'));
+ });
+ });
+
+ style.add(css);
+});
diff --git a/src/base/settings/random.css b/src/base/settings/random.css
new file mode 100644
index 00000000..5805a6e7
--- /dev/null
+++ b/src/base/settings/random.css
@@ -0,0 +1,7 @@
+.avatar.glyphicon-random {
+ width: 64px;
+ text-align: center;
+ margin: auto;
+ height: 64px;
+ padding-top: 24px;
+}
\ No newline at end of file
diff --git a/src/base/settings/random.html b/src/base/settings/random.html
new file mode 100644
index 00000000..9ab6cfdb
--- /dev/null
+++ b/src/base/settings/random.html
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/src/base/store/buyPacks.js b/src/base/store/buyPacks.js
new file mode 100644
index 00000000..0dff4e38
--- /dev/null
+++ b/src/base/store/buyPacks.js
@@ -0,0 +1,108 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+import * as hover from 'src/utils/hover.js';
+import wrap from 'src/utils/2.pokemon.js';
+import sleep from 'src/utils/sleep.js';
+import * as api from 'src/utils/4.api.js';
+import Translation from 'src/structures/constants/translation.ts';
+import { getTranslationArray } from '../underscript/translation.js';
+
+wrap(() => {
+ // buy multiple packs
+ function buyPacks({
+ type, count, gold,
+ }) {
+ const rawCost = document.querySelector(`#btn${gold ? '' : 'Ucp'}${type}Add`).nextElementSibling.textContent;
+ const cost = Number(rawCost);
+ if (gold) { // Gold error checking
+ const g = parseInt($('#golds').text(), 10);
+ if (g < cost * count) {
+ throw new Error('Not enough Gold');
+ }
+ } else { // UCP error checking
+ const ucp = parseInt($('#ucp').text(), 10);
+ if (ucp < cost * count) {
+ throw new Error('Not enough UCP');
+ }
+ }
+ $.fx.off = true;
+ const addPack = global('addPack');
+ for (let i = 0; i < count; i++) {
+ sleep(i * 10).then(() => {
+ globalSet('canAdd', true);
+ addPack(`add${type}Pack${gold ? '' : 'Ucp'}`, true);
+ });
+ }
+ sleep(500).then(() => {
+ $.fx.off = false;
+ });
+ }
+
+ function getCount(e, cost, baseCost) {
+ if (e.ctrlKey) return Math.floor(parseInt($(cost ? '#ucp' : '#golds').text(), 10) / baseCost);
+ if (e.altKey) return 10;
+ return 1;
+ }
+
+ eventManager.on(':preload:Packs', () => {
+ const types = ['', 'DR', 'UTY'];
+
+ types.forEach((type) => {
+ ['', 'Ucp'].forEach((cost) => {
+ const el = document.querySelector(`#btn${cost}${type}Add`);
+ if (!el) return;
+ el.onclick = null;
+ el.addEventListener('click', (e) => {
+ const price = Number(el.nextElementSibling.textContent);
+ const count = getCount(e, cost, price);
+ if (!count) return;
+ const data = {
+ count,
+ type,
+ gold: !cost,
+ };
+ if (cost && !e.shiftKey) {
+ global('BootstrapDialog').show({
+ title: `${Translation.PURCHASE}`,
+ message: Translation.General('purchase.pack.cost').translate(count, count * price),
+ buttons: [{
+ label: `${Translation.CONTINUE}`,
+ cssClass: 'btn-success',
+ action(diag) {
+ buyPacks(data);
+ diag.close();
+ },
+ }, {
+ label: `${Translation.CANCEL}`,
+ cssClass: 'btn-danger',
+ action(diag) {
+ diag.close();
+ },
+ }],
+ });
+ } else {
+ buyPacks(data);
+ }
+ });
+ eventManager.on('underscript:ready', () => {
+ const { key } = Translation.General('purchase.pack.note');
+ const array = [...getTranslationArray(key)];
+ if (cost) {
+ array.push(Translation.General('bypass.shift'));
+ }
+ $(el).hover(hover.show(array.join(' ')));
+ });
+ });
+ });
+
+ api.register('buyPacks', (count, { type = '', gold = true } = {}) => {
+ if (!types.includes(type)) throw new Error(`Unsupported Pack: ${type}`);
+ if (!Number.isInteger(count) || count < 1) throw new Error(`Count(${count}) must be a number`);
+ buyPacks({
+ type,
+ count,
+ gold: !!gold,
+ });
+ });
+ });
+});
diff --git a/src/base/store/cosmetics.confirm.js b/src/base/store/cosmetics.confirm.js
new file mode 100644
index 00000000..f9ef4325
--- /dev/null
+++ b/src/base/store/cosmetics.confirm.js
@@ -0,0 +1,47 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as hover from 'src/utils/hover.js';
+import wrap from 'src/utils/2.pokemon.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+wrap(() => {
+ eventManager.on(':preload:CosmeticsShop', () => {
+ $('form[action=CosmeticsShop] button')
+ .hover(hover.show(Translation.General('bypass.shift')), hover.hide)
+ .click(function click(e) {
+ if (e.shiftKey) return;
+ e.preventDefault();
+ const form = $(e.currentTarget).parent();
+ const parent = getParent(form);
+ const image = parent.find('img')[0]?.outerHTML || '[Failed to detect image]';
+ const cost = parent.find('span[class=ucp]:first').text();
+ BootstrapDialog.show({
+ title: `${Translation.PURCHASE}`,
+ message: `${image}
${
+ Translation.General('purchase.item.cost').translate(cost)
+ }`,
+ buttons: [{
+ label: `${Translation.CONTINUE}`,
+ cssClass: 'btn-success',
+ action(diag) {
+ form.submit();
+ diag.close();
+ },
+ }, {
+ label: `${Translation.CANCEL}`,
+ cssClass: 'btn-danger',
+ action(diag) {
+ diag.close();
+ },
+ }],
+ });
+ });
+ });
+});
+
+function getParent(form) {
+ const table = $(form.parents('table, div')[0]);
+ if (table.find('img').length) {
+ return table;
+ }
+ return table.parent().parent();
+}
diff --git a/src/base/store/quickPacks.js b/src/base/store/quickPacks.js
new file mode 100644
index 00000000..2dff9a90
--- /dev/null
+++ b/src/base/store/quickPacks.js
@@ -0,0 +1,332 @@
+/* eslint-disable no-multi-assign, no-nested-ternary */
+import eventManager from 'src/utils/eventManager.js';
+import eventEmitter from 'src/utils/eventEmitter.js';
+import { global, globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import * as hover from 'src/utils/hover.js';
+import { blankToast, toast as basicToast } from 'src/utils/2.toasts.js';
+import * as api from 'src/utils/4.api.js';
+import formatNumber from 'src/utils/formatNumber.js';
+import { getCollection } from 'src/utils/user.js';
+import { buttonCSS as css, window } from 'src/utils/1.variables.js';
+import Item from 'src/structures/constants/item.js';
+import length from 'src/utils/length';
+
+onPage('Packs', async function quickOpenPack() {
+ const collection = await getCollection();
+ const results = {
+ packs: 0,
+ cards: [],
+ };
+ const status = {
+ state: 'waiting', // waiting, processing, canceled
+ pack: '',
+ original: 0,
+ total: 0, // Total packs to open
+ remaining: 0, // How many packs still need to be opened
+ pending: 0, // How many packs are being waited on
+ pingTimeout: 0,
+ errors: 0,
+ };
+ const events = eventEmitter();
+
+ let timeoutID;
+
+ function setupPing(reset = true) {
+ if (status.state === 'waiting') return;
+ clearPing(reset);
+ timeoutID = setTimeout(() => {
+ status.pingTimeout += 1;
+ if (status.pingTimeout > 10) {
+ events.emit('cancel');
+ }
+ setupPing(false);
+ events.emit('next');
+ }, 200);
+ }
+
+ function clearPing(safe = true) {
+ if (safe) status.pingTimeout = 0;
+ if (timeoutID) clearTimeout(timeoutID);
+ timeoutID = 0;
+ }
+
+ function open(pack, count) {
+ status.pending = 0;
+ const openPack = global('openPack');
+ // const amt = Math.min(step, count);
+ for (let i = 0; i < count; i++) {
+ status.pending += 1;
+ globalSet('canOpen', true);
+ openPack(pack);
+ }
+ }
+
+ function showCards() {
+ const show = global('revealCard', 'show');
+ $('.slot .cardBack').each((i, e) => { show(e, i); });
+ }
+
+ events.on('start', ({
+ pack = '',
+ count: amt = 0,
+ offset = false,
+ }) => {
+ if (openingPacks()) return;
+ results.packs = 0;
+ results.cards = [];
+ status.state = 'processing';
+ status.pack = pack;
+ status.total = amt;
+ status.remaining = amt - offset;
+ status.pending = 0;
+ status.errors = 0;
+ if (!offset) {
+ events.emit('next');
+ }
+ setupPing();
+ });
+
+ let toast = blankToast();
+ events.on('pack', (cards = []) => {
+ status.pending -= 1;
+ results.packs += 1;
+ cards.forEach((card) => {
+ const cCard = collection.find((c) => c.id === card.id && c.shiny === card.shiny);
+ // Don't have one yet?
+ if (!cCard) {
+ // Add to collection
+ collection.push({
+ ...card,
+ quantity: 1,
+ });
+ // Mark as new
+ card.new = true;
+ }
+ });
+ results.cards.push(...cards);
+ events.emit('next');
+ setupPing();
+ });
+
+ events.on('next', () => {
+ if (status.state === 'waiting') return;
+
+ if (status.state === 'processing') {
+ if (status.remaining > 0 && status.pending <= 0) {
+ const count = Math.min(status.remaining, 5);
+ status.remaining -= count;
+ open(status.pack, count);
+ }
+ events.emit('update');
+ }
+
+ const notWaiting = status.pending <= 0;
+ const finishedOpening = results.packs === status.total;
+ const canceled = status.state === 'canceled';
+ const timedout = canceled && status.pingTimeout;
+ if (timedout || notWaiting && (finishedOpening || canceled)) {
+ events.emit('finished');
+ }
+ });
+
+ events.on('update', () => {
+ if (toast.exists()) {
+ toast.setText(` `);
+ } else {
+ // TODO: translation
+ toast = basicToast({
+ title: `Opening ${formatNumber(status.total)} packs`,
+ text: ` `,
+ className: 'dismissable',
+ buttons: {
+ text: 'Stop',
+ className: 'dismiss',
+ css,
+ onclick: (e) => {
+ events.emit('cancel');
+ },
+ },
+ });
+ }
+ });
+
+ const rarity = ['DETERMINATION', 'LEGENDARY', 'EPIC', 'RARE', 'COMMON'];
+ events.on('finished', () => {
+ if (status.state === 'waiting') return; // Invalid state
+ status.state = 'waiting';
+ clearPing();
+ // Post results
+ $(`#nb${status.pack.substring(4, status.pack.length - 4)}Packs`).text(status.original - results.packs);
+ const event = eventManager.cancelable.emit('openedPacks', {
+ count: results.packs,
+ cards: Object.freeze([...results.cards]),
+ });
+ if (!event.canceled) {
+ const cardResults = {
+ shiny: 0,
+ };
+ rarity.forEach((type) => {
+ cardResults[type] = {};
+ });
+ results.cards.forEach((card) => { // Convert each card
+ const r = cardResults[card.rarity];
+ const c = r[card.name] = r[card.name] || { total: 0, shiny: 0, new: false };
+ c.total += 1;
+ if (card.new) c.new = true;
+ if (card.shiny) {
+ if (status.pack !== 'openShinyPack') {
+ cardResults.shiny += 1;
+ }
+ c.shiny += 1;
+ }
+ });
+
+ // Magic numbers, yep. Have between 6...26 cards showing
+ let limit = Math.min(Math.max(Math.floor(window.innerHeight / 38), 6), 26);
+ // Increase the limit if we don't have a specific rarity
+ rarity.forEach((key) => {
+ if (!length(cardResults[key])) {
+ limit += 1;
+ }
+ });
+
+ let text = '';
+ // Build visual results
+ rarity.forEach((key) => {
+ const cards = cardResults[key];
+ const keys = Object.keys(cards);
+ if (!keys.length) return;
+ const buffer = [];
+ let count = 0;
+ let shiny = 0;
+ if (keys.length > limit) keys.sort((a, b) => cards[b].new - cards[a].new); // Push new cards to top of list if we have cards that'll get cut off
+ keys.forEach((name) => {
+ const card = cards[name];
+ count += card.total;
+ shiny += card.shiny;
+ if (limit) {
+ limit -= 1;
+ buffer.push(`${card.new ? `{${$.i18n('cosmetics-new')}} ` : ''}${card.shiny ? 'S ' : ''}${name}${card.total > 1 ? ` (${formatNumber(card.total)}${card.shiny ? `, ${card.shiny}` : ''})` : ''}${limit ? '' : '...'}`);
+ }
+ });
+ text += `${key} (${count}${shiny ? `, ${shiny} shiny` : ''}):${buffer.length ? `\n- ${buffer.join('\n- ')}` : ' ...'}\n`;
+ });
+
+ // Create result toast
+ const total = results.cards.length;
+ basicToast({
+ // TODO: translation?
+ title: `Results: ${formatNumber(results.packs)} Packs${cardResults.shiny ? ` (${total % 4 ? `${formatNumber(total)}, ` : ''}${formatNumber(cardResults.shiny)} shiny)` : total % 4 ? ` (${formatNumber(total)})` : ''}`,
+ text,
+ css: { 'font-family': 'inherit' },
+ });
+
+ // Show cards... I guess
+ showCards();
+ }
+ toast.close();
+ });
+
+ events.on('cancel', () => { // Sets the canceled flag
+ if (status.state === 'processing') {
+ status.state = 'canceled';
+ clearPing();
+ }
+ });
+
+ events.on('error', (err) => {
+ status.pending -= 1;
+ setupPing(); // We got a result, just not what we wanted
+ if (status.state !== 'processing') return; // Invalid state
+ status.errors += 1;
+ // Retry for every pack 3 times (this should act as if you're pushing the button 3 times... which usually opens all packs)
+ if (status.errors <= status.total * 3) {
+ status.remaining += 1;
+ }
+ });
+
+ let autoOpen = false;
+
+ eventManager.on('BootstrapDialog:preshow', function cancel(dialog) {
+ if (openingPacks() && dialog.getTitle() === $.i18n('dialog-error')) {
+ this.canceled = true;
+ }
+ });
+
+ eventManager.on('jQuery', () => {
+ globalSet('translateFromServerJson', function override(message) {
+ try {
+ return this.super(message);
+ } catch {
+ return message;
+ }
+ });
+
+ $(document).ajaxComplete((event, xhr, s) => {
+ if (s.url !== 'PacksConfig' || !s.data) return;
+ const data = xhr.responseJSON;
+ const error = data.action === 'getError';
+ if (data.action !== 'getCards' && !error) return;
+ if (data.cards) { // This has to always be done, to update the collection
+ events.emit('pack', JSON.parse(data.cards));
+ }
+ if (openingPacks()) {
+ if (data.status || error) {
+ events.emit('error', data.message);
+ }
+ } else if (autoOpen && !data.status) {
+ showCards();
+ }
+ });
+ // TODO: translation
+ $('[id^="btnOpen"]').on('click.script', (event) => {
+ autoOpen = event.ctrlKey;
+ const type = $(event.target).prop('id').substring(7);
+ const count = autoOpen ? 1 : parseInt($(`#nb${type}Packs`).text(), 10);
+ if (event.shiftKey) {
+ openPacks(type, count, 1);
+ hover.hide();
+ } else if (count === 1) { // Last pack?
+ hover.hide();
+ }
+ }).on('mouseenter.script', hover.show(`
+ * CTRL Click to auto reveal one (1) pack
+ * Shift Click to auto open ALL packs
+ `)).on('mouseleave.script', hover.hide);
+ });
+
+ function openingPacks() {
+ return status.state !== 'waiting';
+ }
+
+ function openPacks(type, count, start = 0) {
+ if (openingPacks()) return;
+ const packs = parseInt($(`#nb${type}Packs`).text(), 10);
+ // eslint-disable-next-line no-param-reassign
+ count = Math.max(Math.min(count, packs), 0);
+ if (count === 0) return;
+ status.original = packs;
+ events.emit('start', {
+ pack: `open${type}Pack`,
+ count,
+ offset: start,
+ });
+ }
+
+ const types = ['', 'DR', 'UTY', 'Shiny', 'Super', 'Final'];
+ const packTypes = [Item.UT_PACK, Item.DR_PACK, Item.UTY_PACK, Item.SHINY_PACK, Item.SUPER_PACK, Item.FINAL_PACK];
+ api.register('openPacks', (count, type = '') => {
+ if (openingPacks()) throw new Error('Currently opening packs');
+ if (type instanceof Item) {
+ const index = packTypes.indexOf(type);
+ if (index === -1) throw new Error(`Unsupported Item: ${type}`);
+ openPacks(types[index], count);
+ return;
+ }
+ if (!types.includes(type)) throw new Error(`Unsupported Pack: ${type}`);
+ openPacks(type, count);
+ });
+
+ api.register('openingPacks', openingPacks);
+}, 'quickPacks');
diff --git a/src/base/streamer/0.streamer.js b/src/base/streamer/0.streamer.js
new file mode 100644
index 00000000..e7243d20
--- /dev/null
+++ b/src/base/streamer/0.streamer.js
@@ -0,0 +1,58 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import * as api from 'src/utils/4.api.js';
+import { toast } from 'src/utils/2.toasts.js';
+import * as menu from 'src/utils/menu.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const silent = 'Yes (silent)';
+const disabled = 'No';
+const data = [
+ [Translation.Setting('streamer.option.1'), 'Yes'],
+ [Translation.Setting('streamer.option.2'), silent],
+ [Translation.Setting('streamer.option.3'), disabled],
+];
+const mode = settings.register({
+ name: Translation.Setting('streamer'),
+ key: 'underscript.streamer',
+ note: Translation.Setting('streamer.note'),
+ data,
+ default: disabled,
+ onChange: (val) => {
+ if (val === disabled) {
+ update(false);
+ } else {
+ menu.dirty();
+ }
+ },
+ type: 'select',
+ category: Translation.CATEGORY_STREAMER,
+});
+const setting = settings.register({
+ key: 'underscript.streaming',
+ hidden: true,
+});
+api.register('streamerMode', streaming);
+const ON = Translation.Menu('streamer.on');
+const OFF = Translation.Menu('streamer.off');
+menu.addButton({
+ text: () => (streaming() ? ON : OFF),
+ hidden: () => mode.value() === disabled,
+ action: () => update(!streaming()),
+});
+eventManager.on(':preload', alert);
+
+function alert() {
+ if (!streaming() || mode.value() === silent) return;
+ toast(Translation.Toast('streamer'));
+}
+
+function update(value) {
+ setting.set(value);
+ menu.dirty();
+ alert();
+}
+
+export default function streaming() {
+ return setting.value();
+}
diff --git a/src/base/streamer/email.js b/src/base/streamer/email.js
new file mode 100644
index 00000000..9dd4b068
--- /dev/null
+++ b/src/base/streamer/email.js
@@ -0,0 +1,11 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as $el from 'src/utils/elementHelper.js';
+import streaming from './0.streamer.js';
+
+eventManager.on(':preload:Settings', () => {
+ if (!streaming()) return;
+ $el.text.contains(document.querySelectorAll('p'), 'Mail :').forEach((e) => {
+ // TODO: translation?
+ e.innerText = 'Mail : ';
+ });
+});
diff --git a/src/base/streamer/message.private.js b/src/base/streamer/message.private.js
new file mode 100644
index 00000000..e44c3b38
--- /dev/null
+++ b/src/base/streamer/message.private.js
@@ -0,0 +1,76 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global } from 'src/utils/global.js';
+import { toast } from 'src/utils/2.toasts.js';
+import { debug } from 'src/utils/debug.js';
+import each from 'src/utils/each.js';
+import { buttonCSS as css } from 'src/utils/1.variables.js';
+import { isMod, name as username } from 'src/utils/user';
+import streaming from './0.streamer.js';
+
+// Toast for private messages while streaming mode is on
+// TODO: translation
+const busyMessage = ':me:is in do not disturb mode'; // TODO: configurable?
+const allow = 'Allow';
+const hide = 'Hide';
+const silent = 'Hide (silent)';
+const setting = settings.register({
+ name: 'Private Messages',
+ key: 'underscript.streamer.pms',
+ options: [allow, hide, silent],
+ default: hide,
+ category: 'Streamer Mode',
+});
+
+const toasts = {};
+
+eventManager.on('preChat:getPrivateMessage', function streamerMode(data) {
+ if (!streaming() || data.open) return; // if not streaming, if window is already open
+
+ const val = setting.value();
+ if (val === allow) return; // if private messages are allowed
+ debug(data);
+
+ const message = JSON.parse(data.chatMessage);
+ const user = message.user;
+
+ if (isMod(user)) return; // Moderators are always allowed
+
+ this.canceled = true; // Cancel the event from going through
+
+ const userId = user.id;
+ const privateChats = global('privateChats');
+ const history = privateChats[userId] || [];
+ history.push(message);
+
+ if (userId === global('selfId')) return; // ignore automated reply
+
+ global('sendPrivateMessage')(busyMessage, `${userId}`); // send a message that you're busy
+
+ if (val === silent || toasts[userId]) return; // Don't announce anymore
+ toasts[userId] = toast({
+ // TODO: translation
+ text: `Message from ${username(user)}`,
+ buttons: [{
+ css,
+ // TODO: translation
+ text: 'Open',
+ className: 'dismiss',
+ onclick: () => {
+ open(user);
+ },
+ }],
+ className: 'dismissable',
+ });
+});
+eventManager.on(':unload', closeAll);
+
+function open(user) {
+ const { id } = user;
+ global('openPrivateRoom')(id, username(user).replace('\'', ''));
+ delete toasts[id];
+}
+
+function closeAll() {
+ each(toasts, (t) => t.close());
+}
diff --git a/src/base/styles/chat.js b/src/base/styles/chat.js
new file mode 100644
index 00000000..c7d5765d
--- /dev/null
+++ b/src/base/styles/chat.js
@@ -0,0 +1,8 @@
+import style from 'src/utils/style.js';
+
+style.add(
+ '.chat-message { overflow-wrap: break-word; }', // Break text
+ '.chat-messages { user-select: text; }', // Always allow chat to be selected
+ '.chat-messages { height: calc(100% - 30px); }', // Fix chat window being all funky with sizes
+ '.chat-messages { min-height: 100px; }', // Fix chat getting too small
+);
diff --git a/src/base/styles/general.js b/src/base/styles/general.js
new file mode 100644
index 00000000..89ca154b
--- /dev/null
+++ b/src/base/styles/general.js
@@ -0,0 +1,6 @@
+import style from 'src/utils/style.js';
+
+style.add(
+ '.clickable { cursor: pointer; }',
+ '.mainContent { margin-bottom: 55px; }', // Never have the footer cover the bottom of the page
+);
diff --git a/src/base/styles/material.inject.js b/src/base/styles/material.inject.js
new file mode 100644
index 00000000..3fd70910
--- /dev/null
+++ b/src/base/styles/material.inject.js
@@ -0,0 +1,8 @@
+import eventManager from 'src/utils/eventManager.js';
+
+eventManager.on(':preload', () => {
+ const el = document.createElement('link');
+ el.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
+ el.rel = 'stylesheet';
+ document.head.append(el);
+});
diff --git a/src/base/styles/toast.js b/src/base/styles/toast.js
new file mode 100644
index 00000000..282984f4
--- /dev/null
+++ b/src/base/styles/toast.js
@@ -0,0 +1,25 @@
+import style from 'src/utils/style.js';
+
+style.add(`
+ #AlertToast {
+ height: 0;
+ }
+ #AlertToast .dismissable > span {
+ display: block;
+ text-align: center;
+ }
+ #AlertToast .dismissable .dismiss {
+ background-color: transparent;
+ border: 1px solid #fff;
+ display: block;
+ font-family: DTM-Mono;
+ font-size: 14px;
+ margin: 5px auto;
+ max-width: 80%;
+ min-width: 160px;
+ text-transform: capitalize;
+ }
+ #AlertToast .dismissable .dismiss:hover {
+ opacity: 0.6;
+ }
+`);
diff --git a/src/base/translate/fixStats.js b/src/base/translate/fixStats.js
new file mode 100644
index 00000000..cf8136b7
--- /dev/null
+++ b/src/base/translate/fixStats.js
@@ -0,0 +1,11 @@
+import eventManager from 'src/utils/eventManager.js';
+
+eventManager.on('translation:loaded', () => {
+ const CLASSES = ['cost-color', 'atk-color', 'hp-color'];
+ $.extend($.i18n.parser.emitter, {
+ stats: (nodes) => CLASSES
+ .slice(Math.max(0, 3 - nodes.length))
+ .map((clazz, i) => nodes[i].replace(/\d+/, `$& `))
+ .join('/'),
+ });
+});
diff --git a/src/base/translate/preview.js b/src/base/translate/preview.js
new file mode 100644
index 00000000..9370a229
--- /dev/null
+++ b/src/base/translate/preview.js
@@ -0,0 +1,47 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+import toLocale from 'src/utils/toLocale.js';
+
+eventManager.on(':preload:Translate', () => {
+ loadLanguages();
+
+ globalSet('createTranslator', newTranslator);
+
+ eventManager.on('ShowPage', newShowPage);
+});
+
+function newTranslator(translator) {
+ return `
${this.super(translator)}`;
+}
+
+function newShowPage() {
+ const textarea = $('#translators textarea');
+ const preview = $('#preview');
+ textarea.on('input', () => {
+ const text = textarea.val().trim();
+ // TODO: Wait if error?
+ preview.html(text ? `${getPreview('decks-preview')}: ${getPreview(text)}` : '');
+ });
+}
+
+function getPreview(id, locale = getLocale()) {
+ return toLocale({
+ locale,
+ id,
+ data: [1],
+ });
+}
+
+function getLocale() {
+ return document.querySelector('#selectLanguage').value.toLowerCase();
+}
+
+function loadLanguages() {
+ const languages = {};
+ const version = global('translateVersion');
+ $('#selectLanguage option').each(function languageOption() {
+ const lang = this.value.toLowerCase();
+ languages[lang] = `/translation/${lang}.json?v=${version}`;
+ });
+ $.i18n().load(languages);
+}
diff --git a/src/base/underscript/changelog.js b/src/base/underscript/changelog.js
new file mode 100644
index 00000000..c2faa696
--- /dev/null
+++ b/src/base/underscript/changelog.js
@@ -0,0 +1,110 @@
+import showdown from 'showdown';
+import axios from 'axios';
+import { scriptVersion } from 'src/utils/1.variables.js';
+import style from 'src/utils/style.js';
+import * as menu from 'src/utils/menu.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const changelog = {};
+
+const keys = {
+ title: Translation.General('changelog'),
+ text: Translation.Menu('changelog'),
+ note: Translation.Menu('changelog.note'),
+ loading: Translation.General('changelog.loading'),
+ unavailable: Translation.General('changelog.unavailable'),
+};
+
+// Change log :O
+style.add(`
+ .us-changelog h2 {
+ font-size: 24px;
+ }
+
+ .us-changelog h3 {
+ font-size: 20px;
+ }
+
+ extended {
+ display: contents;
+ }
+`);
+function getMarkdown() {
+ if (!changelog.markdown) {
+ changelog.markdown = new showdown.Converter({
+ noHeaderId: true,
+ strikethrough: true,
+ disableForced4SpacesIndentedSublists: true,
+ });
+ }
+ return changelog.markdown;
+}
+function getAxios() {
+ if (!changelog.axios) {
+ // TODO: get from github?
+ changelog.axios = axios.create({ baseURL: 'https://unpkg.com/' });
+ }
+ return changelog.axios;
+}
+
+function open(message) {
+ BootstrapDialog.show({
+ message,
+ title: `${keys.title}`,
+ cssClass: 'mono us-changelog',
+ buttons: [{
+ label: `${Translation.CLOSE}`,
+ action(self) {
+ self.close();
+ },
+ }],
+ });
+}
+
+export function get(version = 'latest', short = false) {
+ const cache = version.includes('.');
+ const key = `${version}${short ? '_short' : ''}`;
+ if (cache && changelog[key]) return Promise.resolve(changelog[key]);
+
+ const extension = `underscript@${version}/changelog.md`;
+ return getAxios().get(extension).then(({ data: text }) => {
+ const first = text.indexOf(`\n## ${cache ? `Version ${version}` : ''}`);
+ let end;
+ if (!~first) throw new Error('Invalid Changelog');
+ if (short) {
+ const index = text.indexOf('\n## ', first + 1);
+ if (index !== -1) end = index;
+ }
+ const parsedHTML = getMarkdown().makeHtml(text.substring(first, end).trim()).replace(/\r?\n/g, '');
+ // Cache results
+ if (cache) changelog[key] = parsedHTML;
+ return parsedHTML;
+ });
+}
+
+export function load(version = 'latest', short = false) {
+ const container = $('').text(keys.loading);
+ open(container);
+ get(version, short).catch((e) => {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ return `${keys.unavailable}`;
+ }).then((m) => container.html(m));
+}
+
+// Add menu button
+menu.addButton({
+ text: keys.text,
+ action() {
+ load(scriptVersion === 'L' ? 'latest' : scriptVersion);
+ },
+ enabled() {
+ return typeof BootstrapDialog !== 'undefined';
+ },
+ note() {
+ if (!this.enabled()) {
+ return keys.note;
+ }
+ return undefined;
+ },
+});
diff --git a/src/base/underscript/chat/ignorelist.js b/src/base/underscript/chat/ignorelist.js
new file mode 100644
index 00000000..cc56edb5
--- /dev/null
+++ b/src/base/underscript/chat/ignorelist.js
@@ -0,0 +1,10 @@
+import each from 'src/utils/each.js';
+import eventManager from 'src/utils/eventManager.js';
+import ignoreUser from 'src/utils/ignoreUser.js';
+
+eventManager.on(':load', () => {
+ each(localStorage, (name, key) => {
+ if (!key.startsWith('underscript.ignore.')) return;
+ ignoreUser(name, key);
+ });
+});
diff --git a/src/base/underscript/discord.js b/src/base/underscript/discord.js
new file mode 100644
index 00000000..04276242
--- /dev/null
+++ b/src/base/underscript/discord.js
@@ -0,0 +1,6 @@
+import eventManager from 'src/utils/eventManager.js';
+import addMenuButton from 'src/utils/menubuttons.js';
+
+eventManager.on(':load', () => {
+ addMenuButton(`
Discord`, 'https://discord.gg/D8DFvrU');
+});
diff --git a/src/base/underscript/patchnotes.js b/src/base/underscript/patchnotes.js
new file mode 100644
index 00000000..10641b5f
--- /dev/null
+++ b/src/base/underscript/patchnotes.js
@@ -0,0 +1,62 @@
+import * as settings from 'src/utils/settings/index.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { noop, scriptVersion } from 'src/utils/1.variables.js';
+import style from 'src/utils/style.js';
+import { toast } from 'src/utils/2.toasts.js';
+import Translation from 'src/structures/constants/translation.ts';
+import eventManager from 'src/utils/eventManager';
+import * as changelog from './changelog.js';
+
+wrap(function patchNotes() {
+ const setting = settings.register({
+ name: Translation.Setting('patches'),
+ key: 'underscript.disable.patches',
+ });
+ const installed = settings.register({
+ key: 'underscript.update.installed',
+ type: 'text',
+ hidden: true,
+ converter() {
+ const key = `underscript.update.${scriptVersion}`;
+ if (localStorage.getItem(key)) {
+ localStorage.removeItem(key);
+ return scriptVersion;
+ }
+ return undefined;
+ },
+ });
+
+ if (
+ setting.value() ||
+ !scriptVersion.includes('.') ||
+ installed.value() === scriptVersion
+ ) return;
+
+ style.add(`
+ #AlertToast div.uschangelog span:nth-of-type(2) {
+ max-height: 300px;
+ overflow-y: auto;
+ display: block;
+ }
+
+ #AlertToast div.uschangelog extended {
+ display: none;
+ }
+ `);
+
+ changelog.get(scriptVersion, true)
+ .then((text) => eventManager.on('underscript:ready', () => notify(text)))
+ .catch(noop);
+
+ function notify(text) {
+ toast({
+ text,
+ title: Translation.Toast('patch.notes'),
+ footer: `v${scriptVersion}`,
+ className: 'uschangelog',
+ onClose() {
+ installed.set(scriptVersion);
+ },
+ });
+ }
+});
diff --git a/src/base/underscript/settings/advanced.html b/src/base/underscript/settings/advanced.html
new file mode 100644
index 00000000..1af11c70
--- /dev/null
+++ b/src/base/underscript/settings/advanced.html
@@ -0,0 +1,10 @@
+
+
+ Export
+
+
+
+
+ Import
+
+
diff --git a/src/base/underscript/settings/advanced.local.js b/src/base/underscript/settings/advanced.local.js
new file mode 100644
index 00000000..259a3c01
--- /dev/null
+++ b/src/base/underscript/settings/advanced.local.js
@@ -0,0 +1,19 @@
+import * as settings from 'src/utils/settings/index.js';
+import html from './advanced.html';
+
+const page = document.createElement('div');
+page.innerHTML = html;
+page.querySelector('#underscriptExportButton').addEventListener('click', () => save());
+// TODO: Translate export/import
+
+settings.getScreen().addTab('Advanced', page).setEnd(true);
+
+function save(data = settings.exportSettings()) {
+ if (!data) return;
+ const t = new Blob([data], { type: 'text/plain' });
+
+ const a = document.createElement('a');
+ a.download = 'underscript.settings.txt';
+ a.href = URL.createObjectURL(t);
+ a.click();
+}
diff --git a/src/base/underscript/translation.js b/src/base/underscript/translation.js
new file mode 100644
index 00000000..2bf1a382
--- /dev/null
+++ b/src/base/underscript/translation.js
@@ -0,0 +1,51 @@
+import Translation from 'src/structures/constants/translation.ts';
+import clone from 'src/utils/clone';
+import eventManager from 'src/utils/eventManager';
+
+/** @type {Map
} */
+const arrays = new Map();
+
+// TODO: include base underscript.json in script so we always have something to show?
+const translations = (async () => {
+ const response = await fetch(
+ 'https://raw.githubusercontent.com/UCProjects/UnderScript/refs/heads/master/lang/underscript.json',
+ {
+ cache: 'default',
+ },
+ );
+ const data = await response.text();
+ const text = typeof GM_getResourceText === 'undefined' ?
+ data :
+ GM_getResourceText('underscript.json') || data;
+ return JSON.parse(text, function reviver(key, value) {
+ if (Array.isArray(value)) {
+ if (!arrays.has(key)) {
+ arrays.set(key, value.map(
+ (_, i) => new Translation(`${key}.${i + 1}`, { prefix: null }),
+ ));
+ }
+ value.forEach((val, i) => {
+ this[`${key}.${i + 1}`] = val;
+ });
+ return undefined;
+ }
+ return value;
+ });
+})();
+
+eventManager.on('translation:loaded', async () => {
+ await $.i18n().load(await translations);
+ eventManager.singleton.emit('translation:underscript');
+});
+
+export function getLength(key) {
+ return arrays.get(key)?.length ?? 0;
+}
+
+/**
+ * @param {string} key
+ * @returns {Translation[]}
+ */
+export function getTranslationArray(key) {
+ return clone(arrays.get(key)) ?? [];
+}
diff --git a/src/base/underscript/updates.js b/src/base/underscript/updates.js
new file mode 100644
index 00000000..3da46610
--- /dev/null
+++ b/src/base/underscript/updates.js
@@ -0,0 +1,93 @@
+import style from 'src/utils/style.js';
+import wrap from 'src/utils/2.pokemon.js';
+import debugToast from 'src/utils/debugToast';
+import { toast as Toast } from 'src/utils/2.toasts.js';
+import semver from 'src/utils/version.js';
+import { buttonCSS, scriptVersion, window } from 'src/utils/1.variables.js';
+import createParser from 'src/utils/parser';
+import eventManager from 'src/utils/eventManager';
+import {
+ register,
+ silent,
+ unregister,
+ validate,
+} from 'src/hooks/updates';
+import plugin from 'src/utils/underscript';
+import Translation from 'src/structures/constants/translation.ts';
+
+// Check for script updates
+wrap(() => {
+ style.add(`
+ #AlertToast h2,
+ #AlertToast h3 {
+ margin: 0;
+ font-size: 20px;
+ }
+
+ #AlertToast h3 {
+ font-size: 17px;
+ }
+ `);
+ const checker = createParser({ updateURL: 'UCProjects/UnderScript' });
+ const DEBUG = 'underscript.update.debug';
+ let updateToast;
+ function isNewer(data) {
+ const version = scriptVersion;
+ if (version === 'L' && !localStorage.getItem(DEBUG)) return false;
+ if (version.includes('-')) return semver(data.newVersion, version); // Allow test scripts to update to release
+ return data.newVersion !== version; // Always assume that the marked version is better
+ }
+ function compareAndToast(data) {
+ if (!data || !isNewer(data)) {
+ updateToast?.close('invalid');
+ return false;
+ }
+ eventManager.once(':update:finished :update:force', () => {
+ updateToast?.close('stale');
+ if (data.announce) return;
+ updateToast = Toast({
+ title: Translation.Toast('update.title'),
+ text: Translation.Toast('update.text').withArgs(data.newVersion),
+ className: 'dismissable',
+ buttons: [{
+ text: Translation.Toast('update'),
+ className: 'dismiss',
+ css: buttonCSS,
+ onclick() {
+ const url = data.url || `https://github.com/UCProjects/UnderScript/releases/download/${data.version}/undercards.user.js`;
+ window.open(url, 'updateUserScript', 'noreferrer');
+ },
+ }],
+ });
+ });
+ return true;
+ }
+ eventManager.on(':update', async (auto) => {
+ if (!auto) {
+ unregister(plugin);
+ }
+ try {
+ const data = await checker.getUpdateData();
+ const update = {
+ url: await checker.getDownload(data),
+ newVersion: await checker.getVersion(data),
+ time: data.assets.find(({ name }) => name.endsWith('.user.js')).updated_at,
+ // TODO: separate setting
+ announce: silent.value(),
+ version: scriptVersion.includes('.') ? scriptVersion : undefined,
+ plugin,
+ };
+ if (compareAndToast(update)) {
+ register(update);
+ }
+ } catch (error) {
+ debugToast(error);
+ }
+ });
+
+ // Toast if update pending
+ eventManager.on('underscript:ready', () => {
+ compareAndToast(validate(plugin));
+ eventManager.emit(':update:force');
+ });
+});
diff --git a/src/base/vanilla/aprilFools.js b/src/base/vanilla/aprilFools.js
new file mode 100644
index 00000000..46ecf95d
--- /dev/null
+++ b/src/base/vanilla/aprilFools.js
@@ -0,0 +1,55 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { isApril, IMAGES } from 'src/utils/isApril.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const year = `${new Date().getFullYear()}`;
+export const aprilFools = settings.register({
+ name: Translation.Setting('fishday'),
+ key: 'underscript.disable.fishday',
+ note: Translation.Setting('fishday.note'),
+ data: { extraValue: year },
+ hidden: () => !isApril() || isSoftDisabled(),
+ onChange() {
+ toggleFish($('body'));
+ },
+});
+
+export function isSoftDisabled() {
+ return localStorage.getItem(aprilFools.key) === year;
+}
+
+const basePath = 'images';
+
+function toggleFish($el) {
+ const disabled = aprilFools.value();
+ const search = disabled ? IMAGES : basePath;
+ const replace = disabled ? basePath : IMAGES;
+
+ $el.find(`img[src*="undercards.net/${search}/"],img[src^="/${search}/"],img[src^="${search}/"]`).each((_, img) => {
+ img.src = img.src.replace(search, replace);
+ }).one('error', () => aprilFools.set(year));
+ $el.find(`[style*="url(\\"${search}/"]`).each((i, img) => {
+ img.style.background = img.style.background.replace(search, replace);
+ }).one('error', () => aprilFools.set(year));
+}
+
+eventManager.on('undercards:season', () => {
+ if (!isApril() || isSoftDisabled()) return;
+ eventManager.on(':load', () => {
+ toggleFish($('body'));
+ });
+ eventManager.on('func:appendCard', (card, container) => {
+ toggleFish(container);
+ });
+ eventManager.on('Chat:getMessage', ({ chatMessage }) => {
+ const { id } = JSON.parse(chatMessage);
+ toggleFish($(`#message-${id}`));
+ });
+ eventManager.on('Chat:getHistory', ({ room }) => {
+ toggleFish($(`#${room}`));
+ });
+ eventManager.on('Home:Refresh', () => {
+ toggleFish($('table.spectateTable'));
+ });
+});
diff --git a/src/base/vanilla/card.append.js b/src/base/vanilla/card.append.js
new file mode 100644
index 00000000..0373fc64
--- /dev/null
+++ b/src/base/vanilla/card.append.js
@@ -0,0 +1,69 @@
+import { window } from 'src/utils/1.variables.js';
+import getExtras from 'src/utils/appendCardExtras.js';
+import each from 'src/utils/each.js';
+import eventEmitter from 'src/utils/eventEmitter.js';
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+import VarStore from 'src/utils/VarStore.js';
+
+const PREFIX = 'appendCard';
+const internal = eventEmitter();
+let event = PREFIX;
+let data = [];
+const extras = VarStore();
+
+internal.on('set', (e = PREFIX) => {
+ event = e;
+}).on('pre', (...args) => {
+ eventManager.emit(`pre:func:${event}`, ...args);
+ if (event !== PREFIX) eventManager.emit(`pre:func:${PREFIX}`, ...args);
+}).on('post', (...args) => {
+ if (event === PREFIX || !args.length) {
+ const eventData = [
+ ...data,
+ ...args,
+ ];
+ if (eventData.length) {
+ eventManager.emit(`func:${event}`, ...eventData);
+ if (event !== PREFIX) eventManager.emit(`func:${PREFIX}`, ...eventData);
+ }
+ data = [];
+ event = PREFIX; // Reset
+ } else {
+ data = args;
+ if (extras.isSet()) data.push(...extras.value);
+ }
+});
+
+eventManager.on(':preload', () => {
+ const set = globalSet(PREFIX, function appendCard(card, container) {
+ internal.emit('pre', card);
+ const element = this.super(card, container);
+ if (eventManager.emit(`${PREFIX}()`, { card, element, container }).ran) { // Support old listeners
+ // eslint-disable-next-line no-console -- Warn developers to not use this
+ console.warn(`'${PREFIX}()' is deprecated, please use 'func:${PREFIX}' instead`);
+ }
+ internal.emit('post', card, element);
+ return element;
+ }, {
+ throws: false,
+ });
+ if (set === undefined) return;
+
+ function override(key) {
+ globalSet(key, function func(...args) {
+ extras.value = getExtras(key, args);
+ internal.emit('set', key);
+ const ret = this.super(...args);
+ internal.emit('post');
+ return ret;
+ });
+ }
+
+ const otherKeys = ['showCardHover'];
+ each(window, (_, key = '') => {
+ if (key !== PREFIX && key.startsWith(PREFIX) || otherKeys.includes(key)) {
+ override(key);
+ }
+ });
+});
diff --git a/src/base/vanilla/card.text.outline.js b/src/base/vanilla/card.text.outline.js
new file mode 100644
index 00000000..d856f2ea
--- /dev/null
+++ b/src/base/vanilla/card.text.outline.js
@@ -0,0 +1,29 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import VarStore from 'src/utils/VarStore.js';
+import style from 'src/utils/style.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('card.text.outline'),
+ key: 'underscript.card.text.outline',
+ page: 'Library',
+ default: true,
+ onChange: toggle,
+ category: Translation.CATEGORY_OUTLINE,
+});
+const art = VarStore();
+
+function toggle(add = setting.value()) {
+ if (art.isSet()) {
+ if (add) return;
+ art.get().remove();
+ }
+ if (add) {
+ art.set(style.add(
+ '.card { text-shadow: -1px -1px black, 1px 1px black, -1px 1px black, 1px -1px black; }',
+ ));
+ }
+}
+
+eventManager.on(':preload', toggle);
diff --git a/src/base/vanilla/card.tribe.outline.js b/src/base/vanilla/card.tribe.outline.js
new file mode 100644
index 00000000..5284140e
--- /dev/null
+++ b/src/base/vanilla/card.tribe.outline.js
@@ -0,0 +1,29 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import VarStore from 'src/utils/VarStore.js';
+import style from 'src/utils/style.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('card.tribe.outline'),
+ key: 'underscript.card.tribe.outline',
+ page: 'Library',
+ default: true,
+ onChange: toggle,
+ category: Translation.CATEGORY_OUTLINE,
+});
+const art = VarStore();
+
+function toggle(add = setting.value()) {
+ if (art.isSet()) {
+ if (add) return;
+ art.get().remove();
+ }
+ if (add) {
+ art.set(style.add(
+ '.cardTribes .tribe { color: rgb(0, 0, 0); filter: drop-shadow(0px 0px) drop-shadow(0px 0px); }',
+ ));
+ }
+}
+
+eventManager.on(':preload', toggle);
diff --git a/src/base/vanilla/cardBaseArt.js b/src/base/vanilla/cardBaseArt.js
new file mode 100644
index 00000000..5179dc3a
--- /dev/null
+++ b/src/base/vanilla/cardBaseArt.js
@@ -0,0 +1,27 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { globalSet } from 'src/utils/global.js';
+import { window } from 'src/utils/1.variables.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('skins.basic'),
+ key: 'underscript.hide.card-skins',
+ page: 'Library',
+ category: Translation.CATEGORY_CARD_SKINS,
+});
+
+function createCard(card) {
+ const image = card.baseImage;
+ if (setting.value() && image && image !== card.image) {
+ card.typeSkin = 0;
+ card.originalImage = card.image;
+ card.image = image;
+ }
+ return this.super(card);
+}
+
+eventManager.on(':preload', () => {
+ if (!window.createCard) return;
+ globalSet('createCard', createCard);
+});
diff --git a/src/base/vanilla/cardBreakingArt.js b/src/base/vanilla/cardBreakingArt.js
new file mode 100644
index 00000000..f5238e72
--- /dev/null
+++ b/src/base/vanilla/cardBreakingArt.js
@@ -0,0 +1,53 @@
+import Translation from 'src/structures/constants/translation.ts';
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import style from 'src/utils/style.js';
+import VarStore from 'src/utils/VarStore.js';
+import { getTranslationArray } from '../underscript/translation.js';
+
+const def = 'Breaking (Default)';
+const tran = 'Covered (Transparent)';
+const dis = 'Covered';
+
+const setting = settings.register({
+ name: Translation.Setting('skins.breaking'),
+ key: 'underscript.hide.breaking-skin',
+ options: () => {
+ const { key } = Translation.Setting('skins.breaking.option');
+ const options = getTranslationArray(key);
+ return [def, tran, dis].map((val, i) => [
+ options[i],
+ val,
+ ]);
+ },
+ page: 'Library',
+ onChange: update,
+ category: Translation.CATEGORY_CARD_SKINS,
+ converter(value) {
+ switch (value) {
+ case '0': return def;
+ case '1': return dis;
+ default: return undefined;
+ }
+ },
+});
+const art = VarStore();
+const type1 = 'rgb(0, 0, 0)';
+const type2 = 'rgba(0, 0, 0, 0.2)';
+
+function update(value) {
+ if (art.isSet()) {
+ art.get().remove();
+ }
+ if (value === def) return;
+
+ const color = value === tran ? type2 : type1;
+ art.set(style.add(
+ `.breaking-skin .cardHeader, .breaking-skin .cardFooter { background-color: ${color}; }`,
+ '.breaking-skin .cardImage { z-index: 1; }',
+ ));
+}
+
+eventManager.on(':preload', () => {
+ update(setting.value());
+});
diff --git a/src/base/vanilla/cardFullArt.js b/src/base/vanilla/cardFullArt.js
new file mode 100644
index 00000000..99059031
--- /dev/null
+++ b/src/base/vanilla/cardFullArt.js
@@ -0,0 +1,28 @@
+import Translation from 'src/structures/constants/translation.ts';
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import style from 'src/utils/style.js';
+import VarStore from 'src/utils/VarStore.js';
+
+const setting = settings.register({
+ name: Translation.Setting('skins.full'),
+ key: 'underscript.hide.full-skin',
+ page: 'Library',
+ onChange: toggle,
+ category: Translation.CATEGORY_CARD_SKINS,
+});
+const art = VarStore();
+
+function toggle() {
+ if (art.isSet()) {
+ art.get().remove();
+ } else {
+ art.set(style.add(
+ '.full-skin .cardHeader, .full-skin .cardFooter { background-color: rgb(0, 0, 0); }',
+ ));
+ }
+}
+
+eventManager.on(':preload', () => {
+ if (setting.value()) toggle();
+});
diff --git a/src/base/vanilla/cardHover.js b/src/base/vanilla/cardHover.js
new file mode 100644
index 00000000..7e171f98
--- /dev/null
+++ b/src/base/vanilla/cardHover.js
@@ -0,0 +1,17 @@
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+
+function wrapper(...rest) {
+ this.super(...rest);
+ $('#hover-card').click(function hoverCard() {
+ $(this).remove();
+ });
+}
+
+eventManager.on(':preload', () => {
+ const options = {
+ throws: false,
+ };
+ globalSet('displayCardDeck', wrapper, options);
+ globalSet('displayCardHelp', wrapper, options);
+});
diff --git a/src/base/vanilla/cardName.js b/src/base/vanilla/cardName.js
new file mode 100644
index 00000000..c947b691
--- /dev/null
+++ b/src/base/vanilla/cardName.js
@@ -0,0 +1,26 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { globalSet } from 'src/utils/global.js';
+import { toEnglish } from 'src/utils/toLocale.js';
+import { window } from 'src/utils/1.variables.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const setting = settings.register({
+ name: Translation.Setting('card.name.english'),
+ key: 'underscript.standardized.cardname',
+});
+
+function createCard(card, ...rest) {
+ if (!setting.value() || $.i18n().locale === 'en') {
+ return this.super(card, ...rest);
+ }
+
+ const c = $(this.super(card, ...rest));
+ c.find('.cardName').text(toEnglish(`card-name-${card.fixedId}`, 1));
+ return c[0].outerHTML;
+}
+
+eventManager.on(':preload', () => {
+ if (!window.createCard || !$.i18n) return;
+ globalSet('createCard', createCard);
+});
diff --git a/src/base/vanilla/editor.js b/src/base/vanilla/editor.js
new file mode 100644
index 00000000..d0443c51
--- /dev/null
+++ b/src/base/vanilla/editor.js
@@ -0,0 +1,16 @@
+import eventManager from 'src/utils/eventManager.js';
+import addMenuButton from 'src/utils/menubuttons.js';
+
+eventManager.on('jQuery', () => {
+ const text = `
+
+
+
+ `;
+ const $text = $(text);
+ $('a[data-i18n-title="footer-wiki"]').parent().after($text);
+});
+eventManager.on(':load', () => {
+ const img = ' ';
+ addMenuButton(`${img} Card Editor`, 'https://undercard.feildmaster.com');
+});
diff --git a/src/base/vanilla/header.sticky.js b/src/base/vanilla/header.sticky.js
new file mode 100644
index 00000000..165a7ab1
--- /dev/null
+++ b/src/base/vanilla/header.sticky.js
@@ -0,0 +1,28 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import style from 'src/utils/style.js';
+import onPage from 'src/utils/onPage.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+style.add(
+ '.navbar.navbar-default.sticky { position: sticky; top: 0; z-index: 10; -webkit-transform: translateZ(0); transform: translateZ(0); }',
+);
+
+const setting = settings.register({
+ name: Translation.Setting('header.sticky'),
+ key: 'underscript.disable.header.scrolling',
+ onChange: (to) => {
+ toggle(!to);
+ },
+});
+
+eventManager.on(':preload', () => {
+ toggle(!setting.value());
+});
+
+function toggle(val) {
+ if (onPage('Decks')) return;
+ const el = document.querySelector('.navbar.navbar-default');
+ if (!el) return;
+ el.classList.toggle('sticky', val);
+}
diff --git a/src/base/vanilla/iconHelper.js b/src/base/vanilla/iconHelper.js
new file mode 100644
index 00000000..8a4bd602
--- /dev/null
+++ b/src/base/vanilla/iconHelper.js
@@ -0,0 +1,38 @@
+import tippy from 'tippy.js';
+import eventManager from 'src/utils/eventManager.js';
+import style from 'src/utils/style.js';
+import each from 'src/utils/each.js';
+
+// TODO: translation?
+const icons = {
+ gold: 'Gold',
+ dust: 'Dust Used to craft cards',
+ pack: 'Undertale Pack',
+ packPlus: 'Undertale Pack',
+ drPack: 'Deltarune Pack',
+ drPackPlus: 'Deltarune Pack',
+ 'shinyPack.gif': 'Shiny Pack All cards are shiny',
+ 'superPack.gif': 'Super Pack Contains: Common x1 Rare x1 Epic x1 Legendary x1 ',
+ 'finalPack.gif': 'Final Pack Contains: Rare x1 Epic x1 Legendary x1 Determination x1 ',
+};
+
+eventManager.on(':preload', () => {
+ each(icons, (text, type) => {
+ makeTip(`img[src="images/icons/${type}${!~type.indexOf('.') ? '.png' : ''}"]`, text);
+ });
+});
+
+function makeTip(selector, content) {
+ tippy(selector, {
+ content,
+ theme: 'undercards info',
+ animateFill: false,
+ a11y: false,
+ ignoreAttributes: true,
+ // placement: 'left',
+ });
+}
+style.add(
+ '.info-theme hr { margin: 5px 0; }',
+ '.info-theme hr + * {text-align: left;}',
+);
diff --git a/src/base/vanilla/pageSelect.js b/src/base/vanilla/pageSelect.js
new file mode 100644
index 00000000..0cacd899
--- /dev/null
+++ b/src/base/vanilla/pageSelect.js
@@ -0,0 +1,71 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global, globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+import sleep from 'src/utils/sleep.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const disable = settings.register({
+ name: Translation.Setting('page.select'),
+ key: 'underscript.disable.pageselect',
+});
+
+const select = document.createElement('select');
+select.value = 0;
+select.id = 'selectPage';
+select.onchange = () => {
+ changePage(select.value);
+ if (onPage('leaderboard')) {
+ eventManager.emit('Rankings:selectPage', select.value);
+ }
+};
+
+function init() {
+ const maxPage = global('getMaxPage')();
+ if (maxPage - 1 === select.length) return;
+ const local = $(select).empty();
+ for (let i = 0; i <= maxPage; i++) {
+ local.append(`${i + 1} `);
+ }
+ select.value = global('currentPage');
+}
+
+export default function changePage(page) {
+ select.value = page;
+ if (typeof page !== 'number') page = parseInt(page, 10);
+ globalSet('currentPage', page);
+ global('showPage')(page);
+ $('#btnNext').prop('disabled', page === global('getMaxPage')());
+ $('#btnPrevious').prop('disabled', page === 0);
+}
+
+eventManager.on(':preload', () => {
+ globalSet('showPage', function showPage(page) {
+ if (!eventManager.cancelable.emit('preShowPage', page).canceled) {
+ this.super(page);
+ }
+ eventManager.emit('ShowPage', page);
+ }, { throws: false });
+
+ if (disable.value() || !global('getMaxPage', { throws: false })) return;
+ // Add select dropdown
+ $('#currentPage').after(select).hide();
+
+ // Initialization
+ globalSet('applyFilters', function applyFilters(...args) {
+ this.super(...args);
+ sleep().then(init);
+ }, { throws: false });
+ globalSet('setupLeaderboard', function setupLeaderboard(...args) {
+ this.super(...args);
+ sleep().then(() => {
+ init();
+ eventManager.emit('Rankings:init');
+ });
+ }, { throws: false });
+
+ // Update
+ eventManager.on('ShowPage', (page) => {
+ select.value = page;
+ });
+});
diff --git a/src/base/vanilla/pageShortcuts.js b/src/base/vanilla/pageShortcuts.js
new file mode 100644
index 00000000..aebc2764
--- /dev/null
+++ b/src/base/vanilla/pageShortcuts.js
@@ -0,0 +1,46 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import { global, globalSet } from 'src/utils/global.js';
+import * as hover from 'src/utils/hover.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+const disable = settings.register({
+ name: Translation.Setting('page.jump'),
+ key: 'underscript.disable.quickpages',
+});
+
+function ignoring(e) {
+ const ignore = disable.value() || !e.ctrlKey;
+ if (ignore && [0, global('getMaxPage')()].includes(global('currentPage'))) hover.hide();
+ return ignore;
+}
+function firstPage(e) {
+ if (ignoring(e)) return;
+ e.preventDefault();
+ hover.hide();
+ setPage(0);
+}
+function lastPage(e) {
+ if (ignoring(e)) return;
+ e.preventDefault();
+ hover.hide();
+ const page = global('getMaxPage')();
+ setPage(page, page);
+}
+function setPage(page, max = global('getMaxPage')()) {
+ global('showPage')(page);
+ globalSet('currentPage', page);
+ $('#currentPage').text(page + 1);
+ $('#btnNext').prop('disabled', page === max);
+ $('#btnPrevious').prop('disabled', page === 0);
+}
+
+eventManager.on(':preload', () => {
+ if (!global('getMaxPage', { throws: false })) return;
+ const next = $('#btnNext').on('click.script', lastPage);
+ const prev = $('#btnPrevious').on('click.script', firstPage);
+ eventManager.on('underscript:ready', () => {
+ prev.hover(hover.show(`${Translation.General('page.first')}`));
+ next.hover(hover.show(`${Translation.General('page.last')}`));
+ });
+});
diff --git a/src/base/vanilla/patch.menu.js b/src/base/vanilla/patch.menu.js
new file mode 100644
index 00000000..c61a5e17
--- /dev/null
+++ b/src/base/vanilla/patch.menu.js
@@ -0,0 +1,10 @@
+import Translation from 'src/structures/constants/translation.ts';
+import { window } from 'src/utils/1.variables.js';
+import * as menu from 'src/utils/menu.js';
+
+menu.addButton({
+ text: Translation.Menu('gamelog'),
+ action() {
+ window.location = './gameUpdates.jsp';
+ },
+});
diff --git a/src/base/vanilla/quest.highlight.js b/src/base/vanilla/quest.highlight.js
new file mode 100644
index 00000000..677fe9b8
--- /dev/null
+++ b/src/base/vanilla/quest.highlight.js
@@ -0,0 +1,75 @@
+import eventManager from 'src/utils/eventManager.js';
+import * as settings from 'src/utils/settings/index.js';
+import wrap from 'src/utils/2.pokemon.js';
+import onPage from 'src/utils/onPage.js';
+import style from 'src/utils/style.js';
+import $el from 'src/utils/elementHelper.js';
+import { fetch } from 'src/utils/quests.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+wrap(() => {
+ const setting = settings.register({
+ name: Translation.Setting('quest.highlight'),
+ key: 'underscript.disable.questHighlight',
+ });
+
+ const clear = settings.register({
+ key: 'underscript.quest.clear',
+ hidden: true,
+ });
+
+ const skip = settings.register({
+ key: 'underscript.quest.skip',
+ hidden: true,
+ });
+
+ if (setting.value()) return;
+ const questSelector = 'input[type="submit"][value="Claim"]:not(:disabled)';
+
+ eventManager.on(':preload', () => $el.removeClass(document.querySelectorAll('.yellowLink[href="Quests"]'), 'yellowLink'));
+ style.add('a.highlightQuest {color: gold !important;}');
+
+ function highlightQuest() {
+ $('a[href="Quests"]').toggleClass('highlightQuest', clear.value());
+ }
+
+ function clearHighlight() {
+ clear.set(null);
+ }
+
+ function updateQuests(quests) {
+ const completed = quests.filter((q) => q.claimable);
+ if (completed.length) {
+ clear.set(true);
+ } else {
+ clearHighlight();
+ }
+ highlightQuest();
+ }
+
+ if (onPage('') && !clear.value() && !skip.value()) { // TODO: If logged in
+ fetch(({ quests }) => quests && updateQuests(quests), false);
+ }
+
+ eventManager.on('questProgress', updateQuests);
+
+ eventManager.on('logout', clearHighlight);
+
+ eventManager.on('jQuery', function questHighlight() {
+ const quests = $('a[href="Quests"]');
+ if (quests.length) {
+ if (quests.text().includes('(0)')) {
+ skip.set(true);
+ clearHighlight();
+ } else {
+ skip.set(null);
+ }
+ }
+
+ if (onPage('Quests') && !$(questSelector).length) {
+ clearHighlight();
+ }
+
+ highlightQuest();
+ });
+});
diff --git a/src/base/vanilla/reloadCards.js b/src/base/vanilla/reloadCards.js
new file mode 100644
index 00000000..2767a7d9
--- /dev/null
+++ b/src/base/vanilla/reloadCards.js
@@ -0,0 +1,16 @@
+import Translation from 'src/structures/constants/translation.ts';
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+import * as menu from 'src/utils/menu.js';
+
+eventManager.on(':preload', () => {
+ const fetchAllCards = global('fetchAllCards', { throws: false });
+ if (!fetchAllCards) return;
+ menu.addButton({
+ text: Translation.Menu('reload'),
+ action() {
+ localStorage.removeItem('cardsVersion');
+ fetchAllCards();
+ },
+ });
+});
diff --git a/src/base/vanilla/settings.js b/src/base/vanilla/settings.js
new file mode 100644
index 00000000..cf6fe65d
--- /dev/null
+++ b/src/base/vanilla/settings.js
@@ -0,0 +1,106 @@
+import * as settings from 'src/utils/settings/index.js';
+import onPage from 'src/utils/onPage.js';
+import Translation from 'src/structures/constants/translation.ts';
+
+[
+ {
+ name: Translation.Vanilla('settings-language'),
+ key: 'language',
+ options() {
+ return ['en', 'fr', 'ru', 'es', 'pt', 'cn', 'it', 'pl', 'de']
+ .map((locale) => [
+ Translation.Vanilla(`chat-${locale}`),
+ locale,
+ ]);
+ },
+ refresh: true,
+ remove: false,
+ },
+ {
+ name: Translation.Setting('vanilla.chat.rainbow'),
+ key: 'chatRainbowDisabled',
+ category: 'Chat',
+ },
+ {
+ name: Translation.Setting('vanilla.chat.sound'),
+ key: 'chatSoundsDisabled',
+ category: 'Chat',
+ },
+ {
+ name: Translation.Setting('vanilla.chat.avatar'),
+ key: 'chatAvatarsDisabled',
+ category: 'Chat',
+ },
+ {
+ name: Translation.Setting('vanilla.card.shiny'),
+ key: 'gameShinyDisabled',
+ category: 'Game',
+ },
+ {
+ name: Translation.Setting('vanilla.game.music'),
+ key: 'gameMusicDisabled',
+ category: 'Game',
+ },
+ {
+ name: Translation.Setting('vanilla.game.sound'),
+ key: 'gameSoundsDisabled',
+ category: 'Game',
+ },
+ {
+ name: Translation.Setting('vanilla.game.profile'),
+ key: 'profileSkinsDisabled',
+ category: 'Game',
+ },
+ {
+ name: Translation.Setting('vanilla.game.emote'),
+ key: 'gameEmotesDisabled',
+ category: 'Game',
+ },
+ {
+ name: Translation.Setting('vanilla.card.skin'),
+ key: 'breakingDisabled',
+ category: 'Game',
+ },
+ // show hand to friends.......
+ {
+ name: Translation.Setting('vanilla.game.shake'),
+ key: 'shakeDisabled',
+ category: 'Animation',
+ },
+ {
+ name: Translation.Setting('vanilla.game.stats'),
+ key: 'statsDisabled',
+ category: 'Animation',
+ },
+ {
+ name: Translation.Setting('vanilla.game.vfx'),
+ key: 'vfxDisabled',
+ category: 'Animation',
+ },
+ { key: 'deckBeginnerInfo' },
+ { key: 'firstVisit' },
+ { key: 'playDeck' },
+ // { key: 'cardsVersion' }, // no-export?
+ // { key: 'allCards' }, // no-export?
+ // { key: 'scrollY' },
+ // { key: 'browser' },
+ // { key: 'leaderboardPage' },
+ // { key: 'chat' },
+ // { key: 'language' },
+ // { key: '' },
+ // TODO: Add missing keys
+].forEach((setting) => {
+ const { name, category } = setting;
+ const refresh = category === 'Game' || category === 'Animation' ?
+ () => onPage('Game') || onPage('gameSpectating') :
+ undefined;
+ settings.register({
+ refresh,
+ remove: true,
+ ...setting,
+ page: 'game',
+ hidden: name === undefined,
+ });
+});
+
+settings.setDisplayName('Undercards', 'game');
diff --git a/src/base/vanilla/tippy.js b/src/base/vanilla/tippy.js
new file mode 100644
index 00000000..50ec9c6d
--- /dev/null
+++ b/src/base/vanilla/tippy.js
@@ -0,0 +1,18 @@
+import tippy from 'tippy.js';
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+
+// todo: Setting?
+eventManager.on(':load', () => {
+ [
+ global('tippy', { throws: false }),
+ tippy,
+ ].forEach((tip) => {
+ if (!tip) return;
+ const defaults = tip.setDefaultProps || tip.setDefaults;
+ defaults({
+ theme: 'undercards',
+ animateFill: false,
+ });
+ });
+});
diff --git a/src/bundle/.eslintrc.cjs b/src/bundle/.eslintrc.cjs
new file mode 100644
index 00000000..27942f64
--- /dev/null
+++ b/src/bundle/.eslintrc.cjs
@@ -0,0 +1,12 @@
+module.exports = {
+ env: {
+ es6: true,
+ browser: true,
+ jquery: false,
+ greasemonkey: false,
+ node: true,
+ },
+ parserOptions: {
+ sourceType: 'module',
+ },
+};
diff --git a/src/bundle/bundle.js b/src/bundle/bundle.js
new file mode 100644
index 00000000..71f2ff28
--- /dev/null
+++ b/src/bundle/bundle.js
@@ -0,0 +1,6 @@
+import 'axios';
+import 'luxon';
+import 'popper.js';
+import 'showdown';
+import 'tippy.js';
+import 'simpletoast';
diff --git a/src/checker.js b/src/checker.js
new file mode 100644
index 00000000..dd9ca1da
--- /dev/null
+++ b/src/checker.js
@@ -0,0 +1,36 @@
+// eslint-disable-next-line no-unused-vars
+function checkUnderscript(pluginName) {
+ const window = typeof unsafeWindow === 'object' ? unsafeWindow : globalThis;
+ if (window.underscript) return;
+
+ const key = 'underscript.required';
+ if (!sessionStorage.getItem(key)) {
+ sessionStorage.setItem(key, '1'); // Set instantly to prevent multiple alerts happening
+ const message = "Looks like you don't have UnderScript installed, or you deactivated it! In order for plugins to work, you need to have it up and running. Until then, the features of this userscript will simply not work. Thank you for your understanding.";
+
+ if (window.SimpleToast) {
+ SimpleToast({
+ title: 'Missing Requirements',
+ text: message,
+ footer: pluginName,
+ });
+ } else if (window.BootstrapDialog) {
+ BootstrapDialog.show({
+ title: 'Oh No!',
+ type: BootstrapDialog.TYPE_WARNING,
+ message,
+ buttons: [{
+ label: 'Proceed',
+ cssClass: 'btn-primary',
+ action(dialog) {
+ dialog.close();
+ },
+ }],
+ });
+ } else {
+ sessionStorage.removeItem(key);
+ }
+ }
+
+ throw new Error(`${pluginName}: UnderScript required`);
+}
diff --git a/src/checkerV2.js b/src/checkerV2.js
new file mode 100644
index 00000000..627eb0be
--- /dev/null
+++ b/src/checkerV2.js
@@ -0,0 +1,34 @@
+((pluginName, window) => {
+ if (window.underscript) return;
+
+ const key = 'underscript.required';
+ if (!sessionStorage.getItem(key)) {
+ sessionStorage.setItem(key, '1'); // Set instantly to prevent multiple alerts happening
+ const message = "Looks like you don't have UnderScript installed, or you deactivated it! In order for plugins to work, you need to have it up and running. Until then, the features of this userscript will simply not work. Thank you for your understanding.";
+
+ if (window.SimpleToast) {
+ SimpleToast({
+ title: 'Missing Requirements',
+ text: message,
+ footer: pluginName,
+ });
+ } else if (window.BootstrapDialog) {
+ BootstrapDialog.show({
+ title: 'Oh No!',
+ type: BootstrapDialog.TYPE_WARNING,
+ message,
+ buttons: [{
+ label: 'Proceed',
+ cssClass: 'btn-primary',
+ action(dialog) {
+ dialog.close();
+ },
+ }],
+ });
+ } else {
+ sessionStorage.removeItem(key);
+ }
+ }
+
+ throw new Error(`${pluginName}: UnderScript required`);
+})(this.GM_info?.script?.name ?? 'UNKNOWN', typeof unsafeWindow === 'object' ? unsafeWindow : globalThis);
diff --git a/src/hooks/BootstrapDialog.js b/src/hooks/BootstrapDialog.js
new file mode 100644
index 00000000..b18ac062
--- /dev/null
+++ b/src/hooks/BootstrapDialog.js
@@ -0,0 +1,47 @@
+import eventManager from 'src/utils/eventManager.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { window } from 'src/utils/1.variables.js';
+
+function setter(key, args) {
+ const original = args[`on${key}`];
+ function wrapper(dialog) {
+ let ret;
+ if (typeof original === 'function') {
+ ret = wrap(() => original(dialog), `BootstrapDialog:on${key}`);
+ }
+ eventManager.emit(`BootstrapDialog:${key}`, dialog);
+ return ret;
+ }
+ return wrapper;
+}
+
+function construct(Target, [args = {}]) {
+ const obj = new Target({
+ ...args,
+ onshow: setter('show', args),
+ onshown: setter('shown', args),
+ onhide: setter('hide', args),
+ onhidden: setter('hidden', args),
+ });
+ eventManager.emit('BootstrapDialog:create', obj);
+ return obj;
+}
+
+function get(target, prop, R) {
+ if (prop === 'show') {
+ return (o = {}) => {
+ const ret = new R(o);
+ if (eventManager.cancelable.emit('BootstrapDialog:preshow', ret).canceled) {
+ return ret;
+ }
+ return ret.open();
+ };
+ }
+ return Reflect.get(target, prop, R);
+}
+
+eventManager.on(':preload', () => {
+ if (window.BootstrapDialog) {
+ window.BootstrapDialog = new Proxy(window.BootstrapDialog, { construct, get });
+ }
+});
diff --git a/src/hooks/allCardsReady.js b/src/hooks/allCardsReady.js
new file mode 100644
index 00000000..05a24e26
--- /dev/null
+++ b/src/hooks/allCardsReady.js
@@ -0,0 +1,23 @@
+import { window } from 'src/utils/1.variables.js';
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+
+eventManager.on(':preload', () => {
+ function call(cards) {
+ eventManager.singleton.emit('allCardsReady', cards);
+ }
+ const allCards = global('allCards', {
+ throws: false,
+ });
+ if (!allCards) {
+ const cached = localStorage.getItem('allCards');
+ if (!cached) return;
+ const parsed = JSON.parse(cached);
+ window.allCards = parsed;
+ call(parsed);
+ } else if (!allCards.length) {
+ document.addEventListener('allCardsReady', () => call(global('allCards')));
+ } else {
+ call(allCards);
+ }
+});
diff --git a/src/hooks/analytics.js b/src/hooks/analytics.js
new file mode 100644
index 00000000..2e2bc517
--- /dev/null
+++ b/src/hooks/analytics.js
@@ -0,0 +1,52 @@
+/* eslint-disable camelcase */
+import * as settings from 'src/utils/settings/index.js';
+import { scriptVersion, window } from 'src/utils/1.variables.js';
+import eventManager from 'src/utils/eventManager.js';
+
+// This setting doesn't do anything, nor does the detection work.
+// TODO: translation
+settings.register({
+ name: 'Send anonymous statistics',
+ key: 'underscript.analytics',
+ default: () => window.GoogleAnalyticsObject !== undefined,
+ enabled: () => window.GoogleAnalyticsObject !== undefined,
+ hidden: true,
+ note: () => {
+ if (window.GoogleAnalyticsObject === undefined) {
+ return 'Analytics has been disabled by your adblocker.';
+ }
+ return undefined;
+ },
+});
+
+const config = {
+ app_name: 'underscript',
+ app_version: scriptVersion,
+ version: scriptVersion,
+ // eslint-disable-next-line camelcase -- This shouldn't be needed???
+ handler: GM_info.scriptHandler,
+ anonymize_ip: true, // I don't care about IP addresses, don't track this
+ custom_map: {
+ dimension1: 'version',
+ },
+};
+eventManager.on('login', (id) => {
+ // This gives me a truer user count, by joining all hits from the same user together
+ config.user_id = id;
+});
+window.dataLayer = window.dataLayer || [];
+gtag('js', new Date());
+gtag('config', 'G-32N9M5BWMR', config);
+
+function gtag() {
+ dataLayer.push(arguments); // eslint-disable-line no-undef, prefer-rest-params
+}
+
+export function send(...args) {
+ if (!args.length) return;
+ gtag('event', ...args);
+}
+
+export function error(description, fatal = false) {
+ send('exception', { description, fatal });
+}
diff --git a/src/hooks/chat.js b/src/hooks/chat.js
new file mode 100644
index 00000000..699139b4
--- /dev/null
+++ b/src/hooks/chat.js
@@ -0,0 +1,180 @@
+import { SOCKET_SCRIPT_CLOSED } from 'src/utils/1.variables.js';
+import eventManager from 'src/utils/eventManager.js';
+import { debug } from 'src/utils/debug.js';
+import { global, globalSet } from 'src/utils/global.js';
+import VarStore from 'src/utils/VarStore.js';
+import { isActive, updateIfActive } from './session.js';
+
+// TODO: Use Message object
+// TODO: Better history management
+let reconnectAttempts = 0;
+const guestMode = VarStore(false);
+const historyIds = new Set();
+
+function handleClose(event) {
+ console.debug('Disconnected', event);
+ if (![1000, 1006].includes(event.code)) return;
+ setTimeout(reconnect, 1000 * reconnectAttempts);
+}
+
+function bind(socketChat) {
+ const oHandler = socketChat.onmessage;
+ socketChat.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ const { action } = data;
+ debug(data, `debugging.rawchat.${action}`);
+
+ if (action === 'getMessage' && data.idRoom) { // For new chat.
+ data.room = `chat-public-${data.idRoom}`;
+ data.open = global('openPublicChats').includes(data.idRoom);
+ } else if (action === 'getPrivateMessage' && data.idFriend) {
+ data.idRoom = data.idFriend;
+ data.room = `chat-private-${data.idRoom}`;
+ data.open = Array.isArray(global('privateChats')[data.idRoom]);
+ }
+
+ eventManager.emit('preChatMessage', data);
+ if (eventManager.cancelable.emit(`preChat:${action}`, data).canceled) return;
+ oHandler(event);
+ eventManager.emit('ChatMessage', data);
+ eventManager.emit(`Chat:${action}`, data);
+
+ if (action === 'getSelfInfos') {
+ eventManager.singleton.emit('Chat:Connected');
+ getMessages(data);
+ }
+ };
+ const oClose = socketChat.onclose;
+ socketChat.onclose = (e) => {
+ if (e.code !== SOCKET_SCRIPT_CLOSED) oClose();
+ eventManager.emit('Chat:Disconnected');
+ handleClose(e);
+ };
+}
+
+export default function reconnect(force = false) {
+ if (!force && (!isActive() || guestMode.isSet() || reconnectAttempts > 3 || global('socketChat', { throws: false })?.readyState !== WebSocket.CLOSED)) return;
+ reconnectAttempts += 1;
+ const socket = new WebSocket(`wss://${location.hostname}/chat`);
+ globalSet('socketChat', socket);
+ socket.onmessage = (event) => {
+ if (global('socketChat') !== socket) return;
+ const data = JSON.parse(event.data);
+ const { action } = data;
+ switch (action) {
+ case 'getSelfInfos': { // We're only connected if we get self info
+ // Reconnected
+ socket.onmessage = global('onMessageChat');
+ socket.onclose = global('onCloseChat');
+ bind(socket);
+
+ // Process Messages
+ const history = getMessages(data);
+ const append = global('appendMessage');
+ history.forEach((message) => {
+ if ($(`#message-${message.id}`).length) return;
+ append(message, message.idRoom, false);
+ });
+
+ eventManager.emit('Chat:Reconnected');
+ reconnectAttempts = 0;
+ break;
+ }
+ default: {
+ console.debug('Message:', action);
+ // Need to stop connecting?
+ socket.close(SOCKET_SCRIPT_CLOSED, 'reconnect');
+ }
+ }
+ };
+ socket.onclose = handleClose;
+}
+
+function getMessages({ discussionHistory, otherHistory }) {
+ if (!discussionHistory || !otherHistory) {
+ return [];
+ }
+ const history = [
+ ...JSON.parse(discussionHistory),
+ ...JSON.parse(otherHistory),
+ ].filter(({ id }) => !historyIds.has(id));
+ history.forEach(({ id }) => historyIds.add(id));
+ return history;
+}
+
+function sendMessageWrapper(...args) {
+ if (global('socketChat').readyState !== WebSocket.OPEN) {
+ updateIfActive(); // TODO: Have a way to detect activity other than manually resetting it
+ reconnect();
+ eventManager.once('Chat:Reconnected', () => this.super(...args));
+ } else {
+ this.super(...args);
+ }
+}
+
+eventManager.on(':preload', () => {
+ if (typeof socketChat !== 'undefined') {
+ debug('Chat detected');
+ eventManager.singleton.emit('ChatDetected');
+
+ bind(global('socketChat'));
+
+ // Attempt to reconnect when sending messages
+ globalSet('sendMessage', sendMessageWrapper);
+ globalSet('sendPrivateMessage', sendMessageWrapper);
+ // Attempt to reconnect when coming back to this window
+ document.addEventListener('visibilitychange', () => reconnect());
+
+ // Simulate old getHistory
+ globalSet('appendChat', function appendChat(idRoom = '', chatMessages = [], isPrivate = true) {
+ const room = `chat-${isPrivate ? 'private' : 'public'}-${idRoom}`;
+ const newRoom = !document.querySelector(`#${room}`);
+ const data = {
+ idRoom,
+ room,
+ roomName: isPrivate ? '' : global('chatNames')[idRoom - 1] || '',
+ history: JSON.stringify(chatMessages), // TODO: Stop stringify
+ };
+ if (newRoom) {
+ eventManager.emit('preChat:getHistory', data);
+ }
+ this.super(idRoom, chatMessages, isPrivate);
+ if (newRoom) {
+ eventManager.emit('Chat:getHistory', data);
+ }
+ }, {
+ throws: false,
+ });
+ }
+
+ eventManager.on('Chat:getHistory', ({ room, roomName: name }) => {
+ // Send text hook
+ const messages = $(`#${room} .chat-messages`);
+ $(`#${room} input[type="text"]`).keydown(function sending(e) {
+ if (e.key !== 'Enter') return;
+
+ const data = {
+ room,
+ name,
+ messages,
+ input: this,
+ };
+ if (eventManager.cancelable.emit('Chat:send', data).canceled) {
+ debug('Canceled send');
+ $(this).val('');
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ });
+ });
+
+ eventManager.on('GuestMode', () => {
+ console.debug('Guest Mode');
+ guestMode.set(true);
+ });
+
+ eventManager.on('Chat:Reconnected', () => {
+ console.debug('Reconnected');
+ $('.chat-messages').find('.red:last').remove();
+ });
+});
diff --git a/src/hooks/chat.private.history.js b/src/hooks/chat.private.history.js
new file mode 100644
index 00000000..5475c509
--- /dev/null
+++ b/src/hooks/chat.private.history.js
@@ -0,0 +1,45 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+import each from 'src/utils/each.js';
+
+const prefix = 'underscript.history.';
+const history = {};
+
+eventManager.on(':preload', () => {
+ each(localStorage, (data, key = '') => {
+ if (!key.startsWith(prefix)) return;
+ const id = key.substring(prefix.length);
+ history[id] = JSON.parse(data);
+ });
+});
+
+eventManager.on('preChat:getPrivateMessage', function storeHistory(data) {
+ if (!this.canceled || data.open) return;
+
+ const userId = data.idRoom;
+
+ const list = history[userId] || [];
+ if (!list.length) history[userId] = list;
+ list.push(JSON.parse(data.chatMessage));
+
+ const overflow = list.length - 50;
+ if (overflow > 0) {
+ list.splice(0, overflow);
+ }
+
+ localStorage.setItem(`${prefix}${userId}`, JSON.stringify(list));
+});
+
+globalSet('openPrivateRoom', function openPrivateRoom(id, username) {
+ if (history[id]) {
+ global('privateChats')[id] = [...history[id]];
+ global('refreshChats')();
+ }
+ this.super(id, username);
+ if (history[id]) { // Done last incase of errors.
+ delete history[id];
+ localStorage.removeItem(`${prefix}${id}`);
+ }
+}, {
+ throws: false,
+});
diff --git a/src/hooks/command.js b/src/hooks/command.js
new file mode 100644
index 00000000..8117951d
--- /dev/null
+++ b/src/hooks/command.js
@@ -0,0 +1,14 @@
+import eventManager from 'src/utils/eventManager.js';
+
+eventManager.on('Chat:send', function chatCommand({ input, room }) {
+ const raw = input.value;
+ if (this.canceled || !raw.startsWith('/')) return;
+ const index = raw.includes(' ') ? raw.indexOf(' ') : undefined;
+ const command = raw.substring(1, index);
+ const text = index === undefined ? '' : raw.substring(index + 1);
+ const data = { room, input, command, text, output: undefined };
+ const event = eventManager.cancelable.emit('Chat:command', data);
+ this.canceled = event.canceled;
+ if (data.output === undefined) return;
+ input.value = data.output;
+});
diff --git a/src/hooks/craft.card.js b/src/hooks/craft.card.js
new file mode 100644
index 00000000..b691437a
--- /dev/null
+++ b/src/hooks/craft.card.js
@@ -0,0 +1,43 @@
+import { cardName } from 'src/utils/cardHelper';
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('Crafting', () => {
+ eventManager.on('jQuery', () => {
+ $(document).ajaxComplete((event, xhr, options) => {
+ if (options.url !== 'CraftConfig') return;
+ if (!options.data) {
+ eventManager.emit('Craft:Loaded');
+ return;
+ }
+ const data = JSON.parse(options.data);
+ const response = xhr.responseJSON;
+ const success = response.status === 'success';
+ if (data.action === 'craft') {
+ if (success) {
+ const card = response.card ? JSON.parse(response.card) : {};
+ const id = card.id || response.cardId;
+ eventManager.emit('craftcard', {
+ id,
+ name: cardName(card) || response.cardName,
+ dust: response.dust,
+ shiny: data.isShiny || response.shiny || false,
+ });
+ } else {
+ eventManager.emit('crafterrror', response.message, response.status);
+ }
+ } else {
+ eventManager.emit(`Craft:${data.action}`, success, response, data);
+ }
+ });
+ });
+
+ eventManager.on(':preload', () => {
+ globalSet('showPage', function showPage(...args) {
+ const prevPage = global('currentPage');
+ this.super(...args);
+ eventManager.emit('Craft:RefreshPage', global('currentPage'), prevPage);
+ });
+ });
+});
diff --git a/src/hooks/custom.js b/src/hooks/custom.js
new file mode 100644
index 00000000..c24b2f0e
--- /dev/null
+++ b/src/hooks/custom.js
@@ -0,0 +1,15 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+
+eventManager.on(':preload:GamesList', () => {
+ eventManager.singleton.emit('enterCustom');
+ const socket = global('socket');
+ const oHandler = socket.onmessage;
+ socket.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ const { action } = data;
+ if (eventManager.cancelable.emit(`preCustom:${action}`, data).canceled) return;
+ oHandler(event);
+ eventManager.emit(`Custom:${action}`, data);
+ };
+});
diff --git a/src/hooks/deck.js b/src/hooks/deck.js
new file mode 100644
index 00000000..77dffe77
--- /dev/null
+++ b/src/hooks/deck.js
@@ -0,0 +1,34 @@
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('Decks', function deckPage() {
+ eventManager.on('jQuery', () => {
+ $(document).ajaxSuccess((event, xhr, options) => {
+ if (options.url !== 'DecksConfig' || !options.data) return;
+ const { action } = JSON.parse(options.data);
+ const data = xhr.responseJSON;
+ const obj = Object.freeze({ action, data, options, xhr });
+ eventManager.emit('Deck:Change', obj);
+ eventManager.emit(`Deck:${action}`, obj);
+ });
+ $(document).ajaxComplete((event, xhr, options) => {
+ if (options.url !== 'DecksConfig') return;
+ const data = xhr.responseJSON;
+ if (options.type === 'GET') {
+ eventManager.singleton.emit('Deck:Loaded', data);
+ return;
+ }
+ const { action } = JSON.parse(options.data);
+ const obj = Object.freeze({ action, data, options, xhr });
+ eventManager.emit('Deck:postChange', obj);
+ eventManager.emit(`Deck:${action}`, obj);
+ });
+ // Soul change
+ globalSet('updateSoul', function updateSoul() {
+ this.super();
+ const val = $('#selectSouls').val();
+ eventManager.emit('Deck:Soul', val);
+ });
+ });
+});
diff --git a/src/hooks/friends.js b/src/hooks/friends.js
new file mode 100644
index 00000000..9219ee24
--- /dev/null
+++ b/src/hooks/friends.js
@@ -0,0 +1,55 @@
+import axios from 'axios';
+import eventManager from 'src/utils/eventManager.js';
+import decrypt from 'src/utils/decrypt.emails.js';
+import debug from 'src/utils/debugToast';
+import sleep from 'src/utils/sleep.js';
+import { window } from 'src/utils/1.variables.js';
+import length from 'src/utils/length';
+
+// Friends list hooks. TODO: only work if logged in
+function getFromEl(el) {
+ const link = el.find('a:first').attr('href');
+ const id = link.substring(link.indexOf('=') + 1);
+ const name = el.contents()[0].nodeValue.trim();
+ return { id, name };
+}
+
+let validated = 0;
+
+function loadFriends(validate) {
+ if (typeof window.jQuery === 'undefined') return undefined;
+ return axios.get('/Friends').then((response) => {
+ const data = decrypt($(response.data));
+ if (data.find(`span[data-i18n="[html]error-not-allowed"]`).length) {
+ eventManager.singleton.emit(':GuestMode');
+ return true;
+ }
+ const requests = {};
+ // const pending = {};
+ data.find('p:contains(Friend requests)').parent().children('li').each(function fR() {
+ // console.log(this);
+ const f = getFromEl($(this));
+ requests[f.id] = f.name;
+ });
+ const count = length(requests);
+ if (count !== validated && count > 3 && !validate) {
+ return loadFriends(count);
+ }
+ if (validate) {
+ validated = count;
+ if (validate !== count) debug(`Friends: Validation failed (found ${validate}, got ${count})`);
+ }
+
+ // eventManager.emit('Friends:pending', pending);
+ eventManager.emit('preFriends:requests', requests);
+ eventManager.emit('Friends:requests', requests);
+ return false;
+ }).catch((e) => {
+ debug(`Friends: ${e.message}`);
+ }).then((error) => {
+ if (error) return;
+ sleep(10000).then(loadFriends);
+ });
+}
+
+sleep().then(loadFriends);
diff --git a/src/hooks/friendship.js b/src/hooks/friendship.js
new file mode 100644
index 00000000..8f4ff9be
--- /dev/null
+++ b/src/hooks/friendship.js
@@ -0,0 +1,40 @@
+import Item from 'src/structures/constants/item.js';
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+
+eventManager.on(':preload:Friendship', () => {
+ eventManager.singleton.emit('Friendship:load');
+ $(document).ajaxComplete((event, xhr, settings) => {
+ if (settings.url !== 'FriendshipConfig') return;
+ if (settings.type === 'GET') {
+ eventManager.singleton.emit('Friendship:loaded');
+ } else if (xhr.responseJSON) {
+ const data = xhr.responseJSON;
+ if (data.status === 'success') {
+ const {
+ idCard,
+ reward, // GOLD, UCP, PACK, DR_PACK
+ quantity,
+ claim,
+ } = data;
+
+ eventManager.emit('Friendship:claim', {
+ data: global('friendshipItems')[idCard],
+ reward: Item.find(reward) || reward,
+ quantity,
+ claim,
+ });
+ } else if (data.status === 'errorMaintenance') {
+ eventManager.emit('Friendship:claim', {
+ error: JSON.parse(data.message),
+ });
+ }
+ } else {
+ eventManager.emit('Friendship:claim', {
+ error: true,
+ });
+ }
+ });
+
+ eventManager.on('ShowPage', (page) => eventManager.emit('Friendship:page', page));
+});
diff --git a/src/hooks/game.event.js b/src/hooks/game.event.js
new file mode 100644
index 00000000..5affac30
--- /dev/null
+++ b/src/hooks/game.event.js
@@ -0,0 +1,36 @@
+import eventManager from 'src/utils/eventManager.js';
+import { debug } from 'src/utils/debug.js';
+import debugToast from 'src/utils/debugToast';
+
+eventManager.on('GameStart', function gameEvents() {
+ let finished = false;
+ eventManager.on('GameEvent', function logEvent(data) {
+ if (finished) { // Sometimes we get events after the battle is over
+ debugToast(`Extra action: ${data.action}`, 'debugging.events.extra');
+ return;
+ }
+ debug(data.action, 'debugging.events.name');
+ debug(data, 'debugging.events.raw');
+ const emitted = eventManager.emit(data.action, data).ran;
+ if (!emitted) {
+ debugToast(`Unknown action: ${data.action}`);
+ }
+ });
+ eventManager.on('PreGameEvent', function callPreEvent(data) {
+ if (finished) return;
+ const emit = this.cancelable ? eventManager.cancelable.emit : eventManager.emit;
+ const event = emit(`${data.action}:before`, data);
+ if (!event.ran) return;
+ this.canceled = event.canceled;
+ });
+ eventManager.on('getVictory getDefeat getResult', function finish() {
+ finished = true;
+ });
+ eventManager.on('getBattleLog', (data) => {
+ const log = JSON.parse(data.battleLog);
+ const { ran } = eventManager.emit(`Log:${log.battleLogType}`, log);
+ if (!ran) {
+ debugToast(`Unknown action: Log:${log.battleLogType}`);
+ }
+ });
+});
diff --git a/src/hooks/game.js b/src/hooks/game.js
new file mode 100644
index 00000000..c310bad4
--- /dev/null
+++ b/src/hooks/game.js
@@ -0,0 +1,34 @@
+import eventManager from 'src/utils/eventManager.js';
+import onPage from 'src/utils/onPage.js';
+import { debug } from 'src/utils/debug.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { globalSet } from 'src/utils/global.js';
+import { window } from 'src/utils/1.variables.js';
+
+function gameHook() {
+ debug('Playing Game');
+ eventManager.singleton.emit('GameStart');
+ eventManager.singleton.emit('PlayingGame');
+ eventManager.on(':preload', () => {
+ function callGameHooks(data, original) {
+ const run = !eventManager.cancelable.emit('PreGameEvent', data).canceled;
+ if (run) {
+ wrap(() => original(data));
+ }
+ eventManager.emit('GameEvent', data);
+ }
+
+ function hookEvent(event) {
+ callGameHooks(event, this.super);
+ }
+
+ if (undefined !== window.bypassQueueEvents) {
+ globalSet('runEvent', hookEvent);
+ globalSet('bypassQueueEvent', hookEvent);
+ } else {
+ debug('Update your code yo');
+ }
+ });
+}
+
+onPage('Game', gameHook);
diff --git a/src/hooks/hotkeys.hotkey.js b/src/hooks/hotkeys.hotkey.js
new file mode 100644
index 00000000..da8c6dcf
--- /dev/null
+++ b/src/hooks/hotkeys.hotkey.js
@@ -0,0 +1,25 @@
+import eventManager from 'src/utils/eventManager.js';
+import { hotkeys } from 'src/utils/1.variables.js';
+
+function handle(event) {
+ const click = event instanceof MouseEvent;
+ [...hotkeys].forEach((v) => {
+ const key = click ? event.button : event.key;
+ if (click ? v.clickbound(key) : v.keybound(key)) {
+ v.run(event);
+ }
+ });
+}
+
+eventManager.on(':preload', function always() {
+ // Bind hotkey listeners
+ document.addEventListener('mouseup', (event) => {
+ // if (false) return; // TODO: Check for clicking in chat
+ handle(event, true);
+ });
+ document.addEventListener('keyup', (event) => {
+ // TODO: possibly doesn't work in firefox
+ if (event.target.tagName === 'INPUT') return; // We don't want to listen while typing in chat (maybe listen for F-keys?)
+ handle(event);
+ });
+});
diff --git a/src/hooks/leaderboard.js b/src/hooks/leaderboard.js
new file mode 100644
index 00000000..d0cd9fc3
--- /dev/null
+++ b/src/hooks/leaderboard.js
@@ -0,0 +1,8 @@
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+
+eventManager.on(':preload:leaderboard', () => {
+ globalSet('pageName', location.pathname.substr(1));
+ globalSet('action', 'ranked');
+ // TODO: Add other leaderboard hooks here?
+});
diff --git a/src/hooks/logout.js b/src/hooks/logout.js
new file mode 100644
index 00000000..295347a7
--- /dev/null
+++ b/src/hooks/logout.js
@@ -0,0 +1,6 @@
+import eventManager from 'src/utils/eventManager.js';
+import onPage from 'src/utils/onPage.js';
+
+onPage('Disconnect', function logout() {
+ eventManager.singleton.emit('logout');
+});
diff --git a/src/hooks/play.js b/src/hooks/play.js
new file mode 100644
index 00000000..10da7f8f
--- /dev/null
+++ b/src/hooks/play.js
@@ -0,0 +1,34 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { window } from 'src/utils/1.variables.js';
+
+eventManager.on(':preload:Play', function hook() {
+ if (undefined !== window.bypassQueueEvents) {
+ location.href = '/Game';
+ return;
+ }
+ function opened(socket) {
+ eventManager.emit('socketOpen', socket);
+ }
+ globalSet('onOpen', function onOpen(event) {
+ this.super(event);
+ opened(global('socketQueue'));
+ });
+ globalSet('onMessage', function onMessage(event) {
+ const data = JSON.parse(event.data);
+ eventManager.emit(`pre:${data.action}`, data);
+ wrap(() => this.super(event));
+ eventManager.emit('Play:Message', data);
+ eventManager.emit(data.action, data);
+ });
+
+ const socketQueue = global('socketQueue', { throws: false });
+ if (socketQueue) {
+ if (socketQueue.readyState === WebSocket.OPEN) {
+ opened(socketQueue);
+ }
+ socketQueue.onopen = global('onOpen');
+ socketQueue.onmessage = global('onMessage');
+ }
+});
diff --git a/src/hooks/ready.js b/src/hooks/ready.js
new file mode 100644
index 00000000..2a44fded
--- /dev/null
+++ b/src/hooks/ready.js
@@ -0,0 +1,8 @@
+import compound from 'src/utils/compoundEvent';
+import eventManager from 'src/utils/eventManager';
+
+// Call "ready" when all critical assets are loaded
+compound(
+ 'translation:underscript',
+ () => eventManager.singleton.emit('underscript:ready'),
+);
diff --git a/src/hooks/sentry.js b/src/hooks/sentry.js
new file mode 100644
index 00000000..6b72d350
--- /dev/null
+++ b/src/hooks/sentry.js
@@ -0,0 +1,11 @@
+import eventManager from 'src/utils/eventManager.js';
+import { init, login, logout } from 'src/utils/sentry.js';
+
+if (typeof Sentry !== 'undefined') {
+ init();
+
+ eventManager.on('login', login);
+
+ eventManager.on('logout', logout);
+ // eventManager.on(':GuestMode', logout);
+}
diff --git a/src/hooks/session.js b/src/hooks/session.js
new file mode 100644
index 00000000..42a4f748
--- /dev/null
+++ b/src/hooks/session.js
@@ -0,0 +1,41 @@
+import { window } from 'src/utils/1.variables.js';
+import eventManager from 'src/utils/eventManager.js';
+import { global } from 'src/utils/global.js';
+
+export const sessionId = window.crypto?.randomUUID() || Math.random().toString();
+
+function login(id, username) {
+ eventManager.singleton.emit('login', id, username);
+}
+
+if (sessionStorage.getItem('UserID')) {
+ login(sessionStorage.getItem('UserID'), sessionStorage.getItem('Username') ?? undefined);
+}
+
+eventManager.on('Chat:Connected', () => {
+ const sessID = sessionStorage.getItem('UserID');
+ const selfId = global('selfId');
+ const username = global('selfUsername');
+ if (sessID && sessID === selfId) return;
+ login(selfId, username);
+ sessionStorage.setItem('UserID', selfId);
+ sessionStorage.setItem('Username', username);
+});
+eventManager.on('logout', () => {
+ sessionStorage.removeItem('UserID');
+ sessionStorage.removeItem('Username');
+});
+
+export function isActive() {
+ return localStorage.getItem('underscript.session') === sessionId;
+}
+
+export function updateIfActive() {
+ if (document.hidden || isActive()) return;
+ localStorage.setItem('underscript.session', sessionId);
+ eventManager.emit('window:active');
+}
+
+document.addEventListener('visibilitychange', updateIfActive);
+
+updateIfActive();
diff --git a/src/hooks/spectate.js b/src/hooks/spectate.js
new file mode 100644
index 00000000..59d18bea
--- /dev/null
+++ b/src/hooks/spectate.js
@@ -0,0 +1,31 @@
+import eventManager from 'src/utils/eventManager.js';
+import { globalSet } from 'src/utils/global.js';
+import { debug } from 'src/utils/debug.js';
+import onPage from 'src/utils/onPage.js';
+import wrap from 'src/utils/2.pokemon.js';
+import { window } from 'src/utils/1.variables.js';
+
+onPage('Spectate', () => {
+ eventManager.singleton.emit('GameStart');
+
+ eventManager.on(':preload', () => {
+ function callGameHooks(data, original) {
+ const run = !eventManager.cancelable.emit('PreGameEvent', data).canceled;
+ if (run) {
+ wrap(() => original(data));
+ }
+ eventManager.emit('GameEvent', data);
+ }
+
+ function hookEvent(event) {
+ callGameHooks(event, this.super);
+ }
+
+ if (undefined !== window.bypassQueueEvents) {
+ globalSet('runEvent', hookEvent);
+ globalSet('bypassQueueEvent', hookEvent);
+ } else {
+ debug(`You're a fool.`);
+ }
+ });
+});
diff --git a/src/hooks/translation.loaded.js b/src/hooks/translation.loaded.js
new file mode 100644
index 00000000..71882f27
--- /dev/null
+++ b/src/hooks/translation.loaded.js
@@ -0,0 +1,26 @@
+import eventManager from 'src/utils/eventManager.js';
+import { global, globalSet } from 'src/utils/global.js';
+
+const READY = 'translationReady';
+let fallback;
+
+eventManager.on(':preload', () => {
+ if (global(READY, { throws: false })) {
+ eventManager.singleton.emit('translation:loaded');
+ } else {
+ document.addEventListener(READY, () => {
+ eventManager.singleton.emit('translation:loaded', fallback);
+ }, {
+ once: true,
+ });
+ }
+});
+
+// Fallback for if translation function breaks
+eventManager.on(':load', () => {
+ const translationReady = global(READY, { throws: false });
+ if (translationReady !== false || !$?.i18n.messageStore.messages.en) return;
+ fallback = true;
+ globalSet(READY, true);
+ document.dispatchEvent(global('translationEvent'));
+});
diff --git a/src/hooks/unload.js b/src/hooks/unload.js
new file mode 100644
index 00000000..feb6a81b
--- /dev/null
+++ b/src/hooks/unload.js
@@ -0,0 +1,14 @@
+import { SOCKET_SCRIPT_CLOSED, window } from 'src/utils/1.variables.js';
+import eventManager from 'src/utils/eventManager.js';
+import { getPageName } from 'src/utils/onPage.js';
+
+eventManager.on(':preload', () => {
+ function unload() {
+ eventManager.emit(':unload');
+ eventManager.emit(`:unload:${getPageName()}`);
+
+ const chat = window.socketChat;
+ if (chat && chat.readyState <= WebSocket.OPEN) chat.close(SOCKET_SCRIPT_CLOSED, 'unload');
+ }
+ window.onbeforeunload = unload;
+});
diff --git a/src/hooks/updates.css b/src/hooks/updates.css
new file mode 100644
index 00000000..ebc42959
--- /dev/null
+++ b/src/hooks/updates.css
@@ -0,0 +1,26 @@
+.updates {
+ --silent: dimgray;
+}
+
+.updates fieldset.silent {
+ border-color: var(--silent);
+}
+
+.updates fieldset {
+ text-align: center;
+}
+
+.updates .modal-body .btn {
+ width: 100%;
+}
+
+.updates .silent legend {
+ color: var(--silent);
+}
+
+.updates .btn-skip {
+ background-color: var(--silent);
+ color: white;
+ display: block;
+ margin-top: 5px;
+}
\ No newline at end of file
diff --git a/src/hooks/updates.js b/src/hooks/updates.js
new file mode 100644
index 00000000..65f002e5
--- /dev/null
+++ b/src/hooks/updates.js
@@ -0,0 +1,346 @@
+// Detect pending updates, display message, open window... allow updating.
+import luxon from 'luxon';
+import eventManager from 'src/utils/eventManager';
+import { toast as Toast } from 'src/utils/2.toasts';
+import * as menu from 'src/utils/menu';
+import * as settings from 'src/utils/settings';
+import each from 'src/utils/each';
+import wrap from 'src/utils/2.pokemon';
+import Translation from 'src/structures/constants/translation.ts';
+import { buttonCSS, DAY, HOUR, scriptVersion, UNDERSCRIPT } from 'src/utils/1.variables';
+import sleep from 'src/utils/sleep';
+import createParser from 'src/utils/parser';
+import DialogHelper from 'src/utils/DialogHelper';
+import compound from 'src/utils/compoundEvent';
+import { getTranslationArray } from 'src/base/underscript/translation';
+import style from 'src/utils/style';
+import css from './updates.css';
+
+const CHECKING = 'underscript.update.checking'; // TODO: change to setting?
+const LAST = 'underscript.update.last'; // TODO: change to setting?
+const PREFIX = 'underscript.pending.';
+
+let autoTimeout;
+let toast;
+
+style.add(css);
+
+export const disabled = settings.register({
+ name: Translation.Setting('update'),
+ key: 'underscript.disable.updates',
+ category: Translation.CATEGORY_UPDATES,
+});
+
+export const silent = settings.register({
+ name: Translation.Setting('update.silent'),
+ key: 'underscript.updates.silent',
+ category: Translation.CATEGORY_UPDATES,
+});
+
+const keys = {
+ frequency: Translation.Setting('update.frequency.option').key,
+ toast: Translation.Toast('updater'),
+ button: Translation.OPEN,
+ checking: Translation.Toast('update.checking'),
+ skip: Translation.General('update.skip'),
+ updateNote: Translation.Menu('update.note', 1),
+ available: Translation.Toast('update.available', 1),
+ title: Translation.General('updates'),
+ updateCurrent: Translation.General('update.current', 1),
+ updateNew: Translation.General('update.new', 1),
+};
+
+const frequency = settings.register({
+ name: Translation.Setting('update.frequency'),
+ key: 'underscript.updates.frequency',
+ data: () => {
+ const tls = getTranslationArray(keys.frequency);
+ return [0, HOUR, DAY].map((val, i) => (tls ? [
+ tls[i],
+ val,
+ ] : val));
+ },
+ default: HOUR,
+ type: 'select',
+ category: Translation.CATEGORY_UPDATES,
+ transform(value) {
+ return Number(value) || 0;
+ },
+});
+
+const dialog = new DialogHelper();
+const pendingUpdates = new Map();
+
+export function register({
+ plugin,
+ version,
+ ...rest
+}) {
+ const key = plugin.name || plugin;
+ const data = { version, ...rest };
+ localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(data));
+ pendingUpdates.set(key, data);
+}
+
+// TODO: keep registry of update URL's being used? only allow one of any url
+export function registerPlugin(plugin, data = {}) {
+ validate(plugin);
+ /** @type {FileParser} */
+ const parser = createParser(data);
+ const { version } = plugin;
+ let finished = false;
+ plugin.events.until(':update', async () => {
+ if (finished || !plugin.canUpdate) return finished;
+ const info = await parser.getUpdateData();
+ const newVersion = await parser.getVersion(info);
+ if (finished || !plugin.canUpdate) return finished;
+ if (newVersion !== version) {
+ const cached = pendingUpdates.get(plugin.name);
+ if (cached?.newVersion === newVersion) {
+ return false;
+ }
+ register({
+ plugin,
+ newVersion,
+ version,
+ url: await parser.getDownload(info),
+ });
+ } else {
+ unregister(plugin);
+ }
+ return false;
+ });
+ return () => {
+ finished = true;
+ };
+}
+
+export function validate(plugin) {
+ const key = plugin.name || plugin;
+ const existing = pendingUpdates.get(key);
+ if (existing) {
+ const version = key === UNDERSCRIPT ? scriptVersion : plugin.version;
+ const isValid = existing.version === undefined || existing.version === version;
+ const isNotUpdated = existing.newVersion !== version;
+ if (isNotUpdated && isValid) {
+ return existing;
+ }
+ unregister(plugin);
+ }
+ return false;
+}
+
+export function unregister(plugin) {
+ const key = plugin.name || plugin;
+ localStorage.removeItem(`${PREFIX}${key}`);
+ return pendingUpdates.delete(key);
+}
+
+function notify(text, addButton = false) {
+ if (toast?.exists()) {
+ toast.setText(`${text}`);
+ } else {
+ toast = Toast({
+ title: keys.toast,
+ text,
+ className: 'dismissable',
+ buttons: addButton ? {
+ text: keys.button,
+ className: 'dismiss',
+ css: buttonCSS,
+ onclick: open,
+ } : undefined,
+ });
+ }
+}
+
+async function check(auto = true) {
+ if (sessionStorage.getItem(CHECKING)) return;
+ sessionStorage.setItem(CHECKING, true);
+
+ if (autoTimeout) {
+ clearTimeout(autoTimeout);
+ autoTimeout = 0;
+ }
+
+ const previousState = !pendingUpdates.size;
+
+ if (!auto || !disabled.value()) {
+ await eventManager.async.emit(':update', auto);
+ }
+
+ if (!pendingUpdates.size !== previousState) {
+ menu.dirty();
+ }
+
+ function finish() {
+ toast?.close();
+ eventManager.emit(':update:finished', auto);
+ }
+
+ const updateFound = [...pendingUpdates.values()].filter(({ announce = true }) => announce).length;
+ if (updateFound) {
+ finish();
+ notify(keys.available.withArgs(updateFound), true);
+ } else {
+ notify(keys.available.withArgs(0));
+ const delay = !auto ? 3000 : 1000;
+ sleep(delay).then(finish);
+ }
+
+ // Setup next check, defaulting to an hour if "Page Load" is set
+ const timeout = frequency.value() || HOUR;
+ autoTimeout = setTimeout(check, timeout);
+
+ localStorage.setItem(LAST, Date.now());
+ sessionStorage.removeItem(CHECKING);
+}
+
+function setup() {
+ const last = Number(localStorage.getItem(LAST));
+ const now = Date.now();
+ const timeout = last - now + frequency.value();
+
+ if (!last || timeout <= 0) {
+ check();
+ } else {
+ autoTimeout = setTimeout(check, timeout);
+ }
+
+ const updateFound = [...pendingUpdates.values()].filter(({ announce = true }) => announce).length;
+ if (updateFound) {
+ notify(keys.available.translate(updateFound), true);
+ }
+}
+
+function open() {
+ dialog.open({
+ title: `${keys.title}`,
+ cssClass: 'underscript-dialog updates',
+ message: build,
+ });
+}
+
+menu.addButton({
+ text: Translation.Menu('update'),
+ action() {
+ check(false);
+ },
+ note() {
+ const last = Number(localStorage.getItem(LAST));
+ const when = last ? luxon.DateTime.fromMillis(last).toLocaleString(luxon.DateTime.DATETIME_FULL) : 'never';
+ return keys.updateNote.translate(when);
+ },
+});
+
+menu.addButton({
+ text: Translation.Menu('update.pending'),
+ action: open,
+ // note() {},
+ hidden() {
+ return !pendingUpdates.size;
+ },
+});
+
+eventManager.on(':update', (auto) => {
+ toast?.close();
+ if (auto && silent.value()) return;
+ notify(keys.checking);
+});
+
+// Load pending updates
+each(localStorage, (data, key) => wrap(() => {
+ if (!key.startsWith(PREFIX)) return;
+ if (disabled.value()) {
+ localStorage.removeItem(key);
+ return;
+ }
+ const name = key.substring(key.lastIndexOf('.') + 1);
+ pendingUpdates.set(name, JSON.parse(data));
+}, key));
+
+sessionStorage.removeItem(CHECKING);
+compound('underscript:ready', ':load', setup);
+
+function build() {
+ let addedRefresh = false;
+ const container = $('');
+ function refreshButton() {
+ if (addedRefresh) return;
+ dialog.prependButton({
+ label: `${Translation.General('refresh')}`,
+ cssClass: 'btn-success',
+ action() {
+ location.reload();
+ },
+ });
+ addedRefresh = true;
+ }
+ function add(data) {
+ const {
+ announce = true,
+ name,
+ newVersion,
+ url,
+ version,
+ } = data;
+ const isPlugin = name !== UNDERSCRIPT;
+ const skip = isPlugin && !announce;
+ const wrapper = $('
')
+ .toggleClass('silent', skip);
+ const button = $('')
+ .text(keys.updateNew.translate(newVersion))
+ .attr({
+ href: url,
+ rel: 'noreferrer',
+ target: 'updateUserScript',
+ })
+ .addClass('btn')
+ .toggleClass('btn-success', !skip)
+ .toggleClass('btn-skip', skip)
+ .on('click auxclick', () => {
+ refreshButton();
+ button
+ .removeClass()
+ .addClass('btn btn-primary');
+ })
+ .prepend(
+ $(' '),
+ ' ',
+ );
+ const silence = $('')
+ .text(keys.skip)
+ .addClass('btn btn-skip')
+ .on('click', () => {
+ register({
+ ...data,
+ plugin: name,
+ announce: false,
+ });
+ wrapper.remove();
+ });
+ container.append(wrapper.append(
+ $('').text(name),
+ $('').text(keys.updateCurrent.translate(version || Translation.UNKNOWN)),
+ button,
+ announce && isPlugin && silence,
+ ));
+ }
+ const underscript = pendingUpdates.get(UNDERSCRIPT);
+ if (underscript) {
+ add({
+ ...underscript,
+ name: UNDERSCRIPT,
+ });
+ }
+ [...pendingUpdates.entries()].forEach(
+ ([name, data]) => {
+ if (name === UNDERSCRIPT) return;
+ add({
+ ...data,
+ name,
+ });
+ },
+ );
+ return container.children();
+}
diff --git a/src/hooks/z-jQuery.js b/src/hooks/z-jQuery.js
new file mode 100644
index 00000000..407d5b5b
--- /dev/null
+++ b/src/hooks/z-jQuery.js
@@ -0,0 +1,7 @@
+import eventManager from 'src/utils/eventManager.js';
+
+// Attempt to detect jQuery
+eventManager.on(':preload', () => {
+ if (typeof jQuery === 'undefined') return;
+ eventManager.singleton.emit('jQuery', jQuery);
+});
diff --git a/src/hooks/zz.loaded.js b/src/hooks/zz.loaded.js
new file mode 100644
index 00000000..b4202eed
--- /dev/null
+++ b/src/hooks/zz.loaded.js
@@ -0,0 +1,39 @@
+import eventManager from 'src/utils/eventManager.js';
+import { scriptVersion, window } from 'src/utils/1.variables.js';
+import { getPageName } from 'src/utils/onPage.js';
+import sleep from 'src/utils/sleep.js';
+
+const page = getPageName();
+
+function loaded() {
+ if (eventManager.singleton.emit(':loaded').ran) {
+ console.warn('`:loaded` event is depricated, please migrate to `:preload`.');
+ }
+ eventManager.singleton.emit(':preload');
+ if (eventManager.singleton.emit(`:loaded:${page}`).ran) {
+ console.warn(`\`:loaded:${page}\` event is depricated, please migrate to \`:preload:${page}\``);
+ }
+ eventManager.singleton.emit(`:preload:${page}`);
+}
+function done() {
+ eventManager.singleton.emit(':load');
+ eventManager.singleton.emit(`:load:${page}`);
+}
+
+if (location.host.includes('undercards.net')) {
+ console.log(`UnderScript(v${scriptVersion}): Loaded`); // eslint-disable-line no-console
+ if (document.title.includes('Undercards')) {
+ register();
+ }
+}
+function register() {
+ document.addEventListener('DOMContentLoaded', loaded);
+ window.addEventListener('load', () => sleep().then(done));
+ const COMPLETE = document.readyState === 'complete';
+ if (document.readyState === 'interactive' || COMPLETE) {
+ loaded();
+ }
+ if (COMPLETE) {
+ done();
+ }
+}
diff --git a/src/meta.js b/src/meta.js
new file mode 100644
index 00000000..93deb3a9
--- /dev/null
+++ b/src/meta.js
@@ -0,0 +1,25 @@
+// ==UserScript==
+// @name UnderCards script
+// @description Various changes to undercards game
+// @version {{ version }}
+// @author feildmaster
+// @run-at document-body
+// @match https://*.undercards.net/*
+// @match https://feildmaster.github.io/UnderScript/*
+// @exclude https://*.undercards.net/*/*
+// @require https://browser.sentry-cdn.com/7.73.0/bundle.tracing.replay.min.js
+// @require https://unpkg.com/showdown@2.0.0/dist/showdown.min.js
+// @require https://unpkg.com/popper.js@1.16.1/dist/umd/popper.min.js
+// @require https://unpkg.com/tippy.js@4.3.5/umd/index.all.min.js
+// @require https://unpkg.com/axios@0.21.4/dist/axios.min.js
+// @require https://unpkg.com/luxon@1.28.0/build/global/luxon.min.js
+// @require https://raw.githubusercontent.com/feildmaster/SimpleToast/2.0.0/simpletoast.js
+// @homepage https://feildmaster.github.io/UnderScript/
+// @source https://github.com/UCProjects/UnderScript
+// @supportURL https://github.com/UCProjects/UnderScript/issues
+// @updateURL https://github.com/UCProjects/UnderScript/releases/latest/download/undercards.meta.js
+// @downloadURL https://github.com/UCProjects/UnderScript/releases/latest/download/undercards.user.js
+// @namespace https://feildmaster.com/
+// @icon https://www.google.com/s2/favicons?sz=64&domain=undercards.net
+// @grant none
+// ==/UserScript==
diff --git a/src/structures/base.js b/src/structures/base.js
new file mode 100644
index 00000000..6a23190c
--- /dev/null
+++ b/src/structures/base.js
@@ -0,0 +1,19 @@
+export default class Base {
+ #id;
+
+ constructor(data) {
+ this.#id = data.id;
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ update(data) {}
+
+ toJSON() {
+ return {
+ id: this.id,
+ };
+ }
+}
diff --git a/src/structures/card/shopSkin.js b/src/structures/card/shopSkin.js
new file mode 100644
index 00000000..3c8cae07
--- /dev/null
+++ b/src/structures/card/shopSkin.js
@@ -0,0 +1,49 @@
+import isTruthish from 'src/utils/isTrue.js';
+import Skin from './skin.js';
+
+export default class ShopSkin extends Skin {
+ #active = false;
+ #cost = 0;
+ #discount = 0;
+ #owned = false;
+ #unavailable = false;
+
+ constructor({
+ // Vanilla
+ ucpCost = 0,
+ // Ours
+ active,
+ discount,
+ owned,
+ unavailable,
+ cost = ucpCost,
+ ...data
+ }) {
+ super(data);
+ this.#active = isTruthish(active);
+ this.#cost = Number(cost);
+ this.#discount = Number(discount);
+ this.#owned = isTruthish(owned);
+ this.#unavailable = isTruthish(unavailable);
+ }
+
+ get active() {
+ return this.#active;
+ }
+
+ get cost() {
+ return this.#cost;
+ }
+
+ get discount() {
+ return this.#discount;
+ }
+
+ get owned() {
+ return this.#owned;
+ }
+
+ get unavailable() {
+ return this.#unavailable;
+ }
+}
diff --git a/src/structures/card/skin.js b/src/structures/card/skin.js
new file mode 100644
index 00000000..1cc69678
--- /dev/null
+++ b/src/structures/card/skin.js
@@ -0,0 +1,80 @@
+import Base from '../base.js';
+
+export default class Skin extends Base {
+ #author;
+ #card;
+ #image;
+ #name;
+ #type;
+
+ constructor({
+ // Vanilla
+ cardId = 0,
+ skinAuthor = '',
+ skinImage = '',
+ skinName = '',
+ skinType = 0,
+ // Ours
+ author = skinAuthor,
+ card = cardId,
+ image = skinImage,
+ name = skinName,
+ id = name || 0,
+ type = skinType,
+ }) {
+ super({ id });
+ this.#author = author;
+ this.#card = Number(card);
+ this.#image = image;
+ this.#name = name;
+ this.#type = Number(type);
+ }
+
+ get author() {
+ return this.#author;
+ }
+
+ get authorName() { // Vanilla
+ return this.#author;
+ }
+
+ get card() {
+ return this.#card;
+ }
+
+ get cardId() { // Vanilla
+ return this.#card;
+ }
+
+ get image() { // Vanilla
+ return this.#image;
+ }
+
+ get imageSrc() {
+ return `/images/cards/${this.image}.png`;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ get type() {
+ // TODO: convert to constant
+ return this.#type;
+ }
+
+ get typeSkin() { // Vanilla
+ return this.#type;
+ }
+
+ toJSON() {
+ return {
+ author: this.author,
+ card: this.card,
+ image: this.image,
+ src: this.imageSrc,
+ name: this.name,
+ type: this.type,
+ };
+ }
+}
diff --git a/src/structures/chat.local/channel.js b/src/structures/chat.local/channel.js
new file mode 100644
index 00000000..956f8942
--- /dev/null
+++ b/src/structures/chat.local/channel.js
@@ -0,0 +1,27 @@
+import Collection from 'src/utils/collection.js';
+import Base from '../base.js';
+import Message from './message.js';
+
+export default class Channel extends Base {
+ constructor(data, owner) {
+ super(data);
+ this.owner = owner;
+ this.name = data.name;
+ this.messages = new Collection(Message, 50);
+ this.update(data);
+ }
+
+ update(data) {
+ switch (data.action) {
+ case 'getMessage':
+ case 'getPrivateMessage':
+ // TODO: Add new message
+ break;
+ default: break;
+ }
+ }
+
+ sendMessage(message) {
+ return this.owner.sendMessage(message, this);
+ }
+}
diff --git a/src/structures/chat.local/index.js b/src/structures/chat.local/index.js
new file mode 100644
index 00000000..ec80b606
--- /dev/null
+++ b/src/structures/chat.local/index.js
@@ -0,0 +1,32 @@
+import Collection from 'src/utils/collection.js';
+import Channel from './channel.js';
+
+export default class Chat {
+ constructor(data) {
+ this.channels = new Collection(Channel);
+ this.update(data);
+ }
+
+ update(data) {
+ switch (data.action) {
+ case 'getMessage':
+ case 'getPrivateMessage': {
+ // TODO: Add new message
+ break;
+ }
+ default: break;
+ }
+ }
+
+ get isConnected() {
+ return false;
+ }
+
+ get socket() {
+ return undefined;
+ }
+
+ sendMessage(message, channel) {
+ // TODO
+ }
+}
diff --git a/src/structures/chat.local/message.js b/src/structures/chat.local/message.js
new file mode 100644
index 00000000..6e5cfe09
--- /dev/null
+++ b/src/structures/chat.local/message.js
@@ -0,0 +1,53 @@
+import Base from '../base.js';
+import User from './user.js';
+
+export default class Message extends Base {
+ #user;
+ #room;
+ #message;
+ #deleted = false;
+ #action;
+ #rainbow;
+
+ constructor(data, channel = data.channel) {
+ super(data);
+ this.#user = new User(data.user || data.author);
+ this.#room = channel;
+ this.#message = data.chatMessage || data.message;
+ this.#action = (data.action || data.me) === true;
+ this.#rainbow = data.rainbow === true;
+ this.update(data);
+ }
+
+ update(data) {
+ if (data.deleted !== undefined) this.#deleted = data.deleted === true;
+ }
+
+ get channel() {
+ return this.#room;
+ }
+
+ get message() {
+ return this.#message;
+ }
+
+ get action() {
+ return this.#action;
+ }
+
+ get author() {
+ return this.#user;
+ }
+
+ get deleted() {
+ return this.#deleted;
+ }
+
+ get rainbow() {
+ return this.#rainbow;
+ }
+
+ get element() {
+ return document.querySelector(`#${this.channel} #message-${this.id}`);
+ }
+}
diff --git a/src/structures/chat.local/user.js b/src/structures/chat.local/user.js
new file mode 100644
index 00000000..1a3f9f1a
--- /dev/null
+++ b/src/structures/chat.local/user.js
@@ -0,0 +1,23 @@
+import Base from 'src/base.js';
+import { DESIGNER, SUPPORTER } from 'src/constants/roles.js';
+
+export default class User extends Base {
+ constructor(data) {
+ super(data);
+ this.room = data.idRoom;
+ this.update(data);
+ }
+
+ update(data) {
+ }
+
+ get isMod() {
+ const { priority } = this;
+ return priority && priority <= SUPPORTER;
+ }
+
+ get isStaff() {
+ const { priority } = this;
+ return priority && priority <= DESIGNER; // Why did I pick designer?
+ }
+}
diff --git a/src/structures/constants/constant.js b/src/structures/constants/constant.js
new file mode 100644
index 00000000..9fa34cb7
--- /dev/null
+++ b/src/structures/constants/constant.js
@@ -0,0 +1,18 @@
+export default class Constant {
+ #value;
+ constructor(value, ...rest) {
+ this.#value = [value, ...rest];
+ }
+
+ equals(other) {
+ return this === other || this.#value.includes(other?.valueOf());
+ }
+
+ toString() {
+ return this.valueOf();
+ }
+
+ valueOf() {
+ return this.#value[0];
+ }
+}
diff --git a/src/structures/constants/item.js b/src/structures/constants/item.js
new file mode 100644
index 00000000..34f0c7d8
--- /dev/null
+++ b/src/structures/constants/item.js
@@ -0,0 +1,34 @@
+import * as api from 'src/utils/4.api.js';
+import Constant from './constant.js';
+
+export default class Item extends Constant {
+ static GOLD = new Item('Gold', 'gold', 'GOLD', 'reward-gold');
+ static UCP = new Item('UCP', 'ucp', 'item-ucp', 'reward-ucp');
+ static DUST = new Item('Dust', 'dust', 'DUST', 'item-dust', 'reward-dust');
+ static EXP = new Item('XP', 'xp', 'exp', 'experience', 'stat-xp', 'reward-xp');
+ static ELO = new Item('elo');
+ static DT_FRAGMENT = new Item('DT Fragment', 'fragment', 'dt fragment', 'dt frag', 'dtfrag', 'item-dt-fragment', 'reward-dt-fragment');
+
+ static UT_PACK = new Item('Pack', 'pack', 'PACK', 'reward-pack');
+ static DR_PACK = new Item('DR Pack', 'dr pack', 'DRPack', 'DR_PACK', 'reward-dr-pack');
+ static UTY_PACK = new Item('UTY Pack', 'uty pack', 'UTYPack', 'UTY_PACK', 'reward-uty-pack');
+ static SHINY_PACK = new Item('Shiny Pack', 'ShinyPack', 'shiny pack', 'SHINY_PACK', 'reward-shiny-pack');
+ static SUPER_PACK = new Item('Super Pack', 'SuperPack', 'super pack', 'reward-super-pack');
+ static FINAL_PACK = new Item('Final Pack', 'FinalPack', 'final pack', 'reward-final-pack');
+
+ static CARD = new Item('Card', 'card');
+ static SKIN = new Item('Card Skin', 'Skin', 'card skin', 'skin', 'reward-card-skin');
+ static AVATAR = new Item('Avatar', 'avatar', 'reward-avatar');
+ static EMOTE = new Item('Emote', 'emote', 'reward-emote');
+ static PROFILE = new Item('Profile Skin', 'Profile', 'profile skin', 'profile', 'reward-profile-skin');
+
+ static find(value) {
+ if (value instanceof Item) return value;
+ // eslint-disable-next-line no-use-before-define
+ return items.find((item) => item.equals(value));
+ }
+}
+
+const items = Object.values(Item);
+
+api.mod.item = Object.fromEntries(Object.entries(Item));
diff --git a/src/structures/constants/priority.js b/src/structures/constants/priority.js
new file mode 100644
index 00000000..9fb922ef
--- /dev/null
+++ b/src/structures/constants/priority.js
@@ -0,0 +1,22 @@
+import * as api from 'src/utils/4.api.js';
+import Constant from './constant.js';
+
+export default class Priority extends Constant {
+ static FIRST = new Priority('first', 'top');
+ static HIGHEST = new Priority('highest');
+ static HIGH = new Priority('high');
+ static NORMAL = new Priority('normal');
+ static LOW = new Priority('low');
+ static LOWEST = new Priority('lowest');
+ static LAST = new Priority('last', 'bottom');
+
+ static get(value) {
+ if (value instanceof Priority) return value;
+ // eslint-disable-next-line no-use-before-define
+ return values.find((v) => v.equals(value));
+ }
+}
+
+const values = Object.values(Priority);
+
+api.mod.priority = Object.fromEntries(Object.entries(Priority));
diff --git a/src/structures/constants/translation.ts b/src/structures/constants/translation.ts
new file mode 100644
index 00000000..2db93c30
--- /dev/null
+++ b/src/structures/constants/translation.ts
@@ -0,0 +1,141 @@
+import { translateText } from 'src/utils/translate.js';
+import Constant from './constant.js';
+
+export type Tuple
= A extends { length: N } ? A : Tuple;
+
+export type TranslationWithArgsOptions = TranslationOptions & {
+ args: Tuple;
+};
+
+export type TranslationOptions = {
+ args?: string[];
+ fallback?: string;
+ prefix?: string | null;
+};
+
+export interface TranslationBase {
+ readonly key: string;
+ toString(): string;
+ translate(...args: string[]): string;
+ withArgs(...args: string[]): TranslationWithArgs;
+}
+
+export interface TranslationWithArgs extends TranslationBase {
+ translate(...arg: Tuple): string;
+ withArgs(...arg: Tuple): TranslationWithArgs;
+ withArgs(...arg: Tuple): TranslationWithArgs;
+}
+
+export default class Translation extends Constant implements TranslationBase {
+ static DISMISS = this.General('dismiss', 'Dismiss');
+ static ERROR = this.General('error', 'Error');
+ static OPEN = this.General('open', 'Open');
+ static PURCHASE = this.General('purchase.item');
+ static UNDO = this.General('undo', 'Undo');
+ static UNKNOWN = this.General('unknown', 'Unknown');
+ static UPDATE = this.General('update', 'Update');
+
+ static CATEGORY_AUTO_DECLINE = this.Setting('category.autodecline');
+ static CATEGORY_CARD_SKINS = this.Setting('category.card.skins');
+ static CATEGORY_CHAT_COMMAND = this.Setting('category.chat.commands');
+ static CATEGORY_CHAT_IGNORED = this.Setting('category.chat.ignored');
+ static CATEGORY_CHAT_IMPORT = this.Setting('category.chat.import');
+ static CATEGORY_CUSTOM = this.Setting('category.custom');
+ static CATEGORY_FRIENDSHIP = this.Setting('category.friendship');
+ static CATEGORY_HOME = this.Setting('category.home');
+ static CATEGORY_HOTKEYS = this.Setting('category.hotkeys');
+ static CATEGORY_LIBRARY_CRAFTING = this.Setting('category.library.crafting');
+ static CATEGORY_MINIGAMES = this.Setting('category.minigames');
+ static CATEGORY_OUTLINE = this.Setting('category.outline');
+ static CATEGORY_PLUGINS = this.Setting('category.plugins');
+ static CATEGORY_STREAMER = this.Setting('category.streamer');
+ static CATEGORY_UPDATES = this.Setting('category.updates');
+
+ static DISABLE_COMMAND_SETTING = this.Setting('command', 1);
+
+ static IGNORED = this.Toast('ignore', 1);
+ static INFO = this.Toast('toast.info', 'Did you know?');
+
+ static CANCEL = this.Vanilla('dialog-cancel', 'Cancel');
+ static CLOSE = this.Vanilla('dialog-close', 'Close');
+ static CONTINUE = this.Vanilla('dialog-continue', 'Continue');
+
+ private args: string[];
+ private fallback?: string;
+
+ constructor(key: string, {
+ args = [],
+ fallback,
+ prefix = 'underscript',
+ }: TranslationOptions = {}) {
+ if (prefix) {
+ super(`${prefix}.${key}`);
+ } else {
+ super(key);
+ }
+ this.args = args;
+ this.fallback = fallback;
+ }
+
+ get key(): string {
+ return this.valueOf();
+ }
+
+ translate(...args: string[]): string {
+ return translateText(this.key, {
+ args: args.length ? args : this.args,
+ fallback: this.fallback,
+ });
+ }
+
+ withArgs(...args: Tuple): TranslationWithArgs {
+ return new Translation(this.key, {
+ args,
+ prefix: null,
+ });
+ }
+
+ toString() {
+ return this.translate();
+ }
+
+ static General(key: string): Translation;
+ static General(key: string, fallback: string): Translation;
+ static General(key: string, hasArgs: N): TranslationWithArgs;
+ static General(key: string, text?: string | N): TranslationBase | TranslationWithArgs {
+ const fallback = typeof text === 'string' ? text : undefined;
+ return new Translation(`general.${key}`, { fallback });
+ }
+
+ static Menu(key: string): Translation;
+ static Menu(key: string, fallback: string): Translation;
+ static Menu(key: string, hasArgs: N): TranslationWithArgs;
+ static Menu(key: string, text?: string | N): TranslationBase | TranslationWithArgs {
+ const fallback = typeof text === 'string' ? text : undefined;
+ return new Translation(`menu.${key}`, { fallback });
+ }
+
+ static Setting(key: string): Translation;
+ static Setting(key: string, fallback: string): Translation;
+ static Setting(key: string, hasArgs: N): TranslationWithArgs;
+ static Setting(key: string, text?: string | N): TranslationBase | TranslationWithArgs {
+ const fallback = typeof text === 'string' ? text : undefined;
+ return new Translation(`settings.${key}`, { fallback });
+ }
+
+ static Toast(key: string): Translation;
+ static Toast(key: string, fallback: string): Translation;
+ static Toast(key: string, hasArgs: N): TranslationWithArgs;
+ static Toast(key: string, text?: string | N): TranslationBase | TranslationWithArgs {
+ const fallback = typeof text === 'string' ? text : undefined;
+ return new Translation(`toast.${key}`, { fallback });
+ }
+
+ static Vanilla(key: string): Translation;
+ static Vanilla(key: string, fallback: string): Translation
+ static Vanilla(key: string, hasArgs: N): TranslationWithArgs;
+ static Vanilla(key: string, text?: string | number): TranslationBase | TranslationWithArgs {
+ const fallback = typeof text === 'string' ? text : undefined;
+ return new Translation(key.toLowerCase(), { fallback, prefix: null });
+ }
+}
diff --git a/src/structures/game.local/board.js b/src/structures/game.local/board.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/structures/game.local/index.js b/src/structures/game.local/index.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/structures/game.local/player.js b/src/structures/game.local/player.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/structures/quests/Progress.js b/src/structures/quests/Progress.js
new file mode 100644
index 00000000..599c902c
--- /dev/null
+++ b/src/structures/quests/Progress.js
@@ -0,0 +1,37 @@
+export default class Progress {
+ #max;
+ #value;
+
+ constructor({
+ max = 0,
+ value = 0,
+ } = {}) {
+ this.#max = max;
+ this.#value = value;
+ }
+
+ get max() {
+ return this.#max;
+ }
+
+ get value() {
+ return this.#value;
+ }
+
+ get complete() {
+ return this.max === this.value;
+ }
+
+ compare(other) {
+ if (!(other instanceof Progress)) throw new Error('invalid object');
+ return Math.abs(this.value - other.value);
+ }
+
+ toJSON() {
+ return {
+ complete: this.complete,
+ max: this.max,
+ value: this.value,
+ };
+ }
+}
diff --git a/src/structures/quests/Quest.js b/src/structures/quests/Quest.js
new file mode 100644
index 00000000..68ffc7a7
--- /dev/null
+++ b/src/structures/quests/Quest.js
@@ -0,0 +1,88 @@
+import { translateText } from 'src/utils/translate.js';
+import Reward from './Reward.js';
+import Progress from './Progress.js';
+import Base from '../base.js';
+
+export default class Quest extends Base {
+ #args;
+ #claimable = false;
+ #key;
+ #progress = new Progress();
+ #reward = new Reward();
+
+ constructor(data) {
+ const id = isQuest(data) ? data.id : getId(data);
+ super({ id });
+ if (isQuest(data)) {
+ this.#args = data.#args;
+ this.#key = data.#key;
+ } else if (data instanceof Element) {
+ const el = data.querySelector('[data-i18n-custom^="quest"]');
+ if (!el) throw new Error('Malformed quest');
+ this.#args = el.dataset.i18nArgs?.split(',') || [];
+ this.#key = el.dataset.i18nCustom;
+ }
+ this.update(data);
+ }
+
+ update(data) {
+ if (data instanceof Element) {
+ this.#claimable = data.querySelector('input[type="submit"][value="Claim"]:not(:disabled)') !== null;
+ this.#progress = new Progress(data.querySelector('progress'));
+ this.#reward = new Reward(data.querySelector('td:nth-last-child(2)'));
+ } else if (isQuest(data)) {
+ this.#claimable = data.claimable;
+ this.#progress = data.progress;
+ this.#reward = data.reward;
+ }
+ }
+
+ get name() {
+ return translateText(this.#key, {
+ args: this.#args,
+ });
+ }
+
+ get reward() {
+ return this.#reward;
+ }
+
+ get progress() {
+ return this.#progress;
+ }
+
+ get claimable() {
+ return this.#claimable;
+ }
+
+ get claimed() {
+ return !this.claimable && this.progress.complete;
+ }
+
+ clone() {
+ return new Quest(this);
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ name: this.name,
+ reward: this.reward,
+ progress: this.progress,
+ claimable: this.claimable,
+ claimed: this.claimed,
+ };
+ }
+}
+
+/**
+ * @param {*} data
+ * @returns {data is Quest}
+ */
+function isQuest(data) {
+ return data instanceof Quest;
+}
+
+export function getId(element) {
+ return element.querySelector('[name="questId"]')?.value ?? element.querySelector('[data-i18n-custom^="quest"]')?.dataset.i18nCustom;
+}
diff --git a/src/structures/quests/Reward.js b/src/structures/quests/Reward.js
new file mode 100644
index 00000000..c6280de2
--- /dev/null
+++ b/src/structures/quests/Reward.js
@@ -0,0 +1,100 @@
+import Skin from '../card/skin.js';
+import Item from '../constants/item.js';
+
+export default class Reward {
+ #reward;
+ #type;
+
+ constructor(el) {
+ this.#reward = el;
+ }
+
+ get reward() {
+ this.#process();
+ return this.#reward;
+ }
+
+ get type() {
+ this.#process();
+ return this.#type;
+ }
+
+ #process() {
+ if (!(this.#reward instanceof Element)) return;
+ const { type, value } = rewardType(this.#reward);
+ this.#reward = value;
+ this.#type = type;
+ }
+
+ toJSON() {
+ return {
+ reward: this.reward,
+ type: this.type,
+ };
+ }
+}
+
+function rewardType(el) {
+ let temp = el.querySelector('[data-i18n-tips]');
+ if (temp) { // Generic reward
+ const type = temp.dataset.i18nTips;
+ return {
+ type: Item.find(type) || type,
+ value: temp.parentElement.textContent.trim().substring(1),
+ };
+ }
+
+ temp = el.querySelector('[data-i18n-custom="quests-ucp"]');
+ if (temp) {
+ return {
+ type: Item.UCP,
+ value: temp.dataset.i18nArgs,
+ };
+ }
+
+ temp = el.querySelector('.card-skin-bordered');
+ if (temp) {
+ const { textContent: text } = temp.attributes.onmouseover;
+ return {
+ type: Item.CARD,
+ value: text.substring(text.indexOf(',') + 1, text.indexOf(')')).trim(),
+ };
+ }
+
+ temp = el.querySelector('[data-skin-type]');
+ if (temp) {
+ return {
+ type: Item.SKIN,
+ value: new Skin(temp.dataset),
+ };
+ }
+
+ temp = el.querySelector('.avatar');
+ if (temp) {
+ return {
+ type: Item.AVATAR,
+ value: {
+ image: temp.src,
+ rarity: temp.classList[1],
+ },
+ };
+ }
+
+ temp = el.querySelector('[src*="/emotes/"]');
+ if (temp) {
+ return {
+ type: Item.EMOTE,
+ value: temp.src,
+ };
+ }
+
+ temp = el.querySelector('[src*="/profiles/"]');
+ if (temp) {
+ return {
+ type: Item.PROFILE,
+ value: temp.src,
+ };
+ }
+
+ throw new Error('unknown reward type');
+}
diff --git a/src/structures/vault.local/card.js b/src/structures/vault.local/card.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/structures/vault.local/deck.js b/src/structures/vault.local/deck.js
new file mode 100644
index 00000000..99697690
--- /dev/null
+++ b/src/structures/vault.local/deck.js
@@ -0,0 +1,128 @@
+import { global } from 'src/utils/global.js';
+import Base from '../base.js';
+
+export default class Deck extends Base {
+ /**
+ * @type {import("./storage.js").default}
+ */
+ #owner;
+ #soul;
+
+ constructor(owner, soul = '', index = 0) {
+ super({
+ id: index,
+ });
+ this.#owner = owner;
+ this.#soul = soul;
+ }
+
+ get key() {
+ this.#checkLocal();
+ return `${this.#owner.key}.${this.#soul}.${this.id}`;
+ }
+
+ get raw() {
+ const cards = [{
+ id: 0,
+ shiny: false,
+ }];
+ const artifacts = [0];
+ // Clear typings
+ cards.shift();
+ artifacts.shift();
+
+ const data = JSON.parse(this.#getRaw());
+ if (Array.isArray(data.cards)) {
+ cards.concat(data.cards);
+ }
+ if (Array.isArray(data.artifacts)) {
+ artifacts.concat(data.artifacts);
+ }
+ return {
+ artifacts,
+ cards,
+ name: this.name,
+ description: this.description,
+ };
+ }
+
+ get name() {
+ return this.#getRaw('name');
+ }
+
+ set name(to) {
+ this.#setRaw('name', to);
+ }
+
+ get description() {
+ return this.#getRaw('description');
+ }
+
+ set description(to) {
+ this.#setRaw('description', to);
+ }
+
+ get cards() {
+ return this.raw.cards;
+ }
+
+ get artifacts() {
+ return this.raw.artifacts;
+ }
+
+ getCards() {
+ const allCards = global('allCards', { throws: false });
+ const cards = this.cards;
+
+ if (!allCards) return cards;
+
+ return cards.map(({ id, shiny }) => {
+ const card = allCards.find((c) => c.id === id && c.shiny === shiny);
+ if (!card) return {};
+ return { ...card }; // TODO: use card structure
+ });
+ }
+
+ getArtifacts() {
+ const allArtifacts = global('allArtifacts', { throws: false });
+ const artifacts = this.artifacts;
+
+ if (!allArtifacts) return artifacts;
+
+ const arts = artifacts.map((id) => {
+ const artifact = allArtifacts.find(({ id: artID }) => artID === id);
+ if (!artifact) return undefined;
+ return { ...artifact }; // TODO: use artifact structure
+ }).filter((_) => _); // Strip empty entries
+ if (arts.length > 1) { // Only 1 legendary allowed
+ const legend = arts.find(({ legendary }) => legendary);
+ if (legend) return [legend];
+ }
+ return arts;
+ }
+
+ meta() {
+ // TODO - Object that allows getting/setting of metadata items
+ // this.getRaw(`meta.${key}`);
+ // this.setRaw(`meta.${key}`, value);
+ }
+
+ #getRaw(key) { // TODO: Store all data in `${this.key}.meta`?
+ this.#checkLocal();
+ return localStorage.getItem(key ? `${this.key}.${key}` : this.key) ?? '';
+ }
+
+ #setRaw(key, value = null) {
+ this.#checkLocal();
+ const storageKey = key ? `${this.key}.${key}` : this.key;
+ if (value === null) {
+ localStorage.removeItem(storageKey);
+ } else {
+ localStorage.setItem(storageKey, value);
+ }
+ }
+
+ #checkLocal() {
+ if (!this.#owner || !this.#soul || !this.id) throw new Error('Not a local deck');
+ }
+}
diff --git a/src/structures/vault.local/storage.js b/src/structures/vault.local/storage.js
new file mode 100644
index 00000000..3e3c3f82
--- /dev/null
+++ b/src/structures/vault.local/storage.js
@@ -0,0 +1,13 @@
+import Base from '../base.js';
+
+export default class Storage extends Base {
+ constructor(userId) {
+ super({
+ id: userId,
+ });
+ }
+
+ get key() {
+ return `underscript.deck.${this.id}`;
+ }
+}
diff --git a/src/tsconfig.json b/src/tsconfig.json
new file mode 100644
index 00000000..fcfe9ebb
--- /dev/null
+++ b/src/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "include": ["**/*"],
+
+ "compilerOptions": {
+ "allowJs": true,
+ "noEmit": true,
+ "paths": {
+ "src/*": ["./*"],
+ },
+ "rootDir": "."
+ },
+}
\ No newline at end of file
diff --git a/src/utils/.eslintrc.cjs b/src/utils/.eslintrc.cjs
new file mode 100644
index 00000000..fe7e05da
--- /dev/null
+++ b/src/utils/.eslintrc.cjs
@@ -0,0 +1,8 @@
+/* eslint-env node */
+module.exports = {
+ rules: {
+ 'no-return-assign': 'off',
+ 'no-unused-vars': 'off',
+ 'no-console': 'off',
+ },
+};
diff --git a/src/utils/0.publicist.js b/src/utils/0.publicist.js
new file mode 100644
index 00000000..e9b88ae4
--- /dev/null
+++ b/src/utils/0.publicist.js
@@ -0,0 +1,14 @@
+import { window } from './1.variables.js';
+
+function setup() {
+ // eslint-disable-next-line no-undef
+ if (typeof setVersion === 'function') setVersion(GM_info.script.version, GM_info.scriptHandler);
+}
+
+if (!location.host.includes('undercards.net')) {
+ if (document.readyState === 'complete') {
+ setup();
+ } else {
+ window.addEventListener('load', setup);
+ }
+}
diff --git a/src/utils/1.variables.js b/src/utils/1.variables.js
new file mode 100644
index 00000000..af74e9a0
--- /dev/null
+++ b/src/utils/1.variables.js
@@ -0,0 +1,26 @@
+export const script = this;
+export const UNDERSCRIPT = 'UnderScript';
+export const footer = `${UNDERSCRIPT} ©feildmaster
`;
+export const footer2 = `via ${UNDERSCRIPT}
`;
+export const hotkeys = [];
+export const scriptVersion = GM_info.script.version;
+export const buttonCSS = {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+};
+/**
+ * @type {globalThis}
+ */
+export const window = typeof unsafeWindow === 'object' ? unsafeWindow : globalThis;
+
+export const SOCKET_SCRIPT_CLOSED = 3500;
+
+export const HOUR = 60 * 60 * 1000;
+
+export const DAY = 24 * HOUR;
+
+export function noop() {}
diff --git a/src/utils/2.pokemon.js b/src/utils/2.pokemon.js
new file mode 100644
index 00000000..a326ffc4
--- /dev/null
+++ b/src/utils/2.pokemon.js
@@ -0,0 +1,15 @@
+import { captureError } from './sentry.js';
+
+export default function wrap(callback, prefix = '', logger = console) {
+ try {
+ return callback();
+ } catch (e) {
+ const name = prefix || callback && callback.name || 'Undefined';
+ captureError(e, {
+ name,
+ function: 'wrap',
+ });
+ logger.error(`[${name}] Error occured`, e); // eslint-disable-line no-mixed-operators
+ }
+ return undefined;
+}
diff --git a/src/utils/2.toasts.js b/src/utils/2.toasts.js
new file mode 100644
index 00000000..e2d77a9b
--- /dev/null
+++ b/src/utils/2.toasts.js
@@ -0,0 +1,133 @@
+import Translation from 'src/structures/constants/translation.js';
+import { buttonCSS } from './1.variables.js';
+import merge from './merge.js';
+import eventManager from './eventManager.js';
+import toArray from './toArray.js';
+
+let ready = false;
+
+eventManager.on('underscript:ready', () => ready = true);
+
+export function blankToast() {
+ return new SimpleToast();
+}
+
+export function toast(arg) {
+ if (!arg) return false;
+ if (typeof arg === 'string' || arg instanceof Translation) {
+ arg = {
+ text: arg,
+ };
+ }
+ const isTranslation = [
+ arg.text,
+ arg.title,
+ ...toArray(arg.buttons).map(({ text }) => text),
+ ].some((obj) => obj instanceof Translation);
+ const defaults = {
+ footer: 'via UnderScript',
+ css: {
+ 'background-color': 'rgba(0,5,20,0.6)',
+ 'text-shadow': '',
+ 'font-family': 'monospace',
+ footer: {
+ 'text-align': 'end',
+ },
+ },
+ };
+ if (ready && isTranslation) preprocess(arg);
+ const slice = new SimpleToast(merge(defaults, arg));
+ if (!ready && isTranslation && slice.exists()) {
+ const el = $('#AlertToast > div:last');
+ eventManager.on('underscript:ready', () => {
+ process(slice, el, arg);
+ });
+ }
+ return slice;
+}
+
+export function errorToast(error) {
+ function getStack(err = {}) {
+ const stack = err.stack;
+ if (stack) {
+ return stack.replace('<', '<');
+ }
+ return null;
+ }
+
+ const lToast = {
+ title: error.name || error.title || Translation.ERROR,
+ text: error.message || error.text || getStack(error.error || error) || error,
+ css: {
+ 'background-color': 'rgba(200,0,0,0.6)',
+ },
+ className: error.className,
+ onClose: error.onClose,
+ footer: error.footer,
+ buttons: error.buttons,
+ };
+ return toast(lToast);
+}
+
+export function infoToast(arg, key, val = '1') {
+ if (localStorage.getItem(key) === val) return null;
+ if (typeof arg === 'string') {
+ arg = {
+ text: arg,
+ };
+ } else if (typeof arg !== 'object') return null;
+ const override = {
+ onClose: (...args) => {
+ if (typeof arg.onClose === 'function') {
+ if (arg.onClose(...args)) {
+ return;
+ }
+ }
+ localStorage.setItem(key, val);
+ },
+ };
+ const defaults = {
+ title: Translation.INFO,
+ css: {
+ 'font-family': 'inherit',
+ },
+ };
+ return toast(merge(defaults, arg, override));
+}
+
+export function dismissable({ title, text, key, value = 'true', css = {} }) {
+ if (localStorage.getItem(key) === value) return undefined;
+ const buttons = {
+ text: Translation.DISMISS,
+ className: 'dismiss',
+ css: buttonCSS,
+ onclick: (e) => {
+ localStorage.setItem(key, value);
+ },
+ };
+ return toast({
+ title,
+ text,
+ buttons,
+ className: 'dismissable',
+ css,
+ });
+}
+
+function preprocess(arg) {
+ ['text', 'title'].forEach((prop) => {
+ if (arg[prop] instanceof Translation) arg[prop] = `${arg[prop]}`;
+ });
+ toArray(arg.buttons).forEach((button) => {
+ if (button.text instanceof Translation) button.text = `${button.text}`;
+ });
+}
+
+function process(instance, el, { text, title, buttons }) {
+ if (text instanceof Translation) instance.setText(`${text}`);
+ if (title instanceof Translation) el.find('> span:first').text(title);
+ const $buttons = el.find('> button');
+ toArray(buttons).forEach((button, i) => {
+ if (button.text instanceof Translation) $buttons[i].textContent = button.text;
+ });
+}
diff --git a/src/utils/3.doublecheck.js b/src/utils/3.doublecheck.js
new file mode 100644
index 00000000..f4ee8595
--- /dev/null
+++ b/src/utils/3.doublecheck.js
@@ -0,0 +1,5 @@
+/* eslint-disable no-underscore-dangle */
+import { scriptVersion, window } from './1.variables.js';
+
+if (window._UnderScript) throw new Error('UnderScript loaded twice');
+window._UnderScript = scriptVersion;
diff --git a/src/utils/4.api.js b/src/utils/4.api.js
new file mode 100644
index 00000000..d821319c
--- /dev/null
+++ b/src/utils/4.api.js
@@ -0,0 +1,44 @@
+import {
+ scriptVersion,
+ window,
+} from './1.variables.js';
+
+const underscript = {
+ version: scriptVersion,
+};
+
+const modules = {};
+
+export function register(name, val, module = false) {
+ // if (underscript.ready) throw new Error(`Registering module (${name}) too late!`);
+ if (underscript[name]) {
+ if (!module) throw new Error(`${name} already exists`);
+ console.error(`Module [${name}] skipped, variable exists`);
+ return;
+ }
+
+ underscript[name] = val;
+}
+
+export const mod = new Proxy(modules, {
+ get(o, key, r) {
+ if (!(key in o)) {
+ const ob = {};
+ Reflect.set(o, key, ob, r);
+ register(key, ob, true);
+ }
+ return Reflect.get(o, key, r);
+ },
+ set(o, key, val, r) {
+ if (key in o) return false;
+ register(key, val, true);
+ return Reflect.set(o, key, val, r);
+ },
+});
+
+window.underscript = new Proxy(underscript, {
+ get(...args) {
+ return new Proxy(Reflect.get(...args), { set() {} });
+ },
+ set() {},
+});
diff --git a/src/utils/DialogHelper.js b/src/utils/DialogHelper.js
new file mode 100644
index 00000000..4a87fdd1
--- /dev/null
+++ b/src/utils/DialogHelper.js
@@ -0,0 +1,72 @@
+import Translation from 'src/structures/constants/translation.ts';
+import eventEmitter from './eventEmitter.js';
+
+export default class DialogHelper {
+ #instance;
+
+ #events = eventEmitter();
+
+ isOpen() {
+ return !!this.#instance;
+ }
+
+ open({
+ buttons = [],
+ cssClass = 'underscript-dialog',
+ message,
+ title,
+ ...options
+ } = BootstrapDialog.defaultOptions) {
+ if (this.isOpen() || !message || !title) return;
+ BootstrapDialog.show({
+ ...options,
+ title,
+ message,
+ buttons: [
+ ...buttons,
+ {
+ cssClass: 'btn-primary',
+ label: `${Translation.CLOSE}`,
+ action: () => this.close(),
+ },
+ ],
+ cssClass: `mono ${cssClass}`,
+ onshown: (diag) => {
+ this.#instance = diag;
+ this.#events.emit('open', diag);
+ },
+ onhidden: () => {
+ this.#instance = null;
+ this.#events.emit('close');
+ },
+ });
+ }
+
+ close() {
+ this.#instance?.close();
+ }
+
+ onClose(callback) {
+ this.#events.on('close', callback);
+ }
+
+ onOpen(callback) {
+ this.#events.on('open', callback);
+ }
+
+ appendButton(...buttons) {
+ const diag = this.#instance;
+ if (!diag) return;
+
+ diag.options.buttons.push(...buttons);
+ diag.updateButtons();
+ }
+
+ prependButton(...buttons) {
+ const diag = this.#instance;
+ if (!diag) return;
+
+ diag.options.buttons.unshift(...buttons);
+ diag.updateButtons();
+ }
+}
diff --git a/src/utils/VarStore.js b/src/utils/VarStore.js
new file mode 100644
index 00000000..c5c5fa68
--- /dev/null
+++ b/src/utils/VarStore.js
@@ -0,0 +1,32 @@
+export default function VarStore(def) {
+ let v = def;
+
+ function get() {
+ const ret = v;
+ set(def);
+ return ret;
+ }
+
+ function peak() {
+ return v;
+ }
+
+ function set(val) {
+ return v = val;
+ }
+
+ function isSet() {
+ return v !== def;
+ }
+
+ const ret = {
+ get, set, peak, isSet, value: v,
+ };
+
+ Object.defineProperty(ret, 'value', {
+ get,
+ set,
+ });
+
+ return Object.freeze(ret);
+}
diff --git a/src/utils/active.js b/src/utils/active.js
new file mode 100644
index 00000000..03586305
--- /dev/null
+++ b/src/utils/active.js
@@ -0,0 +1 @@
+export default () => document.visibilityState === 'visible';
diff --git a/src/utils/appendCardExtras.js b/src/utils/appendCardExtras.js
new file mode 100644
index 00000000..980f9bce
--- /dev/null
+++ b/src/utils/appendCardExtras.js
@@ -0,0 +1,11 @@
+const argExtractor = {
+ appendCardCardSkinShop: (...args) => args,
+ appendCardFriendship: (_, ...args) => args,
+ showCardHover: (...args) => args,
+};
+
+export default function getExtras(key, args = []) {
+ const extractor = argExtractor[key];
+ if (!extractor) return undefined;
+ return extractor(...args);
+}
diff --git a/src/utils/cardHelper.js b/src/utils/cardHelper.js
new file mode 100644
index 00000000..df5e3df7
--- /dev/null
+++ b/src/utils/cardHelper.js
@@ -0,0 +1,183 @@
+/* eslint-disable no-param-reassign */
+import * as api from './4.api.js';
+import { debug } from './debug.js';
+import { global } from './global.js';
+import { translateText } from './translate.js';
+
+const unset = [undefined, null];
+export function max(rarity) { // eslint-disable-line no-shadow
+ switch (rarity) {
+ case 'DETERMINATION':
+ case 'LEGENDARY': return 1;
+ case 'EPIC': return 2;
+ case 'RARE':
+ case 'BASE':
+ case 'COMMON': return 3;
+ case 'TOKEN':
+ case 'GENERATED': return 0;
+ default:
+ debug(`Unknown rarity: ${rarity}`);
+ return undefined;
+ }
+}
+
+export function isShiny(el) {
+ return el.classList.contains('shiny');
+}
+
+export function find(id, shiny) {
+ const elements = document.querySelectorAll(`[id="${id}"]`);
+ if (shiny !== undefined) {
+ for (let i = 0; i < elements.length; i++) {
+ const el = elements[i];
+ if (shiny === isShiny(el)) {
+ return el;
+ }
+ }
+ }
+ return elements[0];
+}
+
+export function name(el) {
+ return el.querySelector('.cardName').textContent;
+}
+
+export function rarity(el) {
+ return getCardData(el.id).rarity;
+}
+
+export function quantity(el) {
+ return parseInt(el.querySelector('.cardQuantity .nb, #quantity .nb, .quantity .nb').textContent, 10);
+}
+
+export function cost(el) {
+ return parseInt(el.querySelector('.cardCost').textContent, 10);
+}
+
+export function totalDust() {
+ return parseInt(document.querySelector('span#dust').textContent, 10);
+}
+
+export function craftable(el) {
+ const r = rarity(el);
+ if (quantity(el) >= max(r)) {
+ return false;
+ }
+ const s = isShiny(el);
+ switch (r) {
+ case 'DETERMINATION': return fragCost(r, s) <= totalFrags();
+ case 'LEGENDARY':
+ case 'EPIC':
+ case 'RARE':
+ case 'COMMON':
+ case 'BASE': {
+ const dust = dustCost(r, s);
+ return dust !== null && dust <= totalDust();
+ }
+ case 'TOKEN':
+ case 'GENERATED': return false;
+ default: {
+ debug(`Unknown Rarity: ${r}`);
+ return false;
+ }
+ }
+}
+
+export function totalFrags() {
+ return Number(document.querySelector('span#nbDTFragments').textContent);
+}
+
+export function fragCost(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ case 'DETERMINATION': return s ? 8 : 4;
+ default: return null;
+ }
+}
+
+export function fragGain(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ case 'DETERMINATION': return s ? 4 : 2;
+ default: return null;
+ }
+}
+
+export function dustCost(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ case 'DETERMINATION': return null;
+ case 'LEGENDARY': return s ? 3200 : 1600;
+ case 'EPIC': return s ? 1600 : 400;
+ case 'RARE': return s ? 800 : 100;
+ case 'COMMON': return s ? 400 : 40;
+ case 'BASE': return s ? 400 : null;
+ default: return null;
+ }
+}
+
+export function dustGain(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ case 'TOKEN':
+ case 'GENERATED': // You can't craft this, but I don't want an error
+ case 'DETERMINATION': return null;
+ case 'LEGENDARY': return s ? 1600 : 400;
+ case 'EPIC': return s ? 400 : 100;
+ case 'RARE': return s ? 100 : 20;
+ case 'COMMON': return s ? 40 : 5;
+ case 'BASE': return s ? 40 : null;
+ default: {
+ debug(`Unknown Rarity: ${r}`);
+ return null;
+ }
+ }
+}
+
+export function getCardData(id) {
+ const cards = global('allCards').filter((card) => card.id === parseInt(id, 10));
+ if (cards.length) return cards[0];
+ throw new Error(`Unknown card ${id}`);
+}
+
+export function cardName(card, fallback = card.name) {
+ return translateText(`card-name-${card.fixedId || card.id}`, {
+ args: [1],
+ fallback,
+ });
+}
+
+api.mod.utils.rarity = Object.freeze({
+ max(cardRarity = '') {
+ if (!cardRarity) throw new Error('Rarity required!');
+ return max(cardRarity);
+ },
+ cost(cardRarity = '', shiny = false) {
+ if (!cardRarity) throw new Error('Rarity required!');
+ return dustCost(cardRarity, shiny);
+ },
+ dust(cardRarity = '', shiny = false) {
+ if (!cardRarity) throw new Error('Rarity required!');
+ return dustGain(cardRarity, shiny);
+ },
+});
diff --git a/src/utils/charCount.js b/src/utils/charCount.js
new file mode 100644
index 00000000..f3242010
--- /dev/null
+++ b/src/utils/charCount.js
@@ -0,0 +1,5 @@
+export default function charCount(string = '', char = '') {
+ const regex = new RegExp(char, 'g');
+ const matches = string.match(regex);
+ return matches?.length ?? 0;
+}
diff --git a/src/utils/cleanData.js b/src/utils/cleanData.js
new file mode 100644
index 00000000..7750f072
--- /dev/null
+++ b/src/utils/cleanData.js
@@ -0,0 +1,8 @@
+export default function cleanData(prefix, ...except) {
+ for (let i = localStorage.length; i > 0; i--) {
+ const key = localStorage.key(i - 1);
+ if (key.startsWith(prefix) && !except.includes(key) && !except.includes(key.substring(prefix.length))) {
+ localStorage.removeItem(key);
+ }
+ }
+}
diff --git a/src/utils/clear.js b/src/utils/clear.js
new file mode 100644
index 00000000..b32bfe8f
--- /dev/null
+++ b/src/utils/clear.js
@@ -0,0 +1 @@
+export default (obj) => Object.keys(obj).forEach((key) => delete obj[key]);
diff --git a/src/utils/clone.js b/src/utils/clone.js
new file mode 100644
index 00000000..aee0face
--- /dev/null
+++ b/src/utils/clone.js
@@ -0,0 +1,9 @@
+export default function clone(obj) {
+ if (Array.isArray(obj)) {
+ return [...obj];
+ }
+ if (typeof obj === 'object') {
+ return { ...obj };
+ }
+ return obj;
+}
diff --git a/src/utils/compoundEvent.js b/src/utils/compoundEvent.js
new file mode 100644
index 00000000..6dcb5658
--- /dev/null
+++ b/src/utils/compoundEvent.js
@@ -0,0 +1,36 @@
+import eventManager from './eventManager.js';
+import wrap from './2.pokemon.js';
+
+export default function compound(...events) {
+ const callback = events.pop();
+ if (typeof callback !== 'function') throw new Error('Callback not provided');
+ const cache = {};
+ let triggered = 0;
+ // TODO: cache data
+ function trigger(event, ...data) {
+ if (!cache[event].triggered) {
+ cache[event].triggered = this.singleton ? 'singleton' : true;
+ triggered += 1;
+ }
+
+ if (triggered >= events.length) {
+ events.forEach((ev) => {
+ const e = cache[ev];
+ // Only reset if not singleton
+ if (e.triggered !== true) return;
+ e.triggered = false;
+ triggered -= 1;
+ });
+ wrap(callback);
+ }
+ }
+
+ events.forEach((ev) => {
+ cache[ev] = {
+ triggered: false,
+ };
+ eventManager.on(ev, function wrapper(...data) {
+ trigger.call(this, ev, ...data);
+ });
+ });
+}
diff --git a/src/utils/debug.js b/src/utils/debug.js
new file mode 100644
index 00000000..b8f97f90
--- /dev/null
+++ b/src/utils/debug.js
@@ -0,0 +1,10 @@
+export function debug(message, permission = 'debugging', ...extras) {
+ if (!value(permission) && !value('debugging.*')) return;
+ // message.stack = new Error().stack.split('\n').slice(2);
+ console.debug(`[${permission}]`, message, ...extras);
+}
+
+export function value(key) {
+ const val = localStorage.getItem(key);
+ return val === '1' || val === 'true';
+}
diff --git a/src/utils/debugToast.js b/src/utils/debugToast.js
new file mode 100644
index 00000000..b6c65273
--- /dev/null
+++ b/src/utils/debugToast.js
@@ -0,0 +1,23 @@
+import { toast } from './2.toasts.js';
+import merge from './merge.js';
+import { value } from './debug.js';
+
+export default function debugToast(arg, permission = 'debugging') {
+ if (!value(permission) && !value('debugging.*')) return false;
+ if (typeof arg === 'string') {
+ arg = {
+ text: arg,
+ };
+ }
+ const defaults = {
+ background: '#c8354e',
+ textShadow: '#e74c3c 1px 2px 1px',
+ css: { 'font-family': 'inherit' },
+ button: {
+ // Don't use buttons, mouseOver sucks
+ background: '#e25353',
+ textShadow: '#46231f 0px 0px 3px',
+ },
+ };
+ return toast(merge(defaults, arg));
+}
diff --git a/src/utils/decode.js b/src/utils/decode.js
new file mode 100644
index 00000000..865478b4
--- /dev/null
+++ b/src/utils/decode.js
@@ -0,0 +1,3 @@
+export default function decode(string) {
+ return $('