').hover(hover.show('Average gold cost'));
+ $('#soulInfo span').after('Passive ', avg).remove();
+
+ function round(amt, dec = 2) {
+ return Number.parseFloat(amt).toFixed(dec);
+ }
+
+ function count() {
+ let val = 0;
+ const list = global('decks')[global('soul')];
+ list.forEach(({ cost }) => val += cost); // eslint-disable-line no-return-assign
+ avg.text(`(${round(list.length ? val / list.length : val)})`);
+ }
+
+ eventManager.on('Deck:Soul Deck:Change Deck:Loaded', count);
+ });
+});
diff --git a/src/base/deck/pageShortcuts.js b/src/base/deck/pageShortcuts.js
new file mode 100644
index 00000000..6b6e76b9
--- /dev/null
+++ b/src/base/deck/pageShortcuts.js
@@ -0,0 +1,36 @@
+wrap(function pageShortcuts() {
+ const disable = settings.register({
+ name: 'Disable First/Last Page Shortcut',
+ key: 'underscript.disable.quickpages',
+ page: 'Library',
+ });
+
+ if (!onPage('Crafting') && !onPage('Decks')) return;
+
+ 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();
+ global('showPage')(0);
+ $('#btnPrevious').prop('disabled', true);
+ $('#btnNext').prop('disabled', false);
+ }
+ function lastPage(e) {
+ if (ignoring(e)) return;
+ e.preventDefault();
+ hover.hide();
+ global('showPage')(global('getMaxPage')());
+ $('#btnNext').prop('disabled', true);
+ $('#btnPrevious').prop('disabled', false);
+ }
+
+ eventManager.on('jQuery', () => {
+ $('#btnNext').on('click.script', lastPage).hover(hover.show('CTRL Click: Go to last page'));
+ $('#btnPrevious').on('click.script', firstPage).hover(hover.show('CTRL Click: Go to first page'));
+ });
+});
diff --git a/src/base/deck/preview.js b/src/base/deck/preview.js
new file mode 100644
index 00000000..b61d16c8
--- /dev/null
+++ b/src/base/deck/preview.js
@@ -0,0 +1,18 @@
+settings.register({
+ name: 'Disable Deck Preview',
+ key: 'underscript.disable.deckPreview',
+ // hidden: typeof displayCardDeck === 'function',
+ onChange(val, val2) {
+ if (!onPage('Decks') || typeof cardHoverEnabled === 'undefined') return;
+ globalSet('cardHoverEnabled', !val);
+ },
+ page: 'Library',
+});
+
+onPage('Decks', () => {
+ eventManager.on(':loaded', function previewLoaded() {
+ if (typeof displayCardDeck === 'function') {
+ globalSet('cardHoverEnabled', !settings.value('underscript.disable.deckPreview'));
+ }
+ });
+});
diff --git a/src/base/deck/scrollwheel.js b/src/base/deck/scrollwheel.js
new file mode 100644
index 00000000..54ea6e1f
--- /dev/null
+++ b/src/base/deck/scrollwheel.js
@@ -0,0 +1,17 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Scrolling Collection Pages Hotkey (mousewheel)',
+ key: 'underscript.disable.scrolling',
+ refresh: onPage('Decks') || onPage('Crafting'),
+ page: 'Library',
+ });
+
+ if (onPage('Decks') || onPage('Crafting')) {
+ eventManager.on(':loaded', function scrollwheelLoaded() {
+ globalSet('onload', function onload() {
+ this.super && this.super();
+ if (setting.value()) $('#collection').off('mousewheel DOMMouseScroll');
+ });
+ });
+ }
+});
diff --git a/src/base/deck/storage.js b/src/base/deck/storage.js
new file mode 100644
index 00000000..426428c8
--- /dev/null
+++ b/src/base/deck/storage.js
@@ -0,0 +1,367 @@
+settings.register({
+ name: 'Disable Deck Storage',
+ key: 'underscript.storage.disable',
+ refresh: () => onPage('Decks'),
+ page: 'Library',
+});
+
+settings.register({
+ name: 'Deck Storage Rows',
+ key: 'underscript.storage.rows',
+ type: 'select',
+ options: ['1', '2', '3', '4', '5', '6'],
+ refresh: () => onPage('Decks'),
+ extraPrefix: 'underscript.deck.',
+ page: 'Library',
+});
+
+onPage('Decks', function deckStorage() {
+ if (settings.value('underscript.storage.disable')) return;
+ const mockLastOpenedDialog = {
+ close() {},
+ };
+ let templastOpenedDialog;
+ style.add('.btn-storage { margin-top: 5px; margin-right: 8px; width: 36px; }');
+
+ function getFromLibrary(id, shiny, library) {
+ return library.find((card) => card.id === id && (shiny === undefined || card.shiny === shiny));
+ }
+ function getCardData(id, shiny, deep) {
+ const library = global('deckCollections', 'collections');
+ if (deep) {
+ // Search all decks
+ const keys = Object.keys(library);
+ for (let i = 0; i < keys.length; i++) {
+ const card = getFromLibrary(id, shiny, library[keys[i]]);
+ if (card) return card;
+ }
+ return null;
+ }
+ return getFromLibrary(id, shiny, library[global('soul')]);
+ }
+
+ eventManager.on('jQuery', () => {
+ const container = $('');
+ const buttons = [];
+ let loading;
+ let pending = [];
+
+ function processNext() {
+ if (global('lastOpenedDialog') === mockLastOpenedDialog) {
+ globalSet('lastOpenedDialog', templastOpenedDialog);
+ }
+ const job = pending.shift();
+ if (job) {
+ if (job.action === 'validate') {
+ loadDeck(loading);
+ if (!pending.length) {
+ loading = null;
+ }
+ return;
+ }
+ if (job.action === 'clear') {
+ global('removeAllCards')();
+ } else if (job.action === 'remove') {
+ global('removeCard')(parseInt(job.id, 10), job.shiny === true);
+ } else if (job.action === 'clearArtifacts') {
+ global('clearArtifacts')();
+ } else if (job.action === 'addArtifact') {
+ debug(`Adding artifact: ${job.id}`);
+ templastOpenedDialog = global('lastOpenedDialog');
+ globalSet('lastOpenedDialog', mockLastOpenedDialog);
+ global('addArtifact')(job.id);
+ } else {
+ global('addCard')(parseInt(job.id, 10), job.shiny === true);
+ }
+ if (!pending.length) {
+ pending.push({ action: 'validate' });
+ }
+ }
+ }
+
+ eventManager.on('Deck:postChange', ({ data }) => {
+ if (!['addCard', 'removeCard', 'removeAllCards', 'clearArtifacts', 'addArtifact'].includes(data.action)) return;
+ if (data.status === 'error') {
+ pending = [];
+ return;
+ }
+ processNext();
+ });
+
+ for (let i = 1, x = Math.max(parseInt(settings.value('underscript.storage.rows'), 10), 1) * 4; i <= x; i++) {
+ buttons.push($('')
+ .text(i)
+ .addClass('btn btn-sm btn-danger btn-storage'));
+ }
+
+ buttons.forEach((button) => {
+ container.append(button);
+ });
+
+ const clearDeck = $('#yourCardList > button:last');
+ clearDeck.after(container);
+ $('#yourCardList > br').remove();
+ $('#yourCardList').css('margin-bottom', '35px');
+
+ eventManager.on('Deck:Soul', loadStorage);
+
+ function fixDeck(id) {
+ const key = getKey(id);
+ const deck = JSON.parse(localStorage.getItem(key));
+ if (!deck) return;
+ if (!Object.prototype.hasOwnProperty.call(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;
+ debug('loading');
+ pending = []; // Clear pending
+ loading = i;
+ let deck = getDeck(i, true);
+ const cDeck = global('decks')[global('soul')];
+
+ if (cDeck.length) {
+ const builtDeck = [];
+ // Build deck options
+ cDeck.forEach(({ id, shiny }) => {
+ builtDeck.push({
+ id,
+ shiny,
+ action: 'remove',
+ });
+ });
+
+ // Compare the decks
+ const temp = deck.slice(0);
+ const removals = builtDeck.filter((card) => !temp.some((card2, ind) => {
+ const found = card2.id === card.id && (card.shiny && card2.shiny || true);
+ if (found) { // Remove the item
+ temp.splice(ind, 1);
+ }
+ return found;
+ }));
+
+ // Check what we need to do
+ if (!removals.length && !temp.length) { // There's nothing
+ debug('Finished');
+ deck = [];
+ } else if (removals.length > 13) { // Too much to do (Cards in deck + 1)
+ pending.push({
+ action: 'clear',
+ });
+ } else {
+ pending.push(...removals);
+ deck = temp;
+ }
+ }
+ pending.push(...deck);
+ if (!matchingArtifacts(i)) {
+ debug('Loading Artifacts');
+ pending.push({
+ action: 'clearArtifacts',
+ });
+ getArtifacts(i).forEach((id) => {
+ pending.push({
+ id,
+ action: 'addArtifact',
+ });
+ });
+ }
+ processNext();
+ }
+
+
+ function matchingArtifacts(id) {
+ const dArts = getArtifacts(id);
+ const cArts = global('decksArtifacts')[global('soul')];
+ return !dArts.length || dArts.length === cArts.length && cArts.every(({ id: id1 }) => !!~dArts.indexOf(id1));
+ }
+
+ 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) {
+ return deck.cards.filter(({ id, shiny }) => getCardData(id, shiny) !== null);
+ }
+ 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) => {
+ let data = getCardData(card.id, card.shiny) || {};
+ const name = data.name || `${(data = getCardData(card.id) && data && data.name) || 'Disenchanted/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) {
+ list.push(` ${artifact.name} `);
+ }
+ });
+ return list.join(', ');
+ }
+
+ function loadStorage() {
+ buttons.forEach((b, i) => loadButton(i));
+ }
+
+ function loadButton(i) {
+ const soul = global('soul');
+ const deckKey = getKey(i);
+ const nameKey = `${deckKey}.name`;
+ const button = buttons[i];
+ button.off('.deckStorage'); // Remove any lingering events
+ function refreshHover() {
+ hover.hide();
+ button.trigger('mouseenter');
+ }
+ function saveButton() {
+ saveDeck(i);
+ loadButton(i); // I should be able to reset data without doing all this again...
+ refreshHover();
+ }
+ function hoverButton(e) {
+ let text = '';
+ if (e.type === 'mouseenter') {
+ const deck = getDeck(i);
+ if (deck) {
+ text = `
+ ${localStorage.getItem(nameKey) || (`${soul}-${i + 1}`)}
+
+ ${artifacts(i)}
+ Click to load (${deck.length})
+ ${cards(deck)}
+
+ * Right Click to name deck
+ * Shift Click to re-save deck
+ * CTRL Click to erase deck
+
+ `;
+ } else {
+ text = `
+ ${soul}-${i + 1}
+ Click to save current deck
`;
+ }
+ }
+ hover.show(text)(e);
+ }
+ if (!localStorage.getItem(deckKey)) {
+ button.addClass('btn-danger')
+ .removeClass('btn-primary')
+ .hover(hoverButton)
+ .one('click.script.deckStorage', saveButton);
+ } else {
+ button.removeClass('btn-danger')
+ .addClass('btn-primary')
+ .hover(hoverButton)
+ .on('click.script.deckStorage', (e) => {
+ if (e.ctrlKey && e.shiftKey) { // Crazy people...
+ return;
+ }
+ if (e.ctrlKey) { // ERASE
+ localStorage.removeItem(nameKey);
+ localStorage.removeItem(deckKey);
+ loadButton(i); // Reload :(
+ refreshHover(); // Update
+ } else if (e.shiftKey) { // Re-save
+ saveDeck(i); // Save
+ refreshHover(); // Update
+ } 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();
+ loadButton(i); // I really hate this
+ refreshHover();
+ }
+ display.hide();
+ input.show()
+ .focus()
+ .select()
+ // eslint-disable-next-line no-shadow
+ .on('keydown.script.deckStorage', (e) => {
+ if (e.which === 27 || e.which === 13) {
+ e.preventDefault();
+ storeInput();
+ }
+ })
+ .on('focusout.script.deckStorage', () => {
+ storeInput();
+ });
+ });
+ }
+ }
+
+ eventManager.on('Deck:Loaded', () => {
+ loadStorage();
+ });
+ clearDeck.on('click', () => {
+ pending = [];
+ });
+ });
+});
diff --git a/src/base/friends/add.js b/src/base/friends/add.js
new file mode 100644
index 00000000..db0903eb
--- /dev/null
+++ b/src/base/friends/add.js
@@ -0,0 +1,50 @@
+settings.register({
+ name: 'Add friends without refreshing',
+ key: 'underscript.friend.add',
+ default: true,
+ page: 'Friends',
+});
+
+onPage('Friends', function addFriend() {
+ const single = true;
+ const input = document.querySelector('input[name="username"]');
+
+ function submit() {
+ if (typeof URLSearchParams === 'undefined' || !settings.value('underscript.friend.add')) return undefined;
+ const name = input.value.trim();
+ if (name) {
+ input.value = '';
+ input.focus();
+ const params = new URLSearchParams();
+ params.append('username', name);
+ params.append('addFriend', 'Add friend');
+ axios.post('/Friends', params).then((results) => {
+ const page = new DOMParser().parseFromString(results.data, 'text/html').querySelector('div.mainContent');
+ const result = page.querySelector('form[action="Friends"] + p');
+ if (result) {
+ const success = result.classList.contains('green');
+ debug(result);
+ fn.toast({
+ text: `${success ? 'Sent' : 'Failed to send'} friend request to ${name}
`,
+ });
+ if (success) {
+ const element = $el.text.contains(decrypt(page).querySelectorAll('a[href^="Friends?delete="]'), `${name} LV`, { mutex, single });
+ $el.text.contains(document.querySelectorAll('p'), 'Pending requests', { single }).parentElement.append(element);
+ eventManager.emit('newElement', element);
+ }
+ }
+ });
+ }
+ return false;
+ }
+ function mutex(el) {
+ return el.parentElement;
+ }
+
+ input.addEventListener('keydown', (e) => {
+ if ((e.keyCode || e.which) === 13 || [e.code, e.key].contains('Enter')) {
+ if (submit() === false) e.preventDefault();
+ }
+ });
+ document.querySelector('form[action="Friends"]').onsubmit = submit;
+});
diff --git a/src/base/friends/autoDecline.js b/src/base/friends/autoDecline.js
new file mode 100644
index 00000000..54597f7f
--- /dev/null
+++ b/src/base/friends/autoDecline.js
@@ -0,0 +1,97 @@
+wrap(function autoDecline() {
+ const disabled = settings.register({
+ name: 'Disable',
+ key: 'userscript.autodecline.disable',
+ page: 'Friends',
+ category: 'Auto Decline',
+ });
+
+ const silent = settings.register({
+ name: 'Silent',
+ key: 'underscript.autodecline.silent',
+ default: true,
+ page: 'Friends',
+ category: 'Auto Decline',
+ });
+
+ const chat = settings.register({
+ name: 'Include ignored chat users',
+ key: 'underscript.autodecline.ignored',
+ default: true,
+ page: 'Friends',
+ category: 'Auto Decline',
+ });
+
+ // Load blocked users
+ fn.each(localStorage, (name, key) => {
+ if (!key.startsWith('underscript.autodecline.user.')) return;
+ register(key, name);
+ });
+
+ function register(key, name, set = false) {
+ settings.register({
+ key,
+ name,
+ type: 'remove',
+ page: 'Friends',
+ category: 'Auto Decline',
+ });
+ if (set) {
+ localStorage.setItem(key, name);
+ }
+ }
+
+ function post(id, name) {
+ axios.get(`/Friends?delete=${id}`).then(() => {
+ if (!name) return;
+ const message = `Auto declined friend request from: ${name}`;
+ debug(message);
+ if (!silent.value()) {
+ fn.toast(message);
+ }
+ }).catch(console.error);
+ }
+
+ function isBlocked(id) {
+ return !!settings.value(`underscript.autodecline.user.${id}`);
+ }
+
+ function isIgnored(id) {
+ return chat.value() && !!settings.value(`underscript.ignore.${id}`);
+ }
+
+ eventManager.on('preFriends:requests', function filter(requests) {
+ if (disabled.value()) return;
+ fn.each(requests, (name, id) => {
+ if (isBlocked(id) || isIgnored(id)) {
+ debug(`Blocking ${name}[${id}]`);
+ delete requests[id];
+ post(id, name);
+ }
+ });
+ });
+
+ // Add a way to block users
+ onPage('Friends', function blockRequests() {
+ eventManager.on('jQuery', () => {
+ const block = $('').css({
+ 'font-size': '14px',
+ }).text('block');
+ $('p:contains("Friend requests")').parent().find('li').each(function elements() {
+ const el = $(this);
+ const name = el.text().substring(0, el.text().lastIndexOf(' LV '));
+ el.append(' ', block.clone().click(function onClick() {
+ hover.hide();
+ const link = el.find('a:first').attr('href');
+ const id = link.substring(link.indexOf('=') + 1);
+
+ register(`underscript.autodecline.user.${id}`, name, true);
+ post(id);
+ el.find('a[href^="Friends?"]').remove();
+ el.addClass('deleted');
+ $(this).remove();
+ }).hover(hover.show(`Block ${el.text().substring(0, name)}`)));
+ });
+ });
+ });
+});
diff --git a/src/base/friends/delete.js b/src/base/friends/delete.js
new file mode 100644
index 00000000..5b9cac8a
--- /dev/null
+++ b/src/base/friends/delete.js
@@ -0,0 +1,51 @@
+settings.register({
+ name: 'Remove friends without refreshing',
+ key: 'underscript.removeFriend.background',
+ default: true,
+ page: 'Friends',
+});
+
+onPage('Friends', function deleteFriends() {
+ let reminded = false;
+ style.add('.deleted { text-decoration: line-through; }');
+
+ function remove(e) {
+ if (!settings.value('underscript.removeFriend.background')) return;
+ e.preventDefault();
+ process($(this));
+ }
+
+ function process(btn) {
+ const parent = btn.parent();
+ btn.detach();
+ const link = btn.attr('href');
+ axios.get(link).then((response) => {
+ const onlineFriends = $(response.data).find(`#onlineFriends`);
+ if (!onlineFriends.length) {
+ fn.errorToast('Try logging back in');
+ return;
+ }
+ const found = fn.decrypt(onlineFriends).find(`a[href="${link}"]`);
+ if (found.length) {
+ fn.toast(`Failed to remove: ${found.parent().find('span:nth-child(3)').text()}`);
+ btn.appendTo(parent);
+ } else {
+ if (!reminded) {
+ fn.toast({
+ title: 'Please note:',
+ text: 'Friends list will be updated upon refresh.',
+ });
+ reminded = true;
+ }
+ parent.addClass('deleted');
+ }
+ }).catch((e) => {
+ fn.debug(`DeleteFriend: ${e}`);
+ });
+ }
+
+ eventManager.on('Chat:getOnlineFriends', () => $('a.crossDelete').click(remove));
+ eventManager.on(':loaded', () => $('a[href^="Friends?"]').click(remove));
+ eventManager.on('newElement', (e) => $(e).find('a').click(remove));
+ eventManager.on('friendAction', process);
+});
diff --git a/src/base/friends/groupRequest.js b/src/base/friends/groupRequest.js
new file mode 100644
index 00000000..fc264813
--- /dev/null
+++ b/src/base/friends/groupRequest.js
@@ -0,0 +1,22 @@
+settings.register({
+ name: 'Disable decline all button',
+ key: 'underscript.disable.declineAll',
+ refresh: true,
+ page: 'Friends',
+});
+
+onPage('Friends', function groupButtons() {
+ eventManager.on('jQuery', () => {
+ if (settings.value('underscript.disable.declineAll')) return;
+ const declineAll = $('');
+ const container = $('p:contains("Friend requests")').append(' ', declineAll).parent();
+ declineAll.text(' ').addClass('glyphicon glyphicon-remove red').css({
+ cursor: 'pointer',
+ }).hover(hover.show('Decline all'))
+ .click(() => {
+ container.find('a[href^="Friends?delete="]').each(function declineFriend() {
+ eventManager.emit('friendAction', $(this));
+ });
+ });
+ });
+});
diff --git a/src/base/friends/online.js b/src/base/friends/online.js
new file mode 100644
index 00000000..fcbf4a2d
--- /dev/null
+++ b/src/base/friends/online.js
@@ -0,0 +1,36 @@
+settings.register({
+ name: 'Enable online friends',
+ key: 'underscript.enable.onlinefriends',
+ default: true,
+ page: 'Friends',
+});
+eventManager.on(':loaded', () => {
+ const px = 12;
+ style.add(
+ '.tippy-tooltip.undercards-theme { background-color: rgba(0,0,0,0.9); font-size: 13px; border: 1px solid #fff; }',
+ `.tippy-popper[x-placement^='top'] .tippy-tooltip.undercards-theme .tippy-arrow { border-top-color: #fff; bottom: -${px}px; }`,
+ `.tippy-popper[x-placement^='bottom'] .tippy-tooltip.undercards-theme .tippy-arrow { border-bottom-color: #fff; top: -${px}px; }`,
+ `.tippy-popper[x-placement^='left'] .tippy-tooltip.undercards-theme .tippy-arrow { border-left-color: #fff; right: -${px}px; }`,
+ `.tippy-popper[x-placement^='right'] .tippy-tooltip.undercards-theme .tippy-arrow { border-right-color: #fff; left: -${px}px; }`,
+ );
+
+ const el = document.querySelector('a span.nbFriends');
+ if (!el) return;
+ const target = el.parentElement;
+ hover.new('(Loading)
', target, {
+ arrow: true,
+ distance: 0,
+ follow: false,
+ offset: null,
+ footer: 'short',
+ placement: 'top-start',
+ onShow: () => settings.value('underscript.enable.onlinefriends'),
+ });
+
+ function updateTip() {
+ // eslint-disable-next-line no-underscore-dangle
+ target._tippy.popper.querySelector('.onlineFriends').innerHTML = global('selfFriends').filter(({ online }) => online).map((user) => fn.user.name(user)).join(' ') || 'None';
+ }
+ eventManager.on('Chat:getSelfInfos', updateTip);
+ this.updateTip = updateTip;
+});
diff --git a/src/base/friends/requests.js b/src/base/friends/requests.js
new file mode 100644
index 00000000..1766f541
--- /dev/null
+++ b/src/base/friends/requests.js
@@ -0,0 +1,43 @@
+eventManager.on('Friends:requests', (friends) => {
+ // id: name
+ function post(id, accept = true) {
+ const action = accept ? 'accept' : 'delete';
+ axios.get(`/Friends?${action}=${id}`).then(() => {
+ const key = `underscript.request.${id}`;
+ const name = sessionStorage.getItem(key);
+ sessionStorage.removeItem(key);
+ fn.toast(`${accept ? 'Accepted' : 'Declined'} friend request from: ${name}`);
+ }).catch(noop());
+ }
+ const newRequests = [];
+ fn.each(friends, (friend, id) => {
+ const key = `underscript.request.${id}`;
+ if (sessionStorage.getItem(key)) return;
+ const css = {
+ background: 'inherit',
+ }; // I need to add a way to clear all styles
+ fn.toast({
+ title: `Pending Friend Request`,
+ text: friend,
+ buttons: [{
+ css,
+ text: ' ',
+ className: 'glyphicon glyphicon-ok green',
+ onclick: post.bind(null, id),
+ }, {
+ css,
+ text: ' ',
+ className: 'glyphicon glyphicon-remove red',
+ onclick: post.bind(null, id, false),
+ }],
+ });
+ sessionStorage.setItem(key, friend);
+ });
+});
+eventManager.on('logout', () => {
+ Object.keys(sessionStorage).forEach((key) => {
+ if (key.startsWith('underscript.request.')) {
+ sessionStorage.removeItem(key);
+ }
+ });
+});
diff --git a/src/base/friends/updateList.js b/src/base/friends/updateList.js
new file mode 100644
index 00000000..df9760ba
--- /dev/null
+++ b/src/base/friends/updateList.js
@@ -0,0 +1,30 @@
+eventManager.on('ChatDetected', () => {
+ let updatingFriends = false;
+ eventManager.on('Friends:refresh', () => {
+ const socketChat = global('socketChat');
+ if (socketChat.readyState !== 1) return;
+ updatingFriends = true;
+ socketChat.send(JSON.stringify({ action: 'getOnlineFriends' }));
+ });
+ eventManager.on('preChat:getOnlineFriends', function updateFriends(data) {
+ if (!updatingFriends) return;
+ updatingFriends = false;
+ this.canceled = true;
+ const friends = {};
+ JSON.parse(data.friends).forEach((friend) => {
+ friends[friend.id] = friend;
+ });
+ const selfFriends = global('selfFriends');
+ selfFriends.forEach((friend) => {
+ // id, online, idGame, username
+ const id = friend.id;
+ const now = friends[id];
+ delete friends[id];
+ if (now && friend.online !== now.online) {
+ friend.online = now.online;
+ }
+ });
+ $('.nbFriends').text(selfFriends.filter((friend) => friend.online).length);
+ script.updateTip && script.updateTip();
+ });
+});
diff --git a/src/base/friendship/collect.js b/src/base/friendship/collect.js
new file mode 100644
index 00000000..e99bd334
--- /dev/null
+++ b/src/base/friendship/collect.js
@@ -0,0 +1,86 @@
+wrap(() => {
+ onPage('Friendship', () => {
+ const maxClaim = 200 / 5; // Current level limit, no way to dynamically figure this out if he ever adds more rewards
+ let button;
+ let collecting = false;
+ const rewards = {};
+ let pending = 0;
+
+ function canClaim({ notClaimed, claim }) {
+ return notClaimed && claim < maxClaim;
+ }
+
+ function canCollect() {
+ return fn.some(global('friendshipItems'), canClaim);
+ }
+
+ function claimReward(data) {
+ if (canClaim(data)) {
+ global('claim')(data.idCard);
+ pending += 1;
+ }
+ }
+
+ function collect() {
+ if (!canCollect() || collecting) return;
+ collecting = true;
+ pending = 0;
+ fn.clear(rewards);
+ fn.each(global('friendshipItems'), claimReward);
+ }
+
+ function getLabel(type = '') {
+ switch (type) {
+ case 'GOLD': return ' ';
+ case 'DUST': return ' ';
+ case 'PACK': return ' ';
+ case 'DR_PACK': return ' ';
+ default: return type.toLowerCase();
+ }
+ }
+
+ eventManager.on('Friendship:loaded', () => {
+ button.prop('disabled', !canCollect());
+ });
+
+ eventManager.on('Friendship:claim', ({
+ data, reward, quantity, error,
+ }) => {
+ if (!pending || !collecting) return;
+ pending -= 1;
+
+ if (!error) {
+ rewards[reward] = (rewards[reward] || 0) + quantity;
+
+ // Claim again if necessary
+ claimReward(data);
+ }
+
+ if (pending) return;
+
+ eventManager.emit('Friendship:results', error);
+ });
+
+ eventManager.on('Friendship:results', (error) => {
+ const lines = [];
+ fn.each(rewards, (count, type) => {
+ lines.push(`- ${count} ${getLabel(type)}`);
+ });
+ const toast = error ? fn.errorToast : fn.toast;
+ toast({
+ title: 'Claimed Friendship Rewards',
+ text: lines.join(' '),
+ });
+ button.prop('disabled', !canCollect());
+ collecting = false;
+ });
+
+ eventManager.on(':loaded', () => {
+ button = $('Collect All ');
+ button.prop('disabled', true);
+ button.on('click.script', collect);
+ button.hover(hover.show('Collect all rewards'));
+ $('p[data-i18n="[html]crafting-all-cards"]').css('display', 'inline-block').after(' ', button);
+ });
+ });
+});
diff --git a/src/base/friendship/magic.js b/src/base/friendship/magic.js
new file mode 100644
index 00000000..c4d3c559
--- /dev/null
+++ b/src/base/friendship/magic.js
@@ -0,0 +1,44 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable friendship notification',
+ key: 'underscript.disable.friendship.notification',
+ });
+
+ const max = 200 / 5; // Limit level 200
+
+ function getFriendship() {
+ if (setting.value()) return;
+ axios.get('/FriendshipConfig').then((resp) => {
+ const items = JSON.parse(resp.data.friendshipItems)
+ .filter((item) => {
+ const lvl = fn.getLevel(item.xp);
+ return lvl > 0 && item.claim < Math.min(Math.floor(lvl / 5), max);
+ }).map((item) => $.i18n(`card-name-${item.idCard}`, 1));
+
+ if (!items.length) return;
+
+ fn.toast({
+ title: 'Pending Friendship Rewards',
+ text: `- ${items.join('\n- ')}`,
+ className: 'dismissable',
+ buttons: {
+ text: 'Go now!',
+ className: 'dismiss',
+ css: {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+ },
+ onclick: (e) => {
+ location.href = '/Friendship';
+ },
+ },
+ });
+ });
+ }
+
+ eventManager.on('getVictory getDefeat', getFriendship);
+});
diff --git a/src/base/game/@opponent.js b/src/base/game/@opponent.js
new file mode 100644
index 00000000..17524c2e
--- /dev/null
+++ b/src/base/game/@opponent.js
@@ -0,0 +1,14 @@
+onPage('Game', () => {
+ const regex = /(^| )@o\b/gi;
+ let toast = fn.infoToast('You can mention opponents with @o', 'underscript.notice.mention', '1');
+
+ function convert({ input }) {
+ if (toast) {
+ toast.close('processed');
+ toast = null;
+ }
+ $(input).val($(input).val()
+ .replace(regex, `$1@${$('#enemyUsername').text()}`));
+ }
+ eventManager.on('Chat:send', convert);
+});
diff --git a/src/base/game/battleLog.js b/src/base/game/battleLog.js
new file mode 100644
index 00000000..d13612b5
--- /dev/null
+++ b/src/base/game/battleLog.js
@@ -0,0 +1,413 @@
+/* eslint-disable no-use-before-define */
+settings.register({
+ name: 'Disable Battle Log',
+ key: 'underscript.disable.logger',
+ page: 'Game',
+ onChange: (to, from) => {
+ if (!onPage('Game') && !onPage('Spectate')) return;
+ if (to) {
+ $('#history').hide();
+ } else {
+ $('#history').show();
+ }
+ },
+});
+
+settings.register({
+ name: 'Hide Dust Counter',
+ key: 'underscript.disable.dust',
+ type: 'select',
+ default: 'always',
+ options: ['never', 'playing', 'spectating', 'always'],
+ page: 'Game',
+});
+
+settings.register({
+ name: 'Dust Counter Location',
+ key: 'underscript.dust.location',
+ type: 'select',
+ options: [],
+ disabled: true,
+ hidden: true,
+ page: 'Game',
+});
+
+eventManager.on('GameStart', function battleLogger() {
+ const ignoreEvents = Object.keys({
+ getEmote: 'Player is using emote',
+ getConnectedFirst: '',
+ refreshTimer: 'Never need to know about this one',
+ getPlayableCards: 'On turn start, selects cards player can play',
+ getTurn: 'Turn update',
+ getCardDrawed: 'Add card to your hand',
+ updateSpell: '',
+ getFakeDeath: 'Card "died" and respawns 1 second later',
+ getMonsterTemp: 'You\'re about to play a monster',
+ getSpellTemp: 'You\'re about to play a spell',
+ getTempCancel: 'Temp card cancelled',
+ getShowMulligan: 'Switching out hands, ignore it',
+ getHideMulligan: 'Hide the mulligan, gets called twice',
+ getUpdateHand: 'Updates full hand',
+ getError: 'Takes you to "home" on errors, can be turned into a toast',
+ getGameError: 'Takes you to "play" on game errors, can be turned into a toast',
+ getBattleLog: 'In-game battle log',
+ getBotDelay: '...',
+ clearSpell: '',
+ getPlaySound: '',
+ getAnimation: '',
+ });
+ const turnText = '>>> Turn';
+ let baseLives = 1;
+ let turn = 0;
+ let currentTurn = 0;
+ const players = {};
+ let monsters = {};
+ let lastEffect;
+ const other = {};
+ let yourDust;
+ let enemyDust;
+ let lastSP;
+ function addDust(player) {
+ if (!player || !players[player]) return;
+ const display = player === global('userId') ? yourDust : enemyDust;
+ const dust = typeof players[player].dust === 'undefined' ? players[player].dust = 0 : players[player].dust += 1;
+ display.html(dust);
+ }
+ const make = {
+ player: function makePlayer(player, title = false) {
+ const c = $('');
+ c.append(` `, ' ', fn.user.name(player));
+ c.addClass(player.class);
+ if (!title) {
+ c.css('text-decoration', 'underline');
+ // show lives, show health, show gold, show hand, possibly deck size as well
+ const data = `${player.hp} hp, ${player.gold} gold ${player.dust} dust`;
+ c.hover(hover.show(data, '2px solid white'));
+ }
+ return c;
+ },
+ card: function makeCard(card) {
+ const c = $('');
+ c.append(card.name);
+ c.css('text-decoration', 'underline');
+
+ const d = $('');
+ const appendCard = global('appendCard');
+ try {
+ appendCard(card, d);
+ } catch (e) { // if he ever decides to switch it again.......
+ appendCard(d, card);
+ }
+ c.hover(hover.show(d));
+ return c;
+ },
+ };
+
+ eventManager.on('connect', function initBattle(data) {
+ debug(data, 'debugging.raw.game');
+ const you = JSON.parse(data.you);
+ const enemy = JSON.parse(data.enemy);
+ // Set gold
+ const gold = JSON.parse(data.golds);
+ you.gold = gold[you.id];
+ enemy.gold = gold[enemy.id];
+ // populate monsters
+ JSON.parse(data.board).forEach((card, i) => {
+ if (card === null) return;
+ // id, attack, hp, maxHp, originalattack, originalHp, typeCard, name, image, cost, originalCost, rarity, shiny, quantity
+ card.owner = i < 4 ? enemy.id : you.id;
+ monsters[card.id] = card;
+ });
+ you.level = data.yourLevel;
+ you.class = data.yourSoul;
+ you.rank = data.yourRank;
+ enemy.level = data.enemyLevel;
+ enemy.class = data.enemySoul;
+ enemy.rank = data.enemyRank;
+ // yourArtifacts, yourAvatar {id, image, name, rarity, ucpCost}, division, oldDivision, profileSkin {id, name, image, ucpCost}
+ debug({ you, enemy }, 'debugging.game');
+ turn = data.turn || 0;
+ players[you.id] = you;
+ players[enemy.id] = enemy;
+ // Display Dust
+ const disableDust = settings.value('underscript.disable.dust');
+ yourDust = $('
');
+ enemyDust = $('');
+ if (disableDust === 'never' || (disableDust !== 'always' && disableDust !== (this.event === 'getAllGameInfos' ? 'spectating' : 'playing'))) {
+ const dustImg = $(' ');
+ $('.rightPart').append(dustImg, ' ');
+ $(`#user${global('opponentId')} .rightPart`).append(enemyDust, ' ');
+ const userId = global('userId');
+ $(`#user${userId} .rightPart`).append(yourDust, ' ', $(`#user${userId} .rightPart > button:last`));
+ }
+ // Set lives
+ if (data.lives) {
+ const lives = JSON.parse(data.lives);
+ you.lives = lives[you.id];
+ enemy.lives = lives[enemy.id];
+ } else {
+ baseLives = 0;
+ updateSoul({
+ idPlayer: you.id,
+ soul: you.soul,
+ });
+ updateSoul({
+ idPlayer: enemy.id,
+ soul: enemy.soul,
+ });
+ }
+ addDust(you.id);
+ addDust(enemy.id);
+ // Test changing ID's at endTurn instead of startTurn
+ other[you.id] = enemy.id;
+ other[enemy.id] = you.id;
+ // Initialize the log
+ log.init();
+ if (settings.value('underscript.disable.logger')) {
+ $('#history').hide();
+ }
+ $('div#history div.handle').html('').append(`[${data.gameType}] `, make.player(you), ' vs ', make.player(enemy));
+ log.add(`${turnText} ${turn}`);
+ if (data.userTurn) {
+ currentTurn = data.userTurn;
+ log.add(make.player(players[data.userTurn]), '\'s turn');
+ }
+ });
+ eventManager.on('getFight getFightPlayer', function fight(data) {
+ const target = this.event === 'getFightPlayer' ? make.player(players[data.defendPlayer]) : make.card(monsters[data.defendMonster]);
+ log.add(make.card(monsters[data.attackMonster]), ' attacked ', target);
+ });
+ eventManager.on('getUpdatePlayerHp', function updateHP(data) {
+ debug(data, 'debugging.raw.updateHP');
+ const oHp = players[data.playerId].hp;
+ const hp = data.isDamage ? oHp - data.hp : data.hp - oHp;
+ players[data.playerId].hp = data.hp;
+ if (oHp !== data.hp) { // If the player isn't at 0 hp already
+ log.add(make.player(players[data.playerId]), ` ${data.isDamage ? 'lost' : 'gained'} ${hp} hp`);
+ }
+ // eslint-disable-next-line no-prototype-builtins
+ if (data.hp === 0 && players[data.playerId].lives > baseLives && !players[data.playerId].hasOwnProperty('lostLife')) { // If they have extra lives, and they didn't lose a life already
+ log.add(make.player(players[data.playerId]), ' lost a life');
+ players[data.playerId].lostLife = true;
+ }
+ });
+ eventManager.on('getDoingEffect', function doEffect(data) {
+ debug(data, 'debugging.raw.effect');
+ if (data.card) {
+ const card = JSON.parse(data.card);
+ monsters[card.id] = card;
+ data.monsterId = card.id;
+ }
+ // affecteds: [ids]; monsters affected
+ // playerAffected1: id; player affected
+ // playerAffected2: id; player affected
+ // TODO: Figure out how to do this better
+ if (lastEffect === `m${data.monsterId}`) return;
+ lastEffect = `m${data.monsterId}`;
+ log.add(make.card(monsters[data.monsterId]), '\'s effect activated');
+ });
+ eventManager.on('getArtifactDoingEffect', function doEffect(data) {
+ debug(data, 'debugging.raw.effectArtifact');
+ if (lastEffect === `a${data.playerId}`) return;
+ lastEffect = `a${data.playerId}`;
+ log.add(make.player(players[data.playerId]), '\'s artifact activated');
+ });
+ eventManager.on('getSoulDoingEffect', function soulEffect(data) {
+ debug(data, 'debugging.raw.effectSoul');
+ if (lastEffect === `s${data.playerId}`) return;
+ lastEffect = `s${data.playerId}`;
+ log.add(make.player(players[data.playerId]), '\'s soul activated');
+ // affecteds
+ // playerAffected1
+ // playerAffected2
+ });
+ eventManager.on('getTurnStart', function turnStart(data) {
+ debug(data, 'debugging.raw.turnStart');
+ lastEffect = 0;
+ if (data.numTurn !== turn) {
+ log.add(`${turnText} ${data.numTurn}`);
+ }
+ currentTurn = data.idPlayer; // It would (kindof) help to actually update who's turn it is
+ turn = data.numTurn;
+ log.add(make.player(players[currentTurn]), '\'s turn');
+ });
+ eventManager.on('getTurnEnd', function turnEnd(data) {
+ debug(data, 'debugging.raw.turnEnd');
+ // Lets switch the turn NOW, rather than later, the purpose of this is currently unknown... It just sounded like a good idea, also delete the "lostLife" flag...
+ if (global('time') <= 0) {
+ log.add(make.player(players[currentTurn]), ' timed out');
+ }
+ delete players[currentTurn].lostLife;
+ currentTurn = other[data.idPlayer];
+ delete players[currentTurn].lostLife;
+ lastEffect = 0;
+ lastSP = 0;
+ });
+ eventManager.on('getUpdateBoard', function updateGame(data) {
+ debug(data, 'debugging.raw.boardUpdate');
+ // const oldMonsters = monsters;
+ monsters = {};
+ // TOOD: stuff....
+ JSON.parse(data.board).forEach((card, i) => {
+ if (card === null) return;
+ card.owner = global(i < 4 ? 'opponentId' : 'userId');
+ monsters[card.id] = card;
+ });
+ });
+ eventManager.on('updateMonster updateCard', function updateCard(data) {
+ data.monster = JSON.parse(data.monster || data.card);
+ debug(data, 'debugging.raw.updateMonster');
+ const card = data.monster;
+ monsters[card.id] = fn.merge(monsters[card.id], card);
+ });
+ eventManager.on('getMonsterDestroyed', function monsterKilled(data) {
+ debug(data, 'debugging.raw.kill');
+ // monsterId: #
+ log.add(make.card(monsters[data.monsterId]), ' was killed');
+ addDust(monsters[data.monsterId].owner);
+ delete monsters[data.monsterId];
+ });
+ eventManager.on('getCardBoard getMonsterPlayed', function playCard(data) { // Adds card to X, Y (0(enemy), 1(you))
+ debug(data, 'debugging.raw.boardAdd');
+ const card = JSON.parse(data.card);
+ card.owner = data.idPlayer;
+ monsters[card.id] = card;
+ log.add(make.player(players[data.idPlayer]), ' played ', make.card(card));
+ });
+ eventManager.on('getSpellPlayed', function useSpell(data) {
+ debug(data, 'debugging.raw.spell');
+ // immediately calls "getDoingEffect" and "getUpdateBoard"
+ const card = JSON.parse(data.card);
+ if (lastSP === card.id) return;
+ lastSP = card.id;
+ card.owner = data.idPlayer;
+ monsters[card.id] = card;
+ log.add(make.player(players[data.idPlayer]), ' used ', make.card(card));
+ });
+ eventManager.on('getShowCard', function showCard(data) {
+ const card = JSON.parse(data.card);
+ log.add(make.player(players[data.idPlayer]), ' exposed ', make.card(card));
+ });
+ eventManager.on('getCardDestroyedHandFull', function destroyCard(data) {
+ debug(data, 'debugging.raw.fullHand');
+ const card = JSON.parse(data.card);
+ debug(data.card, 'debugging.destroyed.card');
+ // This event gets called for *all* discards. Have to do smarter logic here (not just currentTurn!)
+ log.add(make.player(players[data.idPlayer || currentTurn]), ' discarded ', make.card(card));
+ });
+ eventManager.on('getPlayersStats', function updatePlayer(data) { // TODO: When does this get called?
+ debug(data, 'debugging.raw.stats');
+ let temp = JSON.parse(data.handsSize);
+ Object.keys(temp).forEach((key) => {
+ // TODO: hand size monitoring
+ // players[key].hand
+ });
+ // TODO: deck monitoring (decksSize)
+ temp = JSON.parse(data.golds);
+ Object.keys(temp).forEach((key) => {
+ players[key].gold = temp[key];
+ });
+ if (data.lives) {
+ temp = JSON.parse(data.lives);
+ Object.keys(temp).forEach((key) => {
+ players[key].lives = temp[key];
+ });
+ }
+ // data.artifcats
+ // data.turn
+ });
+ eventManager.on('getVictory getDefeat', function gameEnd(data) {
+ debug(data, 'debugging.raw.end');
+ const userId = global('userId');
+ const opponentId = global('opponentId');
+ const you = make.player(players[userId]);
+ const enemy = make.player(players[opponentId]);
+ if (this.event === 'getDefeat') {
+ log.add(enemy, ' beat ', you);
+ return;
+ }
+ if (data.disconnected) {
+ log.add(enemy.clone(), ' left the game.');
+ } else if (players[opponentId].hp > 0) {
+ log.add(enemy.clone(), ' surrendered.');
+ }
+ log.add(you, ' beat ', enemy);
+ });
+ eventManager.on('getResult', function endSpectating(data) {
+ debug(data, 'debugging.raw.end');
+ if (data.cause === 'game-end-surrender') {
+ log.add(`${data.looser} surrendered.`);
+ } else if (data.cause === 'game-end-disconnection') {
+ log.add(`${data.looser} left the game.`);
+ }
+ if (typeof music !== 'undefined') {
+ global('music').addEventListener('playing', () => {
+ if (localStorage.getItem('gameMusicDisabled')) {
+ global('music').pause();
+ }
+ });
+ }
+ // TODO: colorize
+ log.add(`${data.winner} beat ${data.looser}`);
+ });
+ eventManager.on(ignoreEvents.join(' '), function ignore(data) {
+ debug(data, 'debugging.raw.ignore');
+ debug(data, `debugging.raw.ignore.${this.event}`);
+ });
+ eventManager.on('getUpdateSoul', function blah(data) {
+ updateSoul({
+ idPlayer: data.idPlayer,
+ soul: JSON.parse(data.soul),
+ });
+ });
+
+ function updateSoul({ idPlayer, soul = {} }) {
+ const player = players[idPlayer];
+ player.lives = soul.lives || 0;
+ player.dodge = soul.dodge || 0;
+ }
+
+ const log = {
+ init() {
+ const hi = $('
');
+ const ha = $('History
');
+ const lo = $('
');
+ // Positional math -- not working anymore??
+ const mainContent = $('div.mainContent');
+ mainContent.css('position', 'initial');
+ const pos = parseInt(mainContent.css('width'), 10) + parseInt(mainContent.css('margin-left'), 10);
+ mainContent.css('position', '');
+ hi.css({
+ width: `${window.innerWidth - pos - 20}px`,
+ border: '2px solid white',
+ 'background-color': 'rgba(0,0,0,0.9)',
+ position: 'absolute',
+ right: 10,
+ top: 10,
+ 'z-index': 20,
+ });
+ ha.css({
+ 'border-bottom': '1px solid white',
+ 'text-align': 'center',
+ });
+ lo.css({
+ display: 'flex',
+ 'flex-direction': 'column-reverse',
+ 'align-items': 'flex-start',
+ 'overflow-y': 'auto',
+ 'max-height': '600px',
+ });
+ hi.append(ha);
+ hi.append(lo);
+ $('body').append(hi);
+ },
+ add(...args) {
+ const div = $('');
+ args.forEach((a) => {
+ div.append(a);
+ });
+ if (!div.html()) return;
+ $('div#history div#log').prepend(div);
+ },
+ };
+});
diff --git a/src/base/game/commands/gg.js b/src/base/game/commands/gg.js
new file mode 100644
index 00000000..4edc1714
--- /dev/null
+++ b/src/base/game/commands/gg.js
@@ -0,0 +1,21 @@
+eventManager.on('ChatDetected', function goodGame() {
+ const list = ['good game', 'gg', 'Good Game', 'Good game'];
+ const command = 'gg';
+ const setting = settings.register({
+ name: `Disable ${command} command`,
+ key: `underscript.command.${command}`,
+ note: `/${command}`,
+ page: 'Chat',
+ category: 'Commands',
+ });
+
+ if (!onPage('Game')) return;
+ eventManager.on('Chat:command', function ggCommand(data) {
+ if (this.canceled || data.command !== command || setting.value()) return;
+ if (typeof gameId === 'undefined') {
+ this.canceled = true; // Don't send text
+ return;
+ }
+ data.output = `@${$el.text.get(document.querySelector('#enemyUsername'))} ${list[fn.rand(list.length)]}`; // Change the output
+ });
+});
diff --git a/src/base/game/commands/spectate.js b/src/base/game/commands/spectate.js
new file mode 100644
index 00000000..9eda8c65
--- /dev/null
+++ b/src/base/game/commands/spectate.js
@@ -0,0 +1,29 @@
+wrap(function spectate() {
+ const setting = settings.register({
+ name: 'Disable spectate command',
+ key: 'underscript.command.spectate',
+ note: '/spectate [text (optional)]
Output:
You vs Enemy: url [text]',
+ page: 'Chat',
+ category: 'Commands',
+ });
+
+ let toast;
+ eventManager.on('Chat:command', function spectateCommand(data) {
+ if (this.canceled || data.command !== 'spectate' || setting.value()) return;
+ if (typeof gameId === 'undefined' || global('finish')) {
+ this.canceled = true;
+ return;
+ }
+ if (toast) toast.close();
+ data.output = `${$('#yourUsername').text()} vs ${$('#enemyUsername').text()}: ${location.origin}/Spectate?gameId=${global('gameId')}&playerId=${global('userId')}${data.text ? ` - ${data.text}` : ''}`;
+ });
+
+ eventManager.on('GameStart', () => {
+ toast = fn.infoToast({
+ text: 'You can send a spectate URL in chat by typing /specate',
+ onClose() {
+ toast = null;
+ },
+ }, 'underscript.notice.spectatecommand', '1');
+ });
+});
diff --git a/src/base/game/emotes.js b/src/base/game/emotes.js
new file mode 100644
index 00000000..b5fb8458
--- /dev/null
+++ b/src/base/game/emotes.js
@@ -0,0 +1,59 @@
+wrap(function emoteManager() {
+ // let live = false;
+ let self;
+ const spectating = onPage('Spectate');
+ const spectate = settings.register({
+ name: 'Show when spectating',
+ key: 'underscript.emote.spectate',
+ default: true,
+ page: 'Game',
+ category: 'Emotes',
+ });
+ const friends = settings.register({
+ name: 'Friends only',
+ key: 'underscript.emote.friends',
+ page: 'Game',
+ category: 'Emotes',
+ });
+ const enemy = settings.register({
+ name: 'Disable enemy',
+ key: 'underscript.emote.enemy',
+ page: 'Game',
+ category: 'Emotes',
+ });
+
+ eventManager.on('GameStart', () => {
+ eventManager.on(':loaded', () => {
+ self = global('selfId');
+ // live = true;
+ if (disableSpectating()) {
+ globalSet('gameEmotesEnabled', false);
+ debug('Hiding emotes (spectator)');
+ } else {
+ const muteEnemy = enemy.value();
+ globalSet('enemyMute', muteEnemy);
+ if (muteEnemy) {
+ debug('Hiding emotes (enemy)');
+ $('#enemyMute').toggle(!spectating);
+ }
+ }
+ });
+ });
+
+ eventManager.on('getEmote:before', function hideEmotes(data) {
+ // Do nothing if already disabled
+ if (this.canceled || !global('gameEmotesEnabled')) return;
+ if (friendsOnly(data.idUser)) {
+ debug('Hiding emote (friends)');
+ this.canceled = true;
+ }
+ });
+
+ function disableSpectating() {
+ return spectating && !spectate.value();
+ }
+
+ function friendsOnly(id) {
+ return friends.value() && id !== self && !global('isFriend')(id);
+ }
+});
diff --git a/src/base/game/endTurnDelay.js b/src/base/game/endTurnDelay.js
new file mode 100644
index 00000000..3c5869e0
--- /dev/null
+++ b/src/base/game/endTurnDelay.js
@@ -0,0 +1,28 @@
+settings.register({
+ name: 'Disable End Turn Waiting',
+ key: 'underscript.disable.endTurnDelay',
+ page: 'Game',
+});
+
+settings.register({
+ name: 'End Turn Wait Time',
+ key: 'underscript.endTurnDelay',
+ type: 'select',
+ options: [],
+ disabled: true,
+ hidden: true,
+ page: 'Game',
+});
+
+eventManager.on('PlayingGame', function endTurnDelay() {
+ eventManager.on('getTurnStart', function checkDelay() {
+ if (global('userTurn') !== global('userId')) return;
+ if (global('turn') > 3 && !settings.value('underscript.disable.endTurnDelay')) {
+ debug('Waiting');
+ $('#endTurnBtn').prop('disabled', true);
+ sleep(3000).then(() => {
+ $('#endTurnBtn').prop('disabled', false);
+ });
+ }
+ });
+});
diff --git a/src/base/game/endTurnSafety.js b/src/base/game/endTurnSafety.js
new file mode 100644
index 00000000..238e1a95
--- /dev/null
+++ b/src/base/game/endTurnSafety.js
@@ -0,0 +1,15 @@
+eventManager.on('PlayingGame', function fixEndTurn() {
+ eventManager.on(':load', () => {
+ let endedTurn = false;
+ globalSet('endTurn', function endTurn() {
+ if (endedTurn || $('#endTurnBtn').prop('disabled')) return;
+ endedTurn = true;
+ this.super();
+ });
+
+ eventManager.on('getTurnStart', function turnStarted() {
+ if (global('userTurn') !== global('userId')) return;
+ endedTurn = false;
+ });
+ });
+});
diff --git a/src/base/game/gameLog.js b/src/base/game/gameLog.js
new file mode 100644
index 00000000..ba1ae159
--- /dev/null
+++ b/src/base/game/gameLog.js
@@ -0,0 +1,50 @@
+wrap(() => {
+ style.add(
+ '#game-history.left { width: 75px; left: -66px; top: 70px; overflow-y: auto; right: initial; height: 426px; }',
+ '#game-history.left::-webkit-scrollbar { width: 8px; background-color: unset; }',
+ '#game-history.left::-webkit-scrollbar-thumb { background-color: #555; }',
+ '#game-history.hidden { display: none; }',
+ '.timer.active { left: -65px; height: 26px; line-height: 22px; top: 497px; }',
+ // '.timer.active.ally { top: 526px; }',
+ // '.timer.active.enemy { top: 39px; }',
+ );
+
+ let gameActive = false;
+ const BattleLogSetting = 'underscript.disable.logger';
+ const setting = settings.register({
+ name: 'Disable Undercards Battle Log',
+ key: 'underscript.disable.gamelog',
+ page: 'Game',
+ onChange: (to) => {
+ if (gameActive) toggle(to);
+ },
+ });
+
+ eventManager.on('GameStart', () => {
+ gameActive = true;
+ eventManager.on(':load', () => {
+ if (setting.value()) toggle(true);
+ if (!settings.value(BattleLogSetting)) {
+ toggle(true, 'left');
+ timer(true);
+ }
+ });
+
+ settings.on(BattleLogSetting, ({ val: to }) => {
+ toggle(!to, 'left');
+ timer(!to);
+ });
+
+ eventManager.on('getBattleLog', (data) => {
+ // appendBattleLog
+ });
+ });
+
+ function toggle(to, clazz = 'hidden') {
+ $('#game-history').toggleClass(clazz, to);
+ }
+
+ function timer(apply) {
+ $('div.timer').toggleClass('active', apply);
+ }
+});
diff --git a/src/base/game/hideSpells.js b/src/base/game/hideSpells.js
new file mode 100644
index 00000000..28ba356b
--- /dev/null
+++ b/src/base/game/hideSpells.js
@@ -0,0 +1,8 @@
+eventManager.on('getTurnEnd getTurnStart getPlayableCards', function hideSpells() {
+ // Remove stale cards
+ const spells = $('.spellPlayed');
+ if (spells.length) {
+ spells.remove();
+ debug(`(${this.event}) Removed spell`);
+ }
+});
diff --git a/src/base/game/hotkeys/endTurn.js b/src/base/game/hotkeys/endTurn.js
new file mode 100644
index 00000000..5b4cb7d6
--- /dev/null
+++ b/src/base/game/hotkeys/endTurn.js
@@ -0,0 +1,61 @@
+wrap(() => {
+ const fullDisable = settings.register({
+ name: 'Disable End Turn Hotkey',
+ key: 'underscript.disable.endTurn',
+ page: 'Game',
+ category: 'Hotkeys',
+ });
+ const spaceDisable = settings.register({
+ name: 'Disable End Turn with Space',
+ key: 'underscript.disable.endTurn.space',
+ disabled: () => fullDisable.value(),
+ refresh: () => typeof gameId !== 'undefined',
+ page: 'Game',
+ category: 'Hotkeys',
+ });
+ const mouseDisable = settings.register({
+ name: 'Disable End Turn with Middle Click',
+ key: 'underscript.disable.endTurn.middleClick',
+ disabled: () => fullDisable.value(),
+ refresh: () => typeof gameId !== 'undefined',
+ page: 'Game',
+ category: 'Hotkeys',
+ });
+
+ eventManager.on('PlayingGame', function bindHotkeys() {
+ // Binds to Space, Middle Click
+ const hotkey = new Hotkey('End turn').run((e) => {
+ if (fullDisable.value()) return;
+ if (!$(e.target).is('#endTurnBtn') && global('userTurn') === global('userId')) global('endTurn')();
+ });
+ if (!spaceDisable.value()) {
+ hotkey.bindKey(32);
+ }
+ if (!mouseDisable.value()) {
+ hotkey.bindClick(2);
+ }
+ hotkeys.push(hotkey);
+
+ if (!fullDisable.value() && !spaceDisable.value() && !mouseDisable.value()) {
+ fn.infoToast({
+ text: 'You can skip turns with
space and
middle mouse button. (These can be disabled in settings)',
+ className: 'dismissable',
+ buttons: {
+ text: 'Open Settings',
+ className: 'dismiss',
+ css: {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+ },
+ onclick: (e) => {
+ settings.open('Game');
+ },
+ },
+ }, 'underscript.notice.endTurn.hotkeys', '1');
+ }
+ });
+});
diff --git a/src/base/game/persistBGM.js b/src/base/game/persistBGM.js
new file mode 100644
index 00000000..0b8c0d8b
--- /dev/null
+++ b/src/base/game/persistBGM.js
@@ -0,0 +1,33 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Persist Arena (Background and Music)',
+ key: 'underscript.persist.bgm',
+ default: true,
+ refresh: window.gameId !== undefined,
+ page: 'Game',
+ });
+
+ eventManager.on('GameStart', () => {
+ eventManager.on('connect', (data) => {
+ const val = sessionStorage.getItem(`underscript.bgm.${data.gameId}`);
+ if (setting.value() && val) {
+ $('body').css('background', `#000 url('images/backgrounds/${val}.png') no-repeat`);
+ }
+ });
+
+ eventManager.on(':loaded', () => {
+ globalSet('playBackgroundMusic', function playBackgroundMusic(sound) {
+ if (setting.value()) {
+ const key = `underscript.bgm.${global('gameId')}`;
+ const background = sessionStorage.getItem(key);
+ if (background) {
+ return this.super(background);
+ }
+
+ sessionStorage.setItem(key, sound);
+ }
+ return this.super(sound);
+ });
+ });
+ });
+});
diff --git a/src/base/game/restoreSound.js b/src/base/game/restoreSound.js
new file mode 100644
index 00000000..3456372c
--- /dev/null
+++ b/src/base/game/restoreSound.js
@@ -0,0 +1,14 @@
+// Restore sound on refresh
+eventManager.on('getReconnection connect', () => {
+ if (settings.value('gameMusicDisabled')) return;
+ let playing = false;
+ const music = global('music');
+ music.addEventListener('play', () => {
+ playing = true;
+ });
+ function restoreSound() {
+ if (playing) return;
+ music.play();
+ }
+ document.addEventListener('click', restoreSound, { once: true, passive: true });
+});
diff --git a/src/base/game/resultToast.js b/src/base/game/resultToast.js
new file mode 100644
index 00000000..c641520b
--- /dev/null
+++ b/src/base/game/resultToast.js
@@ -0,0 +1,28 @@
+settings.register({
+ name: 'Disable Result Toast',
+ key: 'underscript.disable.resultToast',
+ page: 'Game',
+});
+
+eventManager.on('getResult:before', function resultToast() {
+ if (settings.value('underscript.disable.resultToast')) return;
+ // We need to mark the game as finished (like the source does)
+ globalSet('finish', true);
+ this.canceled = true;
+ const toast = {
+ title: 'Game Finished',
+ text: 'Return Home',
+ buttons: {
+ className: 'skiptranslate',
+ text: '🏠',
+ },
+ css: {
+ 'font-family': 'inherit',
+ button: { background: 'rgb(0, 0, 20)' },
+ },
+ onClose: () => {
+ document.location.href = '/';
+ },
+ };
+ fn.toast(toast);
+});
diff --git a/src/base/game/screenshake.js b/src/base/game/screenshake.js
new file mode 100644
index 00000000..443955e8
--- /dev/null
+++ b/src/base/game/screenshake.js
@@ -0,0 +1,24 @@
+settings.register({
+ name: 'Disable Screen Shake',
+ key: 'underscript.disable.rumble',
+ options: ['Never', 'Always', 'Spectate'],
+ type: 'select',
+ page: 'Game',
+});
+
+eventManager.on('GameStart', function rumble() {
+ eventManager.on(':loaded', () => {
+ const spectating = onPage('Spectate');
+ globalSet('shakeScreen', function shakeScreen(...args) {
+ if (!disabled()) this.super(...args);
+ });
+
+ function disabled() {
+ switch (settings.value('underscript.disable.rumble')) {
+ case 'Spectate': return spectating;
+ case 'Always': return true;
+ default: return false;
+ }
+ }
+ });
+});
diff --git a/src/base/game/surrender.menu.js b/src/base/game/surrender.menu.js
new file mode 100644
index 00000000..8db33c62
--- /dev/null
+++ b/src/base/game/surrender.menu.js
@@ -0,0 +1,25 @@
+onPage('Game', () => {
+ // Unbind the "surrender" hotkey
+ eventManager.on('jQuery', () => {
+ $(document).off('keyup');
+ });
+ function canSurrender() {
+ return global('turn') >= 5;
+ }
+ // Add the "surrender" menu button
+ menu.addButton({
+ text: 'Surrender',
+ enabled: canSurrender,
+ top: true,
+ note: () => {
+ if (!canSurrender()) {
+ return `You can't surrender before turn 5.`;
+ }
+ },
+ action: () => {
+ const socket = global('socketGame');
+ if (socket.readyState !== WebSocket.OPEN) return;
+ socket.send(JSON.stringify({ action: 'surrender' }));
+ },
+ });
+});
diff --git a/src/base/game/tag.opponent.js b/src/base/game/tag.opponent.js
new file mode 100644
index 00000000..af1d5c03
--- /dev/null
+++ b/src/base/game/tag.opponent.js
@@ -0,0 +1,34 @@
+wrap(() => {
+ const tag = settings.register({
+ name: 'Highlight
opponents in chat',
+ key: 'underscript.tag.opponent',
+ default: true,
+ page: 'Chat',
+ });
+
+ style.add('.opponent { color: #d94f41 !important; }');
+
+ eventManager.on('PlayingGame', function tagOpponent() {
+ let toast;
+ function processMessage(message, room) {
+ if (message.user.id === global('opponentId') && tag.value()) {
+ if (!toast) {
+ toast = fn.infoToast('
Opponents are now highlighted in chat.', 'underscript.notice.highlighting.opponent', '1');
+ }
+ $(`#${room} #message-${message.id} .chat-user`).addClass('opponent');
+ if (message.me) { // emotes
+ $(`#${room} #message-${message.id} .chat-message`).addClass('opponent');
+ }
+ }
+ }
+
+ eventManager.on('Chat:getHistory', (data) => {
+ JSON.parse(data.history).forEach((message) => {
+ processMessage(message, data.room);
+ });
+ });
+ eventManager.on('Chat:getMessage', function tagFriends(data) {
+ processMessage(JSON.parse(data.chatMessage), data.room);
+ });
+ });
+});
diff --git a/src/base/hotkeys/menu.js b/src/base/hotkeys/menu.js
new file mode 100644
index 00000000..b0a06a9f
--- /dev/null
+++ b/src/base/hotkeys/menu.js
@@ -0,0 +1,12 @@
+hotkeys.push(new Hotkey('Open Menu')
+ .run((e) => {
+ if (typeof BootstrapDialog !== 'undefined' && Object.keys(BootstrapDialog.dialogs).length) {
+ return;
+ }
+ if (menu.isOpen()) {
+ menu.close();
+ } else {
+ menu.open();
+ }
+ })
+ .bindKey(27));
diff --git a/src/base/hub/missingImport.js b/src/base/hub/missingImport.js
new file mode 100644
index 00000000..e2bf9061
--- /dev/null
+++ b/src/base/hub/missingImport.js
@@ -0,0 +1,40 @@
+wrap(() => {
+ style.add(
+ '.missingArt { color: orange; }',
+ '.missing { color: red; }',
+ );
+
+ function init() {
+ globalSet('showPage', function showPage(page) {
+ this.super(page);
+ thing(page * 10);
+ });
+ fn.dismissable({
+ title: 'Did you know?',
+ text: `An
orange arrow means you're missing artifact(s) and a
red arrow means you're missing card(s)`,
+ key: 'underscript.notice.hubImport',
+ });
+ }
+
+ function thing(start) {
+ const pages = global('pages');
+ for (let i = start; i < start + 10 && i < pages.length; i++) {
+ check(pages[i]);
+ }
+ }
+
+ function check({ code, id }) {
+ const checkArt = global('ownArtifactHub');
+ const deck = JSON.parse(atob(code));
+ const missingCard = global('getOwnedCardsArrayHub')(deck).some((a) => !a);
+ const missingArt = deck.artifactIds.some((art) => !checkArt(art));
+
+ $(`#hub-deck-${id} .show-button`)
+ .toggleClass('missingArt', missingArt)
+ .toggleClass('missing', missingCard);
+ }
+
+ onPage('Hub', () => {
+ eventManager.on(':loaded', init);
+ });
+});
diff --git a/src/base/ignorelist.js b/src/base/ignorelist.js
new file mode 100644
index 00000000..462c2079
--- /dev/null
+++ b/src/base/ignorelist.js
@@ -0,0 +1,4 @@
+fn.each(localStorage, (name, key) => {
+ if (!key.startsWith('underscript.ignore.')) return;
+ fn.ignoreUser(name, key);
+});
diff --git a/src/base/lastpass.js b/src/base/lastpass.js
new file mode 100644
index 00000000..3f1bff7b
--- /dev/null
+++ b/src/base/lastpass.js
@@ -0,0 +1,11 @@
+eventManager.on(':loaded', () => {
+ if (onPage('Settings') || onPage('SignUp') || onPage('SignIn')) return;
+ const type = 'input[type="text"]';
+ [...document.querySelectorAll(type)].forEach((el) => {
+ el.dataset.lpignore = true;
+ });
+
+ eventManager.on('Chat:getHistory', (data) => {
+ document.querySelector(`#${data.room} ${type}`).dataset.lpignore = true;
+ });
+});
diff --git a/src/base/leaderboard/goto.js b/src/base/leaderboard/goto.js
new file mode 100644
index 00000000..3c59f506
--- /dev/null
+++ b/src/base/leaderboard/goto.js
@@ -0,0 +1,68 @@
+wrap(() => {
+ if (!onPage('leaderboard')) return;
+ const data = getData();
+ const skip = new VarStore(true);
+ let replacePage;
+
+ function set(type, value, replace = true) {
+ if (history.state &&
+ Object.prototype.hasOwnProperty.call(history.state, type) &&
+ history.state[type] === value) return;
+ const func = replace && !userLast() ? history.replaceState : history.pushState;
+ const o = {};
+ o[type] = value;
+ func.call(history, o, document.title, `?${type}=${value}`);
+ }
+
+ window.addEventListener('popstate', () => {
+ if (!history.state) return;
+ if (document.readyState === 'complete') load(history.state);
+ else eventManager.on(':loaded', () => debug('!!!pop unready'), load(history.state));
+ });
+
+ eventManager.on('Rankings:selectPage', () => {
+ replacePage = false;
+ });
+ eventManager.on('Rankings:init', () => load(data));
+
+ eventManager.on(':loaded', () => {
+ globalSet('showPage', function showPage(page) {
+ this.super(page);
+ if (skip.get()) return;
+ set('page', page, replacePage);
+ replacePage = undefined;
+ });
+
+ globalSet('findUserRow', function findUserRow(user) {
+ const row = this.super(user);
+ skip.set(row !== -1);
+ set('user', user, false);
+ return row;
+ });
+ });
+
+ eventManager.on('Rankings:selectPage', () => {
+ replacePage = false;
+ });
+
+ function load({ page, user } = {}) {
+ if (user) {
+ $('#searchInput').val(user).submit();
+ } else if (page !== undefined) {
+ fn.changePage(page);
+ }
+ }
+
+ function getData() {
+ const o = {};
+ const d = decodeURIComponent;
+ location.search.substring(1).replace(/([^=&]+)=([^&]*)/g, (m, k, v) => {
+ o[d(k)] = d(v);
+ });
+ return o;
+ }
+
+ function userLast() {
+ return history.state && history.state.user;
+ }
+});
diff --git a/src/base/leaderboard/notFound.js b/src/base/leaderboard/notFound.js
new file mode 100644
index 00000000..84ac5d11
--- /dev/null
+++ b/src/base/leaderboard/notFound.js
@@ -0,0 +1,17 @@
+onPage('leaderboard', () => {
+ const toasts = {};
+ eventManager.on(':loaded', () => {
+ globalSet('findUserRow', function findUserRow(user) {
+ const row = this.super(user);
+ if (row === -1) {
+ if (!toasts[user] || !toasts[user].exists()) {
+ toasts[user] = fn.toast({
+ title: 'Not ranked',
+ text: `Unfortunately ${user} has not qualified to be ranked, or the user does not exist.`,
+ });
+ }
+ }
+ return row;
+ });
+ });
+});
diff --git a/src/base/leaderboard/page.js b/src/base/leaderboard/page.js
new file mode 100644
index 00000000..fc79f06d
--- /dev/null
+++ b/src/base/leaderboard/page.js
@@ -0,0 +1,42 @@
+wrap(() => {
+ if (!onPage('leaderboard')) return;
+ const select = document.createElement('select');
+ select.value = 0;
+ select.id = 'selectPage';
+ function init() {
+ $('#currentPage').after(select).hide();
+ const local = $(select);
+ const maxPage = global('getMaxPage')();
+ for (let i = 0; i <= maxPage; i++) {
+ local.append(`
${i + 1} `);
+ }
+ }
+
+ 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);
+ $('#btnFirst').prop('disabled', page === 0);
+ }
+
+ eventManager.on(':loaded', () => {
+ select.onchange = () => {
+ changePage(select.value);
+ eventManager.emit('Rankings:selectPage', select.value);
+ };
+ globalSet('initLeaderboard', function initLeaderboard(...args) {
+ this.super(...args);
+ init();
+ eventManager.emit('Rankings:init');
+ });
+ globalSet('showPage', function showPage(page) {
+ this.super(page);
+ select.value = page;
+ });
+ });
+
+ fn.changePage = changePage;
+});
diff --git a/src/base/lobby/customFriendsOnly.js b/src/base/lobby/customFriendsOnly.js
new file mode 100644
index 00000000..2c28c3f0
--- /dev/null
+++ b/src/base/lobby/customFriendsOnly.js
@@ -0,0 +1,32 @@
+wrap(function friendsOnly() {
+ const setting = settings.register({
+ name: 'Friends only',
+ key: 'underscript.custom.friendsOnly',
+ note: 'Setting this will only allow friends to join custom games by default.',
+ page: 'Lobby',
+ 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.new(`Only allow friends to join`, container);
+ }
+
+ function joined({ username }) {
+ if (this.canceled || !flag || fn.isFriend(username)) return;
+ debug(`Kicked: ${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..e0ec1d48
--- /dev/null
+++ b/src/base/lobby/disconnected.js
@@ -0,0 +1,36 @@
+wrap(function disconnected() {
+ onPage('Play', setup);
+
+ let waiting = true;
+
+ function setup() {
+ eventManager.on('socketOpen', () => {
+ const socket = global('socketQueue');
+ socket.addEventListener('close', announce);
+ globalSet('onbeforeunload', function onbeforeunload() {
+ socket.removeEventListener('close', announce);
+ this.super();
+ });
+ });
+
+ eventManager.on('Play:Message', (data) => {
+ switch (data.action) {
+ default:
+ waiting = false;
+ return;
+ case 'getLeaveQueue':
+ waiting = true;
+ }
+ });
+ }
+
+ function announce() {
+ if (waiting) {
+ eventManager.emit('closeQueues', 'Disconnected from queue. Please refresh page.');
+ }
+ fn.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..7d4fb12e
--- /dev/null
+++ b/src/base/lobby/enterOnCustom.js
@@ -0,0 +1,29 @@
+onPage('GamesList', function fixEnter() {
+ eventManager.on(':load', () => {
+ let toast = fn.infoToast({
+ text: 'You can now press enter on the Create Game window.',
+ 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.which === 13) {
+ if (toast) {
+ 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..d587d3e8
--- /dev/null
+++ b/src/base/lobby/gameFound.js
@@ -0,0 +1,12 @@
+onPage('Play', () => {
+ const title = document.title;
+ eventManager.on('getWaitingQueue', function updateTitle() {
+ // Title has been modified
+ if (title !== document.title) return;
+ document.title = `Undercards - Match found!`;
+ });
+ eventManager.on('getLeaveQueue', function restoreTitle() {
+ document.title = title;
+ });
+ // fn.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..d34ecc46
--- /dev/null
+++ b/src/base/lobby/gameFoundNotification.js
@@ -0,0 +1,9 @@
+wrap(() => {
+ onPage('Play', () => {
+ eventManager.on('getWaitingQueue', function gameFound() {
+ if (!fn.active()) {
+ fn.notify('Match found!');
+ }
+ });
+ });
+});
diff --git a/src/base/lobby/lowerVolume.js b/src/base/lobby/lowerVolume.js
new file mode 100644
index 00000000..a5acdcb2
--- /dev/null
+++ b/src/base/lobby/lowerVolume.js
@@ -0,0 +1,17 @@
+wrap(() => {
+ const volume = settings.register({
+ name: 'Game Found Volume',
+ 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..f526fa91
--- /dev/null
+++ b/src/base/lobby/noMinigame.js
@@ -0,0 +1,23 @@
+wrap(function minigames() {
+ const setting = settings.register({
+ name: 'Disable mini-games',
+ key: 'underscript.minigames.disabled',
+ page: 'Lobby',
+ refresh: onPage('Play'),
+ });
+
+ onPage('Play', () => {
+ eventManager.on(':loaded', () => {
+ globalSet('onload', function onload() {
+ window.game = undefined; // gets overriden if minigame loads
+ window.saveBest = noop(); // gets overriden if minigame loads
+ if (setting.value()) {
+ debug('Disabling minigames');
+ globalSet('mobile', true);
+ }
+ this.super();
+ if (setting.value()) globalSet('mobile', false);
+ });
+ });
+ });
+});
diff --git a/src/base/lobby/pingCustom.js b/src/base/lobby/pingCustom.js
new file mode 100644
index 00000000..4b01363b
--- /dev/null
+++ b/src/base/lobby/pingCustom.js
@@ -0,0 +1,7 @@
+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..c416e62e
--- /dev/null
+++ b/src/base/lobby/playQueueButton.js
@@ -0,0 +1,31 @@
+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');
+ closeQueues(restarting ? 'Joining is disabled due to server restart.' : 'Waiting for connection to be established.');
+ }
+ });
+
+ eventManager.on('socketOpen', function checkButton() {
+ disable = false;
+ if (queues && !restarting) {
+ queues.off('.script');
+ queues.toggleClass('closed', false);
+ hover.hide();
+ }
+ });
+
+ eventManager.on('closeQueues', closeQueues);
+
+ function closeQueues(message) {
+ queues.toggleClass('closed', true);
+ 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..e709928f
--- /dev/null
+++ b/src/base/lobby/unlockChat.js
@@ -0,0 +1,37 @@
+wrap(() => {
+ const unpause = new 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/material.inject.js b/src/base/material.inject.js
new file mode 100644
index 00000000..9f429eb8
--- /dev/null
+++ b/src/base/material.inject.js
@@ -0,0 +1,6 @@
+wrap(() => {
+ 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/patchnotes.js b/src/base/patchnotes.js
new file mode 100644
index 00000000..183c6e48
--- /dev/null
+++ b/src/base/patchnotes.js
@@ -0,0 +1,29 @@
+wrap(function patchNotes() {
+ const setting = settings.register({
+ name: 'Disable Patch Notes',
+ key: 'underscript.disable.patches',
+ });
+
+ fn.cleanData('underscript.update.', scriptVersion, 'last', 'checking', 'latest');
+ style.add(
+ '#AlertToast div.uschangelog span:nth-of-type(2) { max-height: 300px; overflow-y: auto; display: block; }',
+ '#AlertToast div.uschangelog extended { display: none; }',
+ );
+ if (setting.value() || !scriptVersion.includes('.')) return;
+ const versionKey = `underscript.update.${scriptVersion}`;
+ if (localStorage.getItem(versionKey)) return;
+
+ changelog.get(scriptVersion, true)
+ .then(notify)
+ .catch();
+
+ function notify(text) {
+ localStorage.setItem(versionKey, true);
+ fn.toast({
+ text,
+ title: '[UnderScript] Patch Notes',
+ footer: `v${scriptVersion}`,
+ className: 'uschangelog',
+ });
+ }
+});
diff --git a/src/base/plugin/.eslintrc.js b/src/base/plugin/.eslintrc.js
new file mode 100644
index 00000000..c1615958
--- /dev/null
+++ b/src/base/plugin/.eslintrc.js
@@ -0,0 +1,6 @@
+const readonly = 'readonly';
+module.exports = {
+ globals: {
+ registerModule: readonly,
+ },
+};
diff --git a/src/base/plugin/events.js b/src/base/plugin/events.js
new file mode 100644
index 00000000..8417b496
--- /dev/null
+++ b/src/base/plugin/events.js
@@ -0,0 +1,30 @@
+wrap(() => {
+ const name = 'events';
+ function mod(plugin) {
+ const obj = {
+ on(event, fn) {
+ if (typeof fn !== 'function') throw new Error('Must pass a function');
+
+ function pluginListener(...args) {
+ try {
+ fn.call(this, ...args);
+ } catch (e) {
+ plugin.logger.error(`Event error (${event}):\n`, e, '\n', {
+ args,
+ event: this,
+ });
+ }
+ }
+
+ eventManager.on(event, pluginListener);
+ },
+ emit(event, data, cancelable = false) {
+ return eventManager.emit(event, data, cancelable);
+ },
+ };
+
+ return Object.freeze(obj);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/logger.js b/src/base/plugin/logger.js
new file mode 100644
index 00000000..e39bc3d9
--- /dev/null
+++ b/src/base/plugin/logger.js
@@ -0,0 +1,16 @@
+wrap(() => {
+ const name = 'logger';
+ function mod(plugin) {
+ const obj = {};
+ ['info', 'error', 'log', 'warn'].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/settings.js b/src/base/plugin/settings.js
new file mode 100644
index 00000000..f0122c68
--- /dev/null
+++ b/src/base/plugin/settings.js
@@ -0,0 +1,30 @@
+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: 'Plugins',
+ category: plugin.name,
+ };
+ return settings.register(setting);
+ };
+ }
+
+ function mod(plugin) {
+ const obj = {
+ add: add(plugin),
+ on: (...args) => settings.on(...args),
+ isOpen: () => settings.isOpen(),
+ };
+ return () => obj;
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/plugin/toast.js b/src/base/plugin/toast.js
new file mode 100644
index 00000000..e6747b8c
--- /dev/null
+++ b/src/base/plugin/toast.js
@@ -0,0 +1,15 @@
+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 fn.errorToast(toast);
+ return fn.toast(toast);
+ }
+
+ function mod(plugin) {
+ return (data) => pluginToast(plugin, data);
+ }
+
+ registerModule(name, mod);
+});
diff --git a/src/base/quests/completed.js b/src/base/quests/completed.js
new file mode 100644
index 00000000..5c788d80
--- /dev/null
+++ b/src/base/quests/completed.js
@@ -0,0 +1,36 @@
+wrap(() => {
+ if (!onPage('Quests')) return;
+ 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(':loaded', 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';
+ 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..4f8e05e7
--- /dev/null
+++ b/src/base/quests/localTime.js
@@ -0,0 +1,10 @@
+onPage('Quests', function localTime() {
+ eventManager.on(':load', () => {
+ sleep().then(updateTime);
+ });
+
+ function updateTime() {
+ const time = luxon.DateTime.fromObject({ hour: 6, minute: 0, zone: 'Europe/Paris' }).toLocal();
+ $('[data-i18n="[html]quests-reset"],[data-i18n="[html]quests-day"]').append(` (${time.toLocaleString(luxon.DateTime.TIME_SIMPLE)} local)`);
+ }
+});
diff --git a/src/base/store/quickPacks.js b/src/base/store/quickPacks.js
new file mode 100644
index 00000000..95d84edf
--- /dev/null
+++ b/src/base/store/quickPacks.js
@@ -0,0 +1,283 @@
+/* eslint-disable no-multi-assign, no-nested-ternary */
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Quick Opening Packs',
+ key: 'underscript.disable.packOpening',
+ refresh: () => onPage('Packs'),
+ });
+
+ onPage('Packs', function quickOpenPack() {
+ if (setting.value()) return;
+
+ const results = {
+ packs: 0,
+ cards: [],
+ };
+ const status = {
+ state: 'waiting', // waiting, processing, canceled
+ pack: '',
+ original: 0,
+ total: 0,
+ remaining: 0,
+ pending: 0, // How many cards are being waited on
+ pingTimeout: 0,
+ };
+ const events = fn.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');
+ }, 1000);
+ }
+
+ function clearPing(safe = true) {
+ if (safe) status.pingTimeout = 0;
+ if (timeoutID) clearTimeout(timeoutID);
+ timeoutID = 0;
+ }
+
+ function open(pack, count) {
+ 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 (status.state !== 'waiting') return;
+ results.packs = 0;
+ results.cards = [];
+ status.state = 'processing';
+ status.pack = pack;
+ status.total = amt;
+ status.remaining = amt - offset;
+ status.pending = 0;
+ if (!offset) {
+ events.emit('next');
+ }
+ setupPing();
+ });
+
+ let toast = new SimpleToast();
+ events.on('pack', (cards) => {
+ status.pending -= 1;
+ results.packs += 1;
+ 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, 10);
+ 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 {
+ toast = fn.toast({
+ title: `Opening ${fn.formatNumber(status.total)} packs`,
+ text: ` `,
+ className: 'dismissable',
+ buttons: {
+ text: 'Stop',
+ className: 'dismiss',
+ css: {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+ },
+ 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 };
+ c.total += 1;
+ 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 (!Object.keys(cardResults[key]).length) {
+ return this + 1;
+ }
+ return this;
+ });
+
+ let text = '';
+ // Build visual results
+ rarity.forEach((key) => {
+ const keys = Object.keys(cardResults[key]);
+ if (!keys.length) return;
+ const buffer = [];
+ let count = 0;
+ let shiny = 0;
+ keys.forEach((name) => {
+ const card = cardResults[key][name];
+ count += card.total;
+ shiny += card.shiny;
+ if (limit) {
+ limit -= 1;
+ buffer.push(`${card.shiny ? 'S ' : ''}${name}${card.total > 1 ? ` (${fn.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;
+ fn.toast({
+ title: `Results: ${fn.formatNumber(results.packs)} Packs${cardResults.shiny ? ` (${total % 4 ? `${fn.formatNumber(total)}, ` : ''}${fn.formatNumber(cardResults.shiny)} shiny)` : total % 4 ? ` (${fn.formatNumber(total)})` : ''}`,
+ text,
+ css: { 'font-family': 'inherit' },
+ });
+
+ // Show cards... I guess
+ showCards();
+ }
+ toast.close();
+ });
+
+ events.on('cancel', () => { // Sets the canceled flag
+ if (status.state !== 'waiting') {
+ status.state = 'canceled';
+ }
+ });
+
+ events.on('error', (err) => {
+ // TODO: Error occurred
+ });
+
+ let autoOpen = false;
+
+ eventManager.on('jQuery', () => {
+ $(document).ajaxComplete((event, xhr, settings) => {
+ if (settings.url !== 'PacksConfig' || !settings.data) return;
+ const data = xhr.responseJSON;
+ if (data.action !== 'getCards') return;
+ if (openingPacks()) {
+ if (data.cards) {
+ events.emit('pack', JSON.parse(data.cards));
+ } else if (data.status || data.action === 'getError') {
+ events.emit('error', data.message);
+ }
+ } else if (autoOpen && !data.status) {
+ showCards();
+ }
+ });
+ $('[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', 'Shiny', 'Super', 'Final'];
+ api.register('openPacks', (count, type = '') => {
+ if (openingPacks()) throw new Error('Currently opening packs');
+ if (!types.includes(type)) throw new Error(`Unsupported Pack: ${type}`);
+ openPacks(type, count);
+ });
+
+ api.register('openingPacks', openingPacks);
+ });
+});
diff --git a/src/base/streamer/0.streamer.js b/src/base/streamer/0.streamer.js
new file mode 100644
index 00000000..e12a8024
--- /dev/null
+++ b/src/base/streamer/0.streamer.js
@@ -0,0 +1,45 @@
+wrap(function streamer() {
+ const silent = 'Yes (silent)';
+ const disabled = 'No';
+ const mode = settings.register({
+ name: 'Enable?',
+ key: 'underscript.streamer',
+ note: 'Enables a button on the menu, streamer mode is "off" by default.',
+ options: ['Yes', silent, disabled],
+ default: disabled,
+ onChange: (val) => {
+ if (val === disabled) {
+ update(false);
+ } else {
+ menu.dirty();
+ }
+ },
+ type: 'select',
+ category: 'Streamer Mode',
+ });
+ const setting = settings.register({
+ key: 'underscript.streaming',
+ hidden: true,
+ });
+ Object.defineProperty(script, 'streaming', {
+ get: () => setting.value(),
+ });
+ api.register('streamerMode', () => setting.value());
+ menu.addButton({
+ text: () => `Streamer Mode: ${setting.value() ? 'On' : 'Off'}`,
+ hidden: () => mode.value() === disabled,
+ action: () => update(!setting.value()),
+ });
+ eventManager.on(':loaded', alert);
+
+ function alert() {
+ if (!setting.value() || mode.value() === silent) return;
+ fn.toast('Streamer Mode Active');
+ }
+
+ function update(value) {
+ setting.set(value);
+ menu.dirty();
+ alert();
+ }
+});
diff --git a/src/base/streamer/email.js b/src/base/streamer/email.js
new file mode 100644
index 00000000..e40cfd90
--- /dev/null
+++ b/src/base/streamer/email.js
@@ -0,0 +1,8 @@
+onPage('Settings', () => {
+ if (!settings.value('underscript.streaming')) return;
+ eventManager.on(':loaded', () => {
+ $el.text.contains(document.querySelectorAll('p'), 'Mail :').forEach((e) => {
+ 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..44286dc6
--- /dev/null
+++ b/src/base/streamer/message.private.js
@@ -0,0 +1,77 @@
+/* eslint-disable no-multi-assign */
+// Toast for private messages while streaming mode is on
+wrap(function streamPM() {
+ 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 (!script.streaming) return; // if not streaming
+
+ const val = setting.value();
+ if (val === allow || $(`#${data.room}`).length) return; // if private messages are allowed, if window is already open
+ debug(data);
+
+ const message = JSON.parse(data.chatMessage);
+ const user = message.user;
+
+ if (fn.user.isMod(user)) return; // Moderators are always allowed
+
+ this.canceled = true; // Cancel the event from going through
+
+ const userId = user.id;
+ if (userId === global('selfId')) return; // ignore automated reply
+
+ global('sendPrivateMessage')(busyMessage, userId); // send a message that you're busy
+ if (val === silent) return close(user); // Close instantly when silent mode
+
+ if (toasts[userId]) return; // Don't announce anymore
+ const toast = toasts[userId] = fn.toast({
+ text: `Message from ${fn.user.name(user)}`,
+ buttons: [{
+ css: {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+ },
+ text: 'Open',
+ className: 'dismiss',
+ onclick: () => {
+ open(user);
+ toast.close('open');
+ },
+ }],
+ onClose(type) {
+ if (type === 'open') return;
+ close(user);
+ },
+ className: 'dismissable',
+ });
+ });
+ eventManager.on(':unload', closeAll);
+
+ function open(user) {
+ global('openPrivateRoom')(user.id, fn.user.name(user).replace('\'', ''));
+ }
+
+ function close(user) {
+ global('closePrivateRoom')(user.id);
+ }
+
+ function closeAll() {
+ fn.each(toasts, (t) => t.close());
+ }
+});
diff --git a/src/base/styles/general.js b/src/base/styles/general.js
new file mode 100644
index 00000000..1b92cab0
--- /dev/null
+++ b/src/base/styles/general.js
@@ -0,0 +1,4 @@
+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/toast.js b/src/base/styles/toast.js
new file mode 100644
index 00000000..91daf145
--- /dev/null
+++ b/src/base/styles/toast.js
@@ -0,0 +1,6 @@
+style.add(
+ '#AlertToast { height: 0; }',
+ '#AlertToast .dismissable > span { display: block; text-align: center; }',
+ '#AlertToast .dismissable .dismiss { width: 160px; text-transform: capitalize; display: block; font-family: DTM-Mono; border: 1px solid #fff; font-size: 14px; margin: 5px auto; background-color: transparent; }',
+ '#AlertToast .dismissable .dismiss:hover { opacity: 0.6; }',
+);
diff --git a/src/base/translate/page.js b/src/base/translate/page.js
new file mode 100644
index 00000000..d17a0cb5
--- /dev/null
+++ b/src/base/translate/page.js
@@ -0,0 +1,39 @@
+wrap(() => {
+ if (!onPage('Translate')) return;
+ const select = document.createElement('select');
+ select.value = 0;
+ select.id = 'selectPage';
+ select.onchange = () => {
+ changePage(select.value);
+ };
+
+ function init() {
+ const local = $(select).empty();
+ const maxPage = global('getMaxPage')();
+ for (let i = 0; i <= maxPage; i++) {
+ local.append(`${i + 1} `);
+ }
+ }
+
+ 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);
+ $('#btnFirst').prop('disabled', page === 0);
+ }
+
+ eventManager.on(':loaded', () => {
+ $('#currentPage').after(select).hide();
+ globalSet('applyFilters', function applyFilters(...args) {
+ this.super(...args);
+ init();
+ });
+ globalSet('showPage', function showPage(page) {
+ this.super(page);
+ select.value = page;
+ });
+ });
+});
diff --git a/src/base/translate/preview.js b/src/base/translate/preview.js
new file mode 100644
index 00000000..bc7e6b51
--- /dev/null
+++ b/src/base/translate/preview.js
@@ -0,0 +1,51 @@
+wrap(() => {
+ if (!onPage('Translate')) return;
+ eventManager.on(':loaded', () => {
+ loadLanguages();
+
+ globalSet('createTranslator', newTranslator);
+
+ globalSet('showPage', newShowPage);
+ });
+
+ function newTranslator(translator) {
+ let ret = this.super(translator);
+ if (!translator.translations.length) {
+ ret += `${getPreview('decks-preview')}:
`;
+ }
+ return ret;
+ }
+
+ function newShowPage(page) {
+ this.super(page);
+
+ const textarea = $('#translators textarea');
+ const preview = $('#preview span');
+ textarea.on('input', () => {
+ const text = getPreview(textarea.val());
+ preview.html(text);
+ });
+ }
+
+ function getPreview(id, locale = getLocale()) {
+ return fn.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/updates.js b/src/base/updates.js
new file mode 100644
index 00000000..be84dd7e
--- /dev/null
+++ b/src/base/updates.js
@@ -0,0 +1,145 @@
+// Check for script updates
+wrap(() => {
+ const disabled = settings.register({
+ name: 'Disable Auto Updates',
+ key: 'underscript.disable.updates',
+ });
+
+ style.add(
+ '#AlertToast h2, #AlertToast h3 { margin: 0; font-size: 20px; }',
+ '#AlertToast h3 {font-size: 17px; }',
+ );
+ const baseURL = 'https://unpkg.com/';
+ const MINUTE = 60 * 1000;
+ const HOUR = 60 * MINUTE;
+ const CHECKING = 'underscript.update.checking';
+ const LAST = 'underscript.update.last';
+ const DEBUG = 'underscript.debug.update';
+ const LATEST = 'underscript.update.latest';
+ const base = axios.create({ baseURL });
+ const latest = {
+ set({ version, unpkg }) {
+ if (!version || !unpkg) return;
+ localStorage.setItem(LATEST, JSON.stringify({
+ version,
+ unpkg,
+ time: Date.now(),
+ }));
+ },
+ del() {
+ localStorage.removeItem(LATEST);
+ },
+ chk() {
+ const stored = JSON.parse(localStorage.getItem(LATEST));
+ if (stored) {
+ return compareAndToast(stored);
+ }
+ },
+ };
+ let toast;
+ let updateToast;
+ let autoTimeout;
+ function check() {
+ if (sessionStorage.getItem(CHECKING)) return Promise.resolve();
+ sessionStorage.setItem(CHECKING, true);
+ return base.get(`underscript@${getVersion()}/package.json`).then((response) => {
+ sessionStorage.removeItem(CHECKING);
+ localStorage.setItem(LAST, Date.now());
+ return response;
+ }).catch((error) => {
+ sessionStorage.removeItem(CHECKING);
+ fn.debug(error);
+ if (toast) {
+ toast.setText('Failed to connect to server.');
+ }
+ });
+ }
+ function noUpdateFound() {
+ const ref = toast;
+ if (ref && ref.exists()) {
+ ref.setText('No updates available.');
+ sleep(3000).then(ref.close);
+ }
+ }
+ function isNewer(data) {
+ const version = scriptVersion;
+ if ((version === 'L' || version.includes('-')) && !localStorage.getItem(DEBUG)) return false;
+ if (data.time && data.time < GM_info.script.lastModified) return false;
+ return data.version !== version;
+ }
+ function compareAndToast(data) {
+ if (!isNewer(data)) {
+ latest.del();
+ return;
+ }
+ latest.set(data);
+ updateToast = fn.toast({
+ title: '[UnderScript] Update Available!',
+ text: `Version ${data.version}.`,
+ footer: 'Click to update',
+ onClose(reason) {
+ if (reason !== 'dismissed') return;
+ location.href = `${baseURL}/underscript@${data.version}/${data.unpkg}`;
+ },
+ });
+ return true;
+ }
+ function autoCheck() {
+ // It passed, don't need to check anymore
+ if (latest.chk()) return;
+ check().then(({ data } = {}) => {
+ if (data) {
+ compareAndToast(data);
+ }
+ // One hour from now or one minute from now (if an error occurred)
+ autoTimeout = setTimeout(autoCheck, data ? HOUR : MINUTE);
+ });
+ }
+ // Frequency - when should it check for updates
+ // Menu button - Manual update check
+ menu.addButton({
+ text: 'Check for updates',
+ action() {
+ if (updateToast && updateToast.exists()) return;
+ if (toast) toast.close();
+ toast = fn.toast({
+ title: 'UnderScript updater',
+ text: 'Checking for updates. Please wait.',
+ });
+ check().then(({ data } = {}) => {
+ setupAuto(); // Setup a new auto check (wait another hour)
+ if (!data) return;
+ if (!isNewer(data)) {
+ noUpdateFound();
+ } else {
+ toast.close(); // I need a way to change the 'onclose'
+ compareAndToast(data);
+ }
+ });
+ },
+ note() {
+ const last = parseInt(localStorage.getItem(LAST), 10);
+ return `Last Checked: ${last ? luxon.DateTime.fromMillis(last).toLocaleString(luxon.DateTime.DATETIME_FULL) : 'never'}`;
+ },
+ });
+
+ function setupAuto() {
+ if (disabled.value()) return;
+ if (autoTimeout) clearTimeout(autoTimeout);
+ const last = parseInt(localStorage.getItem(LAST), 10);
+ const now = Date.now();
+ const timeout = last - now + HOUR;
+ if (!last || timeout <= 0) {
+ autoCheck();
+ } else {
+ autoTimeout = setTimeout(autoCheck, timeout);
+ }
+ }
+
+ function getVersion() {
+ return scriptVersion.includes('-') ? 'next' : 'latest';
+ }
+
+ sessionStorage.removeItem(CHECKING);
+ if (!latest.chk()) setupAuto();
+});
diff --git a/src/base/vanilla/aprilFools.js b/src/base/vanilla/aprilFools.js
new file mode 100644
index 00000000..6966232f
--- /dev/null
+++ b/src/base/vanilla/aprilFools.js
@@ -0,0 +1,11 @@
+wrap(function noFoolsHere() {
+ const setting = settings.register({
+ name: 'Disable April Fools Jokes',
+ key: 'underscript.disable.fishday',
+ note: 'Disables *almost* everything.',
+ refresh: true,
+ });
+ if (setting.value()) {
+ eventManager.on(':loaded', () => globalSet('fish', false, { throws: false }));
+ }
+});
diff --git a/src/base/vanilla/card.append.js b/src/base/vanilla/card.append.js
new file mode 100644
index 00000000..1924134d
--- /dev/null
+++ b/src/base/vanilla/card.append.js
@@ -0,0 +1,13 @@
+wrap(function cardEvent() {
+ eventManager.on(':loaded', () => {
+ globalSet('appendCard', function appendCard(card, container) {
+ const element = this.super(card, container);
+ eventManager.emit('appendCard()', {
+ card, element,
+ });
+ return element;
+ }, {
+ throws: false,
+ });
+ });
+});
diff --git a/src/base/vanilla/cardBreakingArt.js b/src/base/vanilla/cardBreakingArt.js
new file mode 100644
index 00000000..4bf2913f
--- /dev/null
+++ b/src/base/vanilla/cardBreakingArt.js
@@ -0,0 +1,25 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Breaking Card Art',
+ key: 'underscript.hide.breaking-skin',
+ page: 'Library',
+ onChange: toggle,
+ category: 'Card Skins',
+ });
+ const art = new VarStore();
+
+ function toggle() {
+ if (art.isSet()) {
+ art.get().remove();
+ } else {
+ art.set(style.add(
+ '.breaking-skin .cardHeader, .breaking-skin .cardFooter { background-color: rgb(0, 0, 0); }',
+ '.breaking-skin .cardImage { z-index: 1; }',
+ ));
+ }
+ }
+
+ eventManager.on(':loaded', () => {
+ if (setting.value()) toggle();
+ });
+});
diff --git a/src/base/vanilla/cardFullArt.js b/src/base/vanilla/cardFullArt.js
new file mode 100644
index 00000000..b47c202f
--- /dev/null
+++ b/src/base/vanilla/cardFullArt.js
@@ -0,0 +1,24 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Full Card Art',
+ key: 'underscript.hide.full-skin',
+ page: 'Library',
+ onChange: toggle,
+ category: 'Card Skins',
+ });
+ const art = new 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(':loaded', () => {
+ if (setting.value()) toggle();
+ });
+});
diff --git a/src/base/vanilla/cardHover.js b/src/base/vanilla/cardHover.js
new file mode 100644
index 00000000..78c44f1a
--- /dev/null
+++ b/src/base/vanilla/cardHover.js
@@ -0,0 +1,16 @@
+wrap(function hoverWrapper() {
+ function wrapper(...rest) {
+ this.super(...rest);
+ $('#hover-card').click(function hoverCard() {
+ $(this).remove();
+ });
+ }
+
+ eventManager.on(':loaded', () => {
+ 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..03531d7f
--- /dev/null
+++ b/src/base/vanilla/cardName.js
@@ -0,0 +1,25 @@
+wrap(function standardizedNaming() {
+ const setting = settings.register({
+ name: 'Force English for card names',
+ 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;
+ }
+
+ function toEnglish(id, ...data) {
+ return fn.toLocale({ id, data });
+ }
+
+ eventManager.on(':loaded', () => {
+ 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..e500c233
--- /dev/null
+++ b/src/base/vanilla/editor.js
@@ -0,0 +1,12 @@
+eventManager.on('jQuery', () => {
+ const text = `
+
+
+
+ `;
+ const $text = $(text);
+ $text.find('img').on('error', function imgError() {
+ $(this).attr('src', './images/cardsBack/BASECardDETERMINATION.png');
+ });
+ $('a[data-i18n-title="footer-wiki"]').parent().after($text);
+});
diff --git a/src/base/vanilla/header.sticky.js b/src/base/vanilla/header.sticky.js
new file mode 100644
index 00000000..6ba478ef
--- /dev/null
+++ b/src/base/vanilla/header.sticky.js
@@ -0,0 +1,24 @@
+wrap(() => {
+ 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: 'Disable header scrolling',
+ key: 'underscript.disable.header.scrolling',
+ onChange: (to) => {
+ toggle(!to);
+ },
+ });
+
+ eventManager.on(':loaded', () => {
+ 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..ce2de3ad
--- /dev/null
+++ b/src/base/vanilla/iconHelper.js
@@ -0,0 +1,34 @@
+wrap(() => {
+ 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(':loaded', () => {
+ fn.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/index.layout.js b/src/base/vanilla/index.layout.js
new file mode 100644
index 00000000..0a44ec5b
--- /dev/null
+++ b/src/base/vanilla/index.layout.js
@@ -0,0 +1,24 @@
+settings.register({
+ name: 'Disable Game List Resizing',
+ key: 'underscript.disable.adjustSpectateView',
+ refresh: () => onPage(''),
+ category: 'Home',
+});
+
+onPage('', function adjustSpectateView() {
+ if (settings.value('underscript.disable.adjustSpectateView')) return;
+ eventManager.on(':load', () => {
+ const spectate = $('#liste');
+ const tbody = spectate.find('tbody');
+ const footer = $('.mainContent footer');
+ function doAdjustment() {
+ tbody.css({
+ height: 'auto',
+ 'max-height': `${footer.offset().top - spectate.offset().top}px`,
+ });
+ }
+ $('.mainContent > br').remove();
+ doAdjustment();
+ $(window).on('resize.script', doAdjustment);
+ });
+});
diff --git a/src/base/vanilla/index.refresh.js b/src/base/vanilla/index.refresh.js
new file mode 100644
index 00000000..90500169
--- /dev/null
+++ b/src/base/vanilla/index.refresh.js
@@ -0,0 +1,55 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Game List Refresh',
+ key: 'undercards.disable.lobbyRefresh',
+ default: false,
+ category: 'Home',
+ init() {
+ onPage('', setup);
+ },
+ onChange(val) {
+ if (onPage('') && val) setup();
+ },
+ });
+
+ let id;
+ let refreshing = false;
+
+ function clear() {
+ if (id) {
+ clearTimeout(id);
+ id = null;
+ }
+ }
+
+ function refresh() {
+ clear();
+ if (refreshing || document.visibilityState === 'hidden' || setting.value()) return;
+ refreshing = true;
+ axios.get('/').then((response) => {
+ const data = fn.decrypt($(response.data));
+ const list = data.find('#liste');
+ const live = $('#liste');
+ live.find('tbody').html(fn.translate(list.find('tbody')).html());
+ live.prev('p').html(fn.translate(list.prev()).html());
+ }).catch((e) => {
+ fn.debug(`Index: ${e.message}`);
+ }).then(() => {
+ refreshing = false;
+ setup();
+ });
+ }
+
+ function setup(delay = 10000) {
+ clear();
+ id = setTimeout(refresh, delay);
+ }
+
+ onPage('', function refreshGameList() {
+ // Restart refresh sequence when returning to page
+ document.addEventListener('visibilitychange', refresh);
+ // Queue initial refresh
+ setup();
+ fn.infoToast('The game list now refreshes automatically, every 10 seconds.', 'underscript.notice.refreshIndex', '1');
+ });
+});
diff --git a/src/base/vanilla/newContent.js b/src/base/vanilla/newContent.js
new file mode 100644
index 00000000..09825675
--- /dev/null
+++ b/src/base/vanilla/newContent.js
@@ -0,0 +1,73 @@
+wrap(() => {
+ const bundle = settings.register({
+ name: 'Enable bundle toast',
+ key: 'underscript.toast.bundle',
+ default: true,
+ refresh: () => onPage(''),
+ category: 'Home',
+ // TODO: Always hide bundles?
+ });
+ const skin = settings.register({
+ name: 'Enable skin toast',
+ key: 'underscript.toast.skins',
+ default: true,
+ refresh: () => onPage(''),
+ category: 'Home',
+ });
+ const emotes = settings.register({
+ name: 'Enable emote toast',
+ key: 'underscript.toast.emotes',
+ default: true,
+ refresh: () => onPage(''),
+ category: 'Home',
+ });
+
+ onPage('', () => {
+ eventManager.on(':loaded', function toasts() {
+ if (bundle.value()) toast('bundle');
+ if (skin.value()) toast('skins');
+ if (emotes.value()) toast('emotes');
+ });
+ });
+
+ function toast(type) {
+ const names = [];
+ const links = [];
+ [...document.querySelectorAll(`p a[href="${selector(type)}"] img`)].forEach((el) => {
+ names.push(imageName(el.src));
+ links.push(el.parentElement.outerHTML);
+ el.parentElement.remove();
+ });
+ const prefix = `underscript.dismiss.${type}.`;
+ const key = `${prefix}${names.join(',')}`;
+ fn.cleanData(prefix, key);
+ if (settings.value(key)) return;
+ fn.dismissable({
+ key,
+ text: links.join(' '),
+ title: title(type),
+ });
+ }
+
+ function title(type) {
+ switch (type) {
+ case 'bundle': return 'New Bundle Available';
+ case 'skins': return 'New skins / avatars !';
+ case 'emotes': return 'New Emotes Available';
+ default: throw new Error(`Unknown Type: ${type}`);
+ }
+ }
+
+ function selector(type) {
+ switch (type) {
+ case 'bundle': return 'Bundle';
+ case 'skins': return 'CardSkinsShop';
+ case 'emotes': return 'CosmeticsShop';
+ default: throw new Error(`Unknown Type: ${type}`);
+ }
+ }
+
+ function imageName(src) {
+ return src.substring(src.lastIndexOf('/') + 1, src.lastIndexOf('.'));
+ }
+});
diff --git a/src/base/vanilla/patch.menu.js b/src/base/vanilla/patch.menu.js
new file mode 100644
index 00000000..5c0d989b
--- /dev/null
+++ b/src/base/vanilla/patch.menu.js
@@ -0,0 +1,6 @@
+menu.addButton({
+ text: 'Game Patch Notes',
+ action() {
+ window.location = './gameUpdates.jsp';
+ },
+});
diff --git a/src/base/vanilla/patch.message.js b/src/base/vanilla/patch.message.js
new file mode 100644
index 00000000..5de6120b
--- /dev/null
+++ b/src/base/vanilla/patch.message.js
@@ -0,0 +1,34 @@
+settings.register({
+ name: 'Disable version toast',
+ key: 'underscript.season.disable',
+ refresh: () => onPage(''),
+ category: 'Home',
+});
+
+onPage('', function patches() {
+ if (settings.value('underscript.season.disable')) return;
+ eventManager.on(':loaded', () => {
+ document.querySelectorAll('.infoIndex').forEach((el) => {
+ const patch = el.querySelector('[data-i18n-custom="home-patch-message"]');
+ if (!patch) return;
+ const element = $(el);
+ const version = patch.dataset.i18nArgs;
+ el.remove();
+ const prefix = 'underscript.season.dismissed.';
+ const key = `${prefix}${version}`;
+ fn.cleanData(prefix, key);
+ eventManager.on('translation:loaded', () => {
+ const translateElement = global('translateElement');
+ element.find('[data-i18n-custom],[data-i18n]').each((i, e) => translateElement($(e)));
+ const value = element.text();
+ if (localStorage.getItem(key) === value) return;
+ fn.dismissable({
+ key,
+ text: element.html(),
+ title: `Undercards Update`,
+ value,
+ });
+ });
+ });
+ });
+});
diff --git a/src/base/vanilla/quest.highlight.js b/src/base/vanilla/quest.highlight.js
new file mode 100644
index 00000000..bd4792cc
--- /dev/null
+++ b/src/base/vanilla/quest.highlight.js
@@ -0,0 +1,77 @@
+wrap(() => {
+ const setting = settings.register({
+ name: 'Disable Quest Completed Notifications',
+ key: 'underscript.disable.questHighlight',
+ });
+
+ if (setting.value()) return; // TODO: Split into a setting to disable just the toast and a setting to disable highlighting.
+ const questSelector = 'input[type="submit"][value="Claim"]:not(:disabled)';
+
+ eventManager.on(':loaded', () => $el.removeClass(document.querySelectorAll('.yellowLink[href="Quests"]'), 'yellowLink'));
+ style.add('a.highlightQuest {color: gold !important;}');
+
+ function highlightQuest() {
+ if (localStorage.getItem('underscript.quest.clear')) {
+ $('a[href="Quests"]').addClass('highlightQuest');
+ }
+ }
+
+ function clearHighlight() {
+ localStorage.removeItem('underscript.quest.clear');
+ }
+
+ function checkHighlight() {
+ axios.get('/Quests').then((response) => {
+ const data = $(response.data);
+ const quests = data.find(questSelector);
+ if (quests.length) {
+ localStorage.setItem('underscript.quest.clear', true);
+ if (onPage('Game')) {
+ let questsCleared = '';
+ quests.each((i, e) => {
+ questsCleared += `- ${fn.translate($(e).parentsUntil('tbody', 'tr').find('span[data-i18n-custom]:first')).text()}\n`;
+ });
+ fn.toast({
+ title: 'Quest Completed!',
+ text: `${questsCleared}Click to go to Quests page`,
+ onClose: () => {
+ location.href = '/Quests';
+ },
+ });
+ } else {
+ highlightQuest();
+ }
+ } else {
+ // Perhaps another tab found a quest at some point...?
+ clearHighlight();
+ }
+ }).catch(noop());
+ }
+
+ if (!localStorage.getItem('underscript.quest.clear')) {
+ if (!localStorage.getItem('underscript.quest.skip')) {
+ onPage('', checkHighlight);
+ }
+ eventManager.on('getVictory getDefeat', checkHighlight);
+ }
+
+ eventManager.on('logout', clearHighlight);
+
+ eventManager.on('jQuery', function questHighlight() {
+ const quests = $('a[href="Quests"]');
+ if (quests.length) {
+ if (quests.text().includes('(0)')) {
+ localStorage.setItem('underscript.quest.skip', true);
+ clearHighlight();
+ } else {
+ localStorage.removeItem('underscript.quest.skip');
+ }
+ }
+
+ if (onPage('Quests') && !$(questSelector).length) {
+ clearHighlight();
+ }
+
+ highlightQuest();
+ });
+});
diff --git a/src/base/vanilla/settings.js b/src/base/vanilla/settings.js
new file mode 100644
index 00000000..94b72be8
--- /dev/null
+++ b/src/base/vanilla/settings.js
@@ -0,0 +1,72 @@
+[
+ {
+ name: 'Disable rainbow chat',
+ key: 'chatRainbowDisabled',
+ category: 'Chat',
+ },
+ {
+ name: 'Disable chat sounds',
+ key: 'chatSoundsDisabled',
+ category: 'Chat',
+ },
+ {
+ name: 'Disable chat avatars',
+ key: 'chatAvatarsDisabled',
+ category: 'Chat',
+ },
+ {
+ name: 'Disable shiny card animation',
+ key: 'gameShinyDisabled',
+ category: 'Game',
+ },
+ {
+ name: 'Disable game music',
+ key: 'gameMusicDisabled',
+ category: 'Game',
+ },
+ {
+ name: 'Disable game sounds',
+ key: 'gameSoundsDisabled',
+ category: 'Game',
+ },
+ {
+ name: 'Disable profile skins',
+ key: 'profileSkinsDisabled',
+ category: 'Game',
+ },
+ {
+ name: 'Disable screen shake',
+ key: 'shakeDisabled',
+ category: 'Game',
+ },
+ {
+ name: 'Disable emotes',
+ key: 'gameEmotesDisabled',
+ category: 'Game',
+ },
+ { 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: '' },
+].forEach((setting) => {
+ const { name, key, category } = setting;
+ const refresh = category === 'Game' ? () => onPage('Game') || onPage('gameSpectating') : undefined;
+ settings.register({
+ name,
+ key,
+ category,
+ refresh,
+ page: 'game',
+ remove: true,
+ 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..5724ecf7
--- /dev/null
+++ b/src/base/vanilla/tippy.js
@@ -0,0 +1,12 @@
+wrap(() => {
+ // todo: Setting?
+ eventManager.on(':loaded', () => {
+ const tippy = global('tippy', { throws: false });
+ if (!tippy) return;
+ const defaults = tippy.setDefaultProps || tippy.setDefaults;
+ defaults({
+ theme: 'undercards',
+ animateFill: false,
+ });
+ });
+});
diff --git a/src/hooks/analytics.js b/src/hooks/analytics.js
new file mode 100644
index 00000000..8042bb4b
--- /dev/null
+++ b/src/hooks/analytics.js
@@ -0,0 +1,48 @@
+// This setting doesn't do anything, nor does the detection work.
+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 analytics = wrap(() => { // eslint-disable-line no-unused-vars
+ const config = {
+ app_name: 'underscript',
+ app_version: scriptVersion,
+ version: scriptVersion,
+ handler: GM_info.scriptHandler,
+ anonymize_ip: true, // I don't care about IP addresses, don't track this
+ custom_map: {
+ dimension1: 'version',
+ },
+ };
+ if (sessionStorage.getItem('UserID')) {
+ // This gives me a truer user count, by joining all hits from the same user together
+ config.user_id = sessionStorage.getItem('UserID');
+ }
+ window.dataLayer = window.dataLayer || [];
+ gtag('js', new Date());
+ gtag('config', 'UA-38424623-4', config);
+
+ function gtag() {
+ dataLayer.push(arguments); // eslint-disable-line no-undef, prefer-rest-params
+ }
+ function send(...args) {
+ if (!args.length) return;
+ gtag('event', ...args);
+ }
+ function error(description, fatal = false) {
+ send('exception', { description, fatal });
+ }
+ return {
+ send, error,
+ };
+});
diff --git a/src/hooks/chat.js b/src/hooks/chat.js
new file mode 100644
index 00000000..b4d8a6cb
--- /dev/null
+++ b/src/hooks/chat.js
@@ -0,0 +1,43 @@
+eventManager.on(':loaded', () => {
+ if (typeof socketChat !== 'undefined') {
+ debug('Chat detected');
+ eventManager.emit('ChatDetected');
+
+ const socketChat = global('socketChat');
+ const oHandler = socketChat.onmessage;
+ socketChat.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ const { action } = data;
+ debug(data, `debugging.rawchat.${action}`);
+
+ // Populate chatroom names
+ if (action === 'getHistory') {
+ chatRoomNames[data.room] = fn.translateText(data.roomName);
+ }
+ if (eventManager.cancelable.emit(`preChat:${action}`, data).canceled) return;
+ oHandler(event);
+ eventManager.emit('ChatMessage', data);
+ eventManager.emit(`Chat:${action}`, data);
+ };
+ 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.which !== 13) 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();
+ }
+ });
+ });
+ }
+});
diff --git a/src/hooks/command.js b/src/hooks/command.js
new file mode 100644
index 00000000..471cd308
--- /dev/null
+++ b/src/hooks/command.js
@@ -0,0 +1,12 @@
+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..90a837fa
--- /dev/null
+++ b/src/hooks/craft.card.js
@@ -0,0 +1,39 @@
+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 r = xhr.responseJSON;
+ const success = r.status === 'success';
+ if (data.action === 'craft') {
+ if (success) {
+ eventManager.emit('craftcard', {
+ id: r.cardId,
+ name: r.cardName,
+ dust: r.dust,
+ shiny: r.shiny,
+ });
+ }
+ } else if (data.action === 'disenchant') {
+ // TODO
+ } else if (data.action === 'auto') {
+ // TODO
+ }
+ });
+ });
+
+ eventManager.on(':loaded', () => {
+ globalSet('showPage', function showPage(...args) {
+ const prevPage = global('currentPage');
+ this.super(...args);
+ eventManager.emit('Craft:RefreshPage', {
+ page: global('currentPage'),
+ prev: prevPage,
+ });
+ });
+ });
+});
diff --git a/src/hooks/custom.js b/src/hooks/custom.js
new file mode 100644
index 00000000..34adb89d
--- /dev/null
+++ b/src/hooks/custom.js
@@ -0,0 +1,14 @@
+onPage('GamesList', function customGame() {
+ eventManager.on(':loaded', () => {
+ eventManager.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..e792cf5b
--- /dev/null
+++ b/src/hooks/deck.js
@@ -0,0 +1,30 @@
+onPage('Decks', function deckPage() {
+ debug('Deck editor');
+ eventManager.on('jQuery', () => {
+ $(document).ajaxSuccess((event, xhr, options) => {
+ if (options.url !== 'DecksConfig' || !options.data) return;
+ const data = JSON.parse(options.data);
+ const obj = Object.freeze({ data, options, xhr });
+ eventManager.emit('Deck:Change', obj);
+ eventManager.emit(`Deck:${data.action}`, obj);
+ });
+ $(document).ajaxComplete((event, xhr, options) => {
+ if (options.url !== 'DecksConfig') return;
+ if (options.type === 'GET') {
+ eventManager.emit('Deck:Loaded', xhr.responseJSON);
+ return;
+ }
+ const data = JSON.parse(options.data);
+ const obj = Object.freeze({ data, options, xhr });
+ eventManager.emit('Deck:postChange', obj);
+ eventManager.emit(`Deck:${data.action}`, obj);
+ });
+ // Class change
+ $('#selectSouls').change(function soulChange() {
+ // Sometimes it takes too long, so lets change it now
+ const val = $(this).val();
+ globalSet('soul', val);
+ eventManager.emit('Deck:Soul', val);
+ });
+ });
+});
diff --git a/src/hooks/friends.js b/src/hooks/friends.js
new file mode 100644
index 00000000..836720cf
--- /dev/null
+++ b/src/hooks/friends.js
@@ -0,0 +1,51 @@
+// Friends list hooks. TODO: only work if logged in
+wrap(() => {
+ function getFromEl(el) {
+ const link = el.find('a:first').attr('href');
+ const id = link.substring(link.indexOf('=') + 1);
+ const name = el.text().substring(0, el.text().lastIndexOf(' LV '));
+ return { id, name };
+ }
+
+ let validated = 0;
+
+ function loadFriends(validate) {
+ if (typeof window.jQuery === 'undefined') return undefined;
+ return axios.get('/Friends').then((response) => {
+ const data = fn.decrypt($(response.data));
+ /*
+ if (data.find(`p:contains(You can't access)`)) {
+ // TODO: stop processing?
+ debug("Can't access friends");
+ return;
+ }
+ */
+ const requests = {};
+ // const pending = {};
+ data.find('p:contains(Friend requests)').parent().children('li').each(function fR() {
+ const f = getFromEl($(this));
+ requests[f.id] = f.name;
+ });
+ const count = Object.keys(requests).length;
+ if (count !== validated && count > 3 && !validate) {
+ return loadFriends(validate);
+ }
+ if (validate) {
+ validated = count;
+ if (validate !== count) fn.debug(`Friends: Validation failed (found ${validate}, got ${count})`);
+ }
+
+ // eventManager.emit('Friends:pending', pending);
+ eventManager.emit('preFriends:requests', requests);
+ eventManager.emit('Friends:requests', requests);
+ eventManager.emit('Friends:refresh');
+ return undefined;
+ }).catch((e) => {
+ fn.debug(`Friends: ${e.message}`);
+ }).then(() => {
+ sleep(10000).then(loadFriends);
+ });
+ }
+
+ sleep(10000).then(loadFriends);
+});
diff --git a/src/hooks/friendship.js b/src/hooks/friendship.js
new file mode 100644
index 00000000..a4089db1
--- /dev/null
+++ b/src/hooks/friendship.js
@@ -0,0 +1,35 @@
+onPage('Friendship', () => {
+ eventManager.on(':loaded', () => {
+ $(document).ajaxComplete((event, xhr, settings) => {
+ if (settings.url !== 'FriendshipConfig') return;
+ if (settings.type === 'GET') {
+ eventManager.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,
+ quantity,
+ claim,
+ });
+ } else if (data.status === 'errorMaintenance') {
+ eventManager.emit('Friendship:claim', {
+ error: JSON.parse(data.message),
+ });
+ }
+ } else {
+ eventManager.emit('Friendship:claim', {
+ error: true,
+ });
+ }
+ });
+ });
+});
diff --git a/src/hooks/game.event.js b/src/hooks/game.event.js
new file mode 100644
index 00000000..a4badc4f
--- /dev/null
+++ b/src/hooks/game.event.js
@@ -0,0 +1,24 @@
+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
+ fn.debug(`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) {
+ fn.debug(`Unknown action: ${data.action}`);
+ }
+ });
+ eventManager.on('PreGameEvent', function callPreEvent(data) {
+ if (finished) return;
+ const event = eventManager.emit(`${data.action}:before`, data, this.cancelable);
+ if (!event.ran) return;
+ this.canceled = event.canceled;
+ });
+ eventManager.on('getVictory getDefeat getResult', function finish() {
+ finished = true;
+ });
+});
diff --git a/src/hooks/game.js b/src/hooks/game.js
new file mode 100644
index 00000000..d3ce814a
--- /dev/null
+++ b/src/hooks/game.js
@@ -0,0 +1,31 @@
+wrap(() => {
+ function gameHook() {
+ debug('Playing Game');
+ eventManager.singleton.emit('GameStart');
+ eventManager.singleton.emit('PlayingGame');
+ eventManager.on(':loaded', () => {
+ function callGameHooks(data, original) {
+ const run = !eventManager.cancelable.emit('PreGameEvent', data).canceled;
+ try {
+ if (run) original(data);
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+ }
+ 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..f9cd25cb
--- /dev/null
+++ b/src/hooks/hotkeys.hotkey.js
@@ -0,0 +1,20 @@
+/* global hotkeys */
+eventManager.on(':loaded', function always() {
+ // Bind hotkey listeners
+ document.addEventListener('mouseup', (event) => {
+ // if (false) return; // TODO: Check for clicking in chat
+ hotkeys.forEach((v) => {
+ if (v.clickbound(event.which)) {
+ v.run(event);
+ }
+ });
+ });
+ document.addEventListener('keyup', (event) => {
+ if (event.target.tagName === 'INPUT') return; // We don't want to listen while typing in chat (maybe listen for F-keys?)
+ hotkeys.forEach((v) => {
+ if (v.keybound(event.which)) {
+ v.run(event);
+ }
+ });
+ });
+});
diff --git a/src/hooks/logout.js b/src/hooks/logout.js
new file mode 100644
index 00000000..2d330170
--- /dev/null
+++ b/src/hooks/logout.js
@@ -0,0 +1,3 @@
+onPage('Disconnect', function logout() {
+ eventManager.emit('logout');
+});
diff --git a/src/hooks/play.js b/src/hooks/play.js
new file mode 100644
index 00000000..addb43c2
--- /dev/null
+++ b/src/hooks/play.js
@@ -0,0 +1,37 @@
+onPage('Play', () => {
+ debug('On play page');
+
+ eventManager.on(':loaded', function hook() {
+ if (undefined !== window.bypassQueueEvents) {
+ location.href = '/Game';
+ return;
+ }
+ debug('Play:Loaded');
+ function opened() {
+ eventManager.emit('socketOpen');
+ }
+ globalSet('onOpen', function onOpen(event) {
+ this.super(event);
+ opened();
+ });
+ globalSet('onMessage', function onMessage(event) {
+ const data = JSON.parse(event.data);
+ try {
+ this.super(event);
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+ }
+ 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.onopen = global('onOpen');
+ socketQueue.onmessage = global('onMessage');
+ }
+ });
+});
diff --git a/src/hooks/session.js b/src/hooks/session.js
new file mode 100644
index 00000000..b35a7283
--- /dev/null
+++ b/src/hooks/session.js
@@ -0,0 +1,9 @@
+eventManager.on('Chat:getSelfInfos', () => {
+ const sessID = sessionStorage.getItem('UserID');
+ const selfId = global('selfId');
+ if (sessID && sessID === selfId) return;
+ sessionStorage.setItem('UserID', selfId);
+});
+eventManager.on('logout', () => {
+ sessionStorage.removeItem('UserID');
+});
diff --git a/src/hooks/spectate.js b/src/hooks/spectate.js
new file mode 100644
index 00000000..eaa65478
--- /dev/null
+++ b/src/hooks/spectate.js
@@ -0,0 +1,27 @@
+onPage('Spectate', () => {
+ debug('Spectating Game');
+ eventManager.emit('GameStart');
+
+ eventManager.on(':loaded', () => {
+ function callGameHooks(data, original) {
+ const run = !eventManager.emit('PreGameEvent', data, data.action === 'getResult').canceled;
+ try {
+ if (run) original(data);
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+ }
+ 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..1c41dbf2
--- /dev/null
+++ b/src/hooks/translation.loaded.js
@@ -0,0 +1,6 @@
+eventManager.on(':loaded', () => {
+ globalSet('translateElement', function translateElement(...args) {
+ eventManager.singleton.emit('translation:loaded');
+ return this.super(...args);
+ });
+});
diff --git a/src/hooks/unload.js b/src/hooks/unload.js
new file mode 100644
index 00000000..18d323e4
--- /dev/null
+++ b/src/hooks/unload.js
@@ -0,0 +1,11 @@
+eventManager.on(':loaded', () => {
+ function unload() {
+ eventManager.emit(':unload');
+ }
+ function last() {
+ const chat = window.socketChat;
+ if (chat && chat.readyState <= WebSocket.OPEN) chat.close();
+ }
+ window.onbeforeunload = unload;
+ window.onunload = last;
+});
diff --git a/src/hooks/z-jQuery.js b/src/hooks/z-jQuery.js
new file mode 100644
index 00000000..b2fcaffd
--- /dev/null
+++ b/src/hooks/z-jQuery.js
@@ -0,0 +1,5 @@
+// Attempt to detect jQuery
+eventManager.on(':loaded', () => {
+ if (typeof jQuery === 'undefined') return;
+ eventManager.emit('jQuery');
+});
diff --git a/src/hooks/zz.loaded.js b/src/hooks/zz.loaded.js
new file mode 100644
index 00000000..b986c1cb
--- /dev/null
+++ b/src/hooks/zz.loaded.js
@@ -0,0 +1,23 @@
+console.log(`UnderScript(v${scriptVersion}): Loaded`); // eslint-disable-line no-console
+eventManager.on(':ready', () => {
+ function loaded() {
+ eventManager.singleton.emit(':loaded');
+ }
+ function done() {
+ eventManager.singleton.emit(':load');
+ }
+
+ document.addEventListener('DOMContentLoaded', loaded);
+ window.addEventListener('load', done);
+ const COMPLETE = document.readyState === 'complete';
+ if (document.readyState === 'interactive' || COMPLETE) {
+ loaded();
+ }
+ if (COMPLETE) {
+ done();
+ }
+});
+
+if (eventManager.singleton.emit(':ready').ran) {
+ api.register('ready', true);
+}
diff --git a/src/meta.js b/src/meta.js
new file mode 100644
index 00000000..2167219b
--- /dev/null
+++ b/src/meta.js
@@ -0,0 +1,24 @@
+// ==UserScript==
+// @name UnderCards script
+// @description Various changes to undercards game
+// @version {{ version }}
+// @author feildmaster
+// @run-at document-start
+// @match https://*.undercards.net/*
+// @match https://feildmaster.github.io/UnderScript/*
+// @exclude https://*.undercards.net/*/*
+// @require https://unpkg.com/showdown@1.9.0/dist/showdown.min.js
+// @require https://unpkg.com/popper.js@1/dist/umd/popper.min.js
+// @require https://unpkg.com/tippy.js@4.2.1/umd/index.all.min.js
+// @require https://unpkg.com/axios@0.18.0/dist/axios.min.js
+// @require https://raw.githubusercontent.com/feildmaster/SimpleToast/2.0.0/simpletoast.js
+// @require https://unpkg.com/luxon@1.8.2/build/global/luxon.min.js
+// @require https://www.googletagmanager.com/gtag/js?id=UA-38424623-4
+// @homepage https://feildmaster.github.io/UnderScript/
+// @source https://github.com/feildmaster/UnderScript
+// @supportURL https://github.com/feildmaster/UnderScript/issues
+// @updateURL https://unpkg.com/underscript/dist/undercards.meta.js
+// @downloadURL https://unpkg.com/underscript/dist/undercards.user.js
+// @namespace https://feildmaster.com/
+// @grant none
+// ==/UserScript==
diff --git a/src/utils/.eslintrc.js b/src/utils/.eslintrc.js
new file mode 100644
index 00000000..7187d9f7
--- /dev/null
+++ b/src/utils/.eslintrc.js
@@ -0,0 +1,7 @@
+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..9225ee8e
--- /dev/null
+++ b/src/utils/0.publicist.js
@@ -0,0 +1,11 @@
+if (!location.host.includes('undercards.net')) {
+ function setup() { // eslint-disable-line no-inner-declarations
+ if (typeof setVersion === 'function') setVersion(GM_info.script.version, GM_info.scriptHandler);
+ }
+ if (document.readyState === 'complete') {
+ setup();
+ } else {
+ window.addEventListener('load', setup);
+ }
+ return; // eslint-disable-line no-useless-return
+}
diff --git a/src/utils/1.variables.js b/src/utils/1.variables.js
new file mode 100644
index 00000000..4480a5b0
--- /dev/null
+++ b/src/utils/1.variables.js
@@ -0,0 +1,10 @@
+const script = this;
+const footer = 'UnderScript ©feildmaster
';
+const footer2 = 'via UnderScript
';
+const hotkeys = [];
+const chatRoomNames = {};
+const pendingIgnore = new VarStore();
+const scriptVersion = GM_info.script.version;
+const fn = {};
+
+function noop() {}
diff --git a/src/utils/2.pokemon.js b/src/utils/2.pokemon.js
new file mode 100644
index 00000000..a5615de5
--- /dev/null
+++ b/src/utils/2.pokemon.js
@@ -0,0 +1,8 @@
+function wrap(callback, prefix = '') {
+ try {
+ return callback();
+ } catch (e) {
+ console.error(`${prefix ? `[${prefix}] ` : callback && callback.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..ebf7d85f
--- /dev/null
+++ b/src/utils/2.toasts.js
@@ -0,0 +1,93 @@
+fn.toast = (arg) => {
+ // Why do I even check for SimpleToast? It *has* to be loaded at this point...
+ if (!window.SimpleToast || !arg) return false;
+ if (typeof arg === 'string') {
+ arg = {
+ text: arg,
+ };
+ }
+ const defaults = {
+ footer: 'via UnderScript',
+ css: {
+ 'background-color': 'rgba(0,5,20,0.6)',
+ 'text-shadow': '',
+ 'font-family': 'monospace',
+ footer: {
+ 'text-align': 'end',
+ },
+ },
+ };
+ return new SimpleToast(fn.merge(defaults, arg));
+};
+
+fn.errorToast = (error) => {
+ function getStack(err = {}) {
+ const stack = err.stack;
+ if (stack) {
+ return stack.replace('<', '<');
+ }
+ return null;
+ }
+
+ const toast = {
+ title: error.name || error.title || 'Error',
+ text: error.message || error.text || getStack(error.error || error) || error,
+ css: {
+ 'background-color': 'rgba(200,0,0,0.6)',
+ },
+ };
+ if (error.footer) {
+ toast.footer = error.footer;
+ }
+ return fn.toast(toast);
+};
+
+fn.infoToast = (arg, key, val) => {
+ 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: 'Did you know?',
+ css: {
+ 'font-family': 'inherit',
+ },
+ };
+ return fn.toast(fn.merge(defaults, arg, override));
+};
+
+fn.dismissable = ({ title, text, key, value = true }) => {
+ const buttons = {
+ text: 'Dismiss',
+ className: 'dismiss',
+ css: {
+ border: '',
+ height: '',
+ background: '',
+ 'font-size': '',
+ margin: '',
+ 'border-radius': '',
+ },
+ onclick: (e) => {
+ localStorage.setItem(key, value);
+ },
+ };
+ return fn.toast({
+ title,
+ text,
+ buttons,
+ className: 'dismissable',
+ });
+};
diff --git a/src/utils/3.doublecheck.js b/src/utils/3.doublecheck.js
new file mode 100644
index 00000000..9c48344f
--- /dev/null
+++ b/src/utils/3.doublecheck.js
@@ -0,0 +1,3 @@
+/* eslint-disable no-underscore-dangle */
+if (window._UnderScript) throw new Error('UnderScript loaded twice');
+window._UnderScript = scriptVersion;
diff --git a/src/utils/VarStore.js b/src/utils/VarStore.js
new file mode 100644
index 00000000..71b9b812
--- /dev/null
+++ b/src/utils/VarStore.js
@@ -0,0 +1,25 @@
+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;
+ }
+
+ return {
+ get, set, peak, isSet,
+ };
+}
diff --git a/src/utils/active.js b/src/utils/active.js
new file mode 100644
index 00000000..8a6b595a
--- /dev/null
+++ b/src/utils/active.js
@@ -0,0 +1 @@
+fn.active = () => document.visibilityState === 'visible';
diff --git a/src/utils/api.js b/src/utils/api.js
new file mode 100644
index 00000000..564bd0fc
--- /dev/null
+++ b/src/utils/api.js
@@ -0,0 +1,17 @@
+const api = wrap(() => {
+ const underscript = {
+ version: scriptVersion,
+ };
+
+ window.underscript = underscript;
+
+ function register(name, val) {
+ if (Object.prototype.hasOwnProperty.call(underscript, name)) throw new Error('Variable already exposed');
+
+ underscript[name] = val;
+ }
+
+ return {
+ register,
+ };
+});
diff --git a/src/utils/cardHelper.js b/src/utils/cardHelper.js
new file mode 100644
index 00000000..a73136af
--- /dev/null
+++ b/src/utils/cardHelper.js
@@ -0,0 +1,112 @@
+const cardHelper = wrap(() => {
+ const unset = [undefined, null];
+ 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 'GENERATED': return 0;
+ default:
+ debug(`Unknown rarity: ${rarity}`);
+ return undefined;
+ }
+ }
+
+ function isShiny(el) {
+ return el.classList.contains('shiny');
+ }
+
+ 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];
+ }
+
+ function name(el) {
+ return el.querySelector('.cardName').textContent;
+ }
+
+ function rarity(el) {
+ return getCardData(el.id).rarity;
+ }
+
+ function quantity(el) {
+ return parseInt(el.querySelector('.cardQuantity .nb, #quantity .nb, .quantity .nb').textContent, 10);
+ }
+
+ function cost(el) {
+ return parseInt(el.querySelector('.cardCost').textContent, 10);
+ }
+
+ function totalDust() {
+ return parseInt(document.querySelector('span#dust').textContent, 10);
+ }
+
+ function dustCost(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ default:
+ 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;
+ }
+ }
+
+ function dustGain(r, s) {
+ if (typeof r === 'object') {
+ if (typeof s !== 'boolean') {
+ s = isShiny(r);
+ }
+ r = rarity(r);
+ }
+ switch (r) {
+ default: fn.debug(`Unknown Rarity: ${r}`); // fallthrough
+ case 'GENERATED': // You can't craft this, but I don't want an error
+ case 'DETERMINATION': return undefined;
+ 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 : 0;
+ }
+ }
+
+ 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}`);
+ }
+
+ return {
+ cost,
+ find,
+ name,
+ rarity,
+ shiny: isShiny,
+ craft: {
+ max,
+ quantity,
+ totalDust,
+ cost: dustCost,
+ worth: dustGain,
+ },
+ };
+});
diff --git a/src/utils/cleanData.js b/src/utils/cleanData.js
new file mode 100644
index 00000000..7c33e7f4
--- /dev/null
+++ b/src/utils/cleanData.js
@@ -0,0 +1,8 @@
+fn.cleanData = 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..4262a56a
--- /dev/null
+++ b/src/utils/clear.js
@@ -0,0 +1 @@
+fn.clear = (obj) => Object.keys(obj).forEach((key) => delete obj[key]);
diff --git a/src/utils/debug.js b/src/utils/debug.js
new file mode 100644
index 00000000..2118f023
--- /dev/null
+++ b/src/utils/debug.js
@@ -0,0 +1,25 @@
+function debug(message, permission = 'debugging', ...extras) {
+ if (!settings.value(permission) && !settings.value('debugging.*')) return;
+ // message.stack = new Error().stack.split('\n').slice(2);
+ console.log(`[${permission}]`, message, ...extras);
+}
+
+fn.debug = (arg, permission = 'debugging') => {
+ if (!settings.value(permission) && !settings.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 fn.toast(fn.merge(defaults, arg));
+};
diff --git a/src/utils/decode.js b/src/utils/decode.js
new file mode 100644
index 00000000..afe60e6c
--- /dev/null
+++ b/src/utils/decode.js
@@ -0,0 +1,3 @@
+fn.decode = function decode(string) {
+ return $('