Beanteki

Shows bean scores on jinteki.net

// ==UserScript==
// @name         Beanteki
// @namespace    https://github.com/nbkelly/jinteki-beanstalk-tracker
// @version      2025-04-22
// @description  Shows bean scores on jinteki.net
// @author       nbkelly
// @match        *.jinteki.net
// @match        *.jinteki.net/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=jinteki.net
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @homepageURL  https://github.com/nbkelly
// ==/UserScript==

// Default: null means "all seasons"
let seasonId = GM_getValue('seasonId', '');

// Add menu command so user can change it
GM_registerMenuCommand("Set Season", () => {
    const current = GM_getValue('seasonId', '');
    const newSeason = prompt("Enter a season ID (current is '3' (lima beans), or leave blank for all seasons):", current);
    if (newSeason !== null) {
        GM_setValue('seasonId', newSeason);
        location.reload(); // refresh to re-fetch with new setting
    }
});

(function() {
    'use strict';

    const script = document.createElement('script');
    script.textContent = `(${injectedWS.toString()})();`;
    document.documentElement.appendChild(script);
    script.remove();

    function injectedWS() {
        const OriginalWebSocket = window.WebSocket;

        window.WebSocket = function (...args) {
            const ws = new OriginalWebSocket(...args);

            ws.addEventListener('message', (event) => {
                if (typeof event.data === 'string') {
                    if (event.data.startsWith('[[[:lobby/list')) {
                        console.log("Lobby listed");
                        window.dispatchEvent(new CustomEvent('ws-decorate', { detail: event.data }));
                    } else if (event.data.startsWith('[[[:game/diff')) {
                        window.dispatchEvent(new CustomEvent('ws-game-diff', { detail: event.data }));
                    }
                }
            });

            return ws;
        };

        window.WebSocket.prototype = OriginalWebSocket.prototype;

        console.log('[Injected Script] WebSocket hooked successfully.');}

    window.addEventListener('ws-decorate', (e) => {
        const { type, data } = e.detail;
        scheduleDecorate();
    });

    window.addEventListener('ws-game-diff', (e) => {
        const { type, data } = e.detail;
        scheduleDecorateUsername();
    });

    const seasonId = GM_getValue('seasonId', '');

    let localSeason = ""
    if (seasonId && seasonId.trim() !== '') {
        localSeason = "?seasonId=" + seasonId.trim();
    }
    const apiURL = 'https://netrunner-beanstalk.net/api/leaderboard' + localSeason;

    function decorateUsernames(userMap) {
        const nodes = document.querySelectorAll('.name-box .username');

        nodes.forEach(node => {
            const name = node.textContent.trim();
            const userData = userMap[name];
            console.log("Name: ", name);

            if (!userData) return;
            if (node.dataset.enhanced) return;

            node.style.color = '#ffaa00'; // gold
            node.style.fondWeight = 'bold';
            node.style.position = 'relative';

            const tooltip = document.createElement('div')
            tooltip.className = "status-tooltip blue-shade"
            tooltip.innerHTML = `
              🏅 Rank: ${userData.rank}<br>
              🫘 Beans: ${userData.points.toFixed(2)}
            `;
            tooltip.style.cssText =`
              position: absolute;
              top: 100%;
              left: 0;
              border-radius: 6px;
              z-index: 9999;
              padding: 6px 10px;
              display: none;
              outline: 2px solid white;
              `

            node.appendChild(tooltip);

            node.addEventListener('mouseenter', () => tooltip.style.display = 'block');
            node.addEventListener('mouseleave', () => tooltip.style.display = 'none');

            node.dataset.enhanced = 'true';
        });
    }

    function decoratePlayers(userMap) {
        const users = document.querySelectorAll('span.user-status');
        users.forEach(span => {
            const nameNode = span.firstChild;
            if (!nameNode || nameNode.nodeType !== Node.TEXT_NODE) return;

            const username = nameNode.textContent.trim();
            const userData = userMap[username];

            if (!userData) return;

            const tooltip = span.querySelector('.status-tooltip');

            if (!tooltip.enhanced) {
                if(tooltip) {
                    // Style the player's name
                    span.style.fontWeight = 'bold';
                    span.style.color = '#ffaa00'; // gold

                    const rankDiv = document.createElement('div');
                    rankDiv.textContent = `🏅 Rank: ${userData.rank}`;
                    tooltip.appendChild(rankDiv);

                    const beansDiv = document.createElement('div');
                    beansDiv.textContent = `🫘 Beans: ${userData.points.toFixed(2)}`;
                    tooltip.appendChild(beansDiv);
                    tooltip.enhanced = "true";
                }
            }
        })}

    const userMetadata = {};
    fetch(apiURL)
        .then(response => response.json())
        .then(data => {
        console.log('Fetched beanstalk data!');

        data.forEach(user => {
            userMetadata[user.user_name] = {
                points: user.points,
                rank: user.rank
            };
        });
    })
        .catch(error => {console.error("API fetch failed:", error);});


    let decorateTimeout;
    function scheduleDecorate() {
        clearTimeout(decorateTimeout);
        decorateTimeout = setTimeout(() => {
            decoratePlayers(userMetadata);
        }, 250);
    }

    // Monitor fetch responses
    const originalFetch = window.fetch;
    window.fetch = function(...args) {
        return originalFetch.apply(this, args).then(response => {
            response.clone().text().then(body => {
                if (body.startsWith('[[[:lobby/list')) {
                    scheduleDecorate();
                }
            });
            return response;
        })
    };

    // Monitor XMLHttpRequest responses
    const open = XMLHttpRequest.prototype.open;
    const send = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function(method, url) {
        this._url = url;
        return open.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function() {
        this.addEventListener('load', function() {
            if (this.responseType == 'text' && this.responseText && this.responseText.startsWith('[[[:lobby/list')) {
                scheduleDecorate();
            }
            if (this.responseType == 'text' && this.responseText && this.responseText.startsWith('[[[:game/diff')) {
                scheduleDecorateUsername();
            }
        });
        return send.apply(this, arguments);
    };

    // Keep track of timing to avoid over-refreshing
    let lastUpdate = 0;
    let pendingTimeout = null;

    const MIN_INTERVAL_MS = 15_000;
    const DELAY_MS = 3_000;

    const now = Date.now();

    function scheduleDecorateUsername() {
        if (pendingTimeout) {
            clearTimeout(pendingTimeout);
            pendingTimeout = null;
        }

        if (now - lastUpdate > MIN_INTERVAL_MS) {
            pendingTimeout = setTimeout(() => {
                console.log('[WS Listener] Updating usernames...');
                if (typeof decorateUsernames === 'function') {
                    decorateUsernames(userMetadata);
                }
                lastUpdate = Date.now();
                pendingTimeout = null;
            }, DELAY_MS);
        }}

})();