Torn Roulette Tracker

Tracks roulette result frequency and highlights top numbers

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Torn Roulette Tracker
// @namespace    https://www.torn.com/
// @version      1.6
// @description  Tracks roulette result frequency and highlights top numbers
// @author       Deviyl[3722358]
// @icon         https://raw.githubusercontent.com/deviyl/icon/refs/heads/main/devicon-modified.png
// @license      CC-BY-NC-ND-4.0
// @match        https://www.torn.com/page.php?sid=roulette*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

// Donations are always appreciated if you find this helpful. <3

/* TERMS OF USE:
     This script is provided under the Creative Commons Attribution-NonCommercial-NoDerivs 4.0
     International License.
       1. AUTHORIZATION: Use of this script is restricted to the users or factions authorized by the developer.
       2. COMMERCIAL USE: This script may not be redistributed or used for commercial purposes.
          Authorization is granted solely by the author and may involve in-game (Torn) currency.
       3. MODIFICATION: You may view the source code for security auditing, but redistribution
          of modified versions ("cracks" or forks) is strictly prohibited.
       4. Owners can add member IDs, change the webhooks, or RoleIDs if applicable.
    To request authorization and/or custom script for you or your faction, please contact Deviyl[3722358] in-game.
 */

/* TORN API DISCLOSURE & USAGE:
    This script requires your Torn API key to retrieve authorized game data.
    Your API key is stored locally in your browser only and is never transmitted, shared, or sent to any external server. All API requests are made directly from your browser to Torn's official API.
    You may revoke your API key at any time via your Torn account settings.
*/

(function () {
    'use strict';

    // ------------------------------------
    // CONFIGURATION
    // ------------------------------------
    const KEY_API = 'rouletteAPIKey';
    const KEY_FREQ = 'rouletteFrequency';
    const KEY_TIMESTAMP = 'rouletteLastTimestamp';

    const API_BASE = 'https://api.torn.com/v2/user/log';
    const LOG_TYPES = '8305,8306';
    const INITIAL_LIMIT = 100;
    const UPDATE_LIMIT = 20;

    const RED_NUMBERS = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];
    const BLACK_NUMBERS = [2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35];

    // ------------------------------------
    // GM STORAGE HELPERS
    // ------------------------------------
    function getAPIKey() {
        return GM_getValue(KEY_API, '');
    }

    function saveAPIKey(key) {
        GM_setValue(KEY_API, key);
    }

    function getFrequency() {
        const raw = GM_getValue(KEY_FREQ, null);
        return raw ? JSON.parse(raw) : {};
    }

    function saveFrequency(freq) {
        GM_setValue(KEY_FREQ, JSON.stringify(freq));
    }

    function getLastTimestamp() {
        return GM_getValue(KEY_TIMESTAMP, 0);
    }

    function saveLastTimestamp(ts) {
        GM_setValue(KEY_TIMESTAMP, ts);
    }

    // ------------------------------------
    // API HELPERS
    // ------------------------------------
    async function fetchLogs(url) {
        const resp = await fetch(url);
        if (!resp.ok) throw new Error(`API error ${resp.status}`);
        const json = await resp.json();
        if (json.error) throw new Error(`Torn API: ${json.error.error}`);
        return json.log || [];
    }

    async function initialLoad(apiKey) {
        const url = `${API_BASE}?log=${LOG_TYPES}&limit=${INITIAL_LIMIT}&key=${apiKey}`;
        const logs = await fetchLogs(url);

        const freq = {};
        let newest = 0;

        for (const entry of logs) {
            const result = String(entry.data?.result);
            if (result === 'undefined') continue;
            freq[result] = (freq[result] || 0) + 1;
            if (entry.timestamp > newest) newest = entry.timestamp;
            console.log(`[Roulette Tracker] Result added: ${result}`);
        }

        saveFrequency(freq);
        saveLastTimestamp(newest);
        return freq;
    }

    async function incrementalUpdate(apiKey) {
        const lastTs = getLastTimestamp();
        const fromTs = lastTs + 1;
        const url = `${API_BASE}?log=${LOG_TYPES}&limit=${UPDATE_LIMIT}&from=${fromTs}&key=${apiKey}`;
        const logs = await fetchLogs(url);

        if (!logs.length) return getFrequency();

        const freq = getFrequency();
        let newest = lastTs;

        for (const entry of logs) {
            const result = String(entry.data?.result);
            if (result === 'undefined') continue;
            freq[result] = (freq[result] || 0) + 1;
            if (entry.timestamp > newest) newest = entry.timestamp;
            console.log(`[Roulette Tracker] Result added: ${result}`);
        }

        saveFrequency(freq);
        saveLastTimestamp(newest);
        return freq;
    }

    // ------------------------------------
    // TOP-N CALCULATION
    // ------------------------------------
    function getTop10Numbers(freq) {
        const sorted = Object.entries(freq)
            .map(([num, count]) => ({ num, count }))
            .sort((a, b) => b.count - a.count);

        if (!sorted.length) return [];

        const cutoffCount = sorted[Math.min(9, sorted.length - 1)].count;

        return sorted
            .filter(x => x.count >= cutoffCount)
            .map(x => x.num);
    }

    // ------------------------------------
    // STYLE
    // ------------------------------------
    GM_addStyle(`#rouletteFreqPanel { margin-top: 10px; font-family: monospace; font-size: 12px; width: 100%; box-sizing: border-box; position: relative; z-index: 1000; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-header { display: flex; justify-content: space-between; align-items: center; background: #2a2a2a; padding: 6px 8px; border-bottom: 1px solid #444; position: relative; z-index: 1001; cursor: pointer; user-select: none; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-header:hover { background: #333; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-header-left { display: flex; align-items: center; gap: 8px; flex: 1; }`);
    GM_addStyle(`#rouletteFreqPanel .collapse-icon { font-size: 10px; color: #888; transition: transform 0.2s; order: -1; }`);
    GM_addStyle(`#rouletteFreqPanel .collapse-icon.collapsed { transform: rotate(-90deg); }`);
    GM_addStyle(`#rouletteFreqPanel .freq-header h3 { margin: 0; color: #ccc; font-size: 13px; font-weight: bold; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-header .total-spins { color: #888; font-size: 11px; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-settings { cursor: pointer !important; font-size: 16px; user-select: none; padding: 0 4px; display: inline-block; position: relative; z-index: 1002; }`);
    GM_addStyle(`#rouletteFreqPanel .freq-settings:hover { opacity: 0.7 !important; }`);
    GM_addStyle(`#rouletteFreqPanel table { width: 100%; border-collapse: collapse; }`);
    GM_addStyle(`#rouletteFreqPanel th { background: #2a2a2a; color: #ccc; padding: 4px 8px; text-align: left; font-weight: bold; border-bottom: 1px solid #444; }`);
    GM_addStyle(`#rouletteFreqPanel td { padding: 3px 8px; border-bottom: 1px solid #333; color: #eee; }`);
    GM_addStyle(`#rouletteFreqPanel tr:nth-child(even) td { background: #1e1e1e; }`);
    GM_addStyle(`#rouletteFreqPanel tr:nth-child(odd) td { background: #252525; }`);
    GM_addStyle(`#rouletteFreqPanel tbody tr:hover td { background: #2d2d2d !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.freq-top td { background: rgba(0, 122, 61, 0.3) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.freq-top:hover td { background: rgba(0, 122, 61, 0.4) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.freq-hot td { background: rgba(0, 230, 118, 0.2) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.freq-hot:hover td { background: rgba(0, 230, 118, 0.3) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.special-top td { background: rgba(0, 122, 61, 0.3) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.special-top:hover td { background: rgba(0, 122, 61, 0.4) !important; }`);
    GM_addStyle(`#rouletteFreqPanel tr.category-separator td { border-top: 3px solid #fff !important; }`);
    GM_addStyle(`#rouletteFreqPanel .num-pill { display: inline-block; min-width: 24px; padding: 2px 6px; border-radius: 4px; text-align: center; font-weight: bold; color: white; }`);
    GM_addStyle(`#rouletteFreqPanel .num-pill.red { background: #c1272d; }`);
    GM_addStyle(`#rouletteFreqPanel .num-pill.black { background: #1a1a1a; }`);
    GM_addStyle(`#rouletteFreqPanel .num-pill.green { background: #007a3d; }`);
    GM_addStyle(`#rouletteFreqPanel .num-pill.gray { background: #555; }`);
    GM_addStyle(`#rouletteFreqPanel .table-container { overflow: hidden; transition: max-height 0.3s ease; }`);
    GM_addStyle(`#rouletteFreqPanel .table-container.collapsed { max-height: 0 !important; }`);
    GM_addStyle(`.straightBet.roulette-hot { outline: 2px solid #00e676 !important; box-shadow: inset 0 0 6px rgba(0, 230, 118, 0.45), 0 0 8px rgba(0, 230, 118, 0.35) !important; position: relative; }`);
    GM_addStyle(`.straightBet.roulette-top { outline: 2px solid #007a3d !important; box-shadow: inset 0 0 8px rgba(0, 122, 61, 0.7), 0 0 10px rgba(0, 122, 61, 0.55) !important; position: relative; }`);
    GM_addStyle(`#bet0.roulette-hot { outline: 2px solid #00e676 !important; box-shadow: inset 0 0 6px rgba(0, 230, 118, 0.45), 0 0 8px rgba(0, 230, 118, 0.35) !important; }`);
    GM_addStyle(`#bet0.roulette-top { outline: 2px solid #007a3d !important; box-shadow: inset 0 0 8px rgba(0, 122, 61, 0.7), 0 0 10px rgba(0, 122, 61, 0.55) !important; }`);
    GM_addStyle(`.roulette-freq-badge { position: absolute; top: 2px; right: 2px; font-size: 11px; font-family: monospace; font-weight: bold; color: #222; background: rgba(255, 255, 255, 0.92); padding: 1px 3px; border-radius: 4px; pointer-events: none; line-height: 1.2; z-index: 999; }`);

    // ------------------------------------
    // DOM HIGHLIGHTING
    // ------------------------------------
    function applyHighlights(freq) {
        document.querySelectorAll('.roulette-hot, .roulette-top').forEach(el => {
            el.classList.remove('roulette-hot', 'roulette-top');
        });
        document.querySelectorAll('.roulette-freq-badge').forEach(el => el.remove());

        const top10 = getTop10Numbers(freq);
        if (!top10.length) return;

        const topCount = Math.max(...top10.map(n => freq[n]));

        for (const num of top10) {
            const el = document.getElementById(`bet${num}`);
            if (!el) continue;

            const isTopTier = freq[num] === topCount;
            el.classList.add(isTopTier ? 'roulette-top' : 'roulette-hot');
            el.style.position = 'relative';

            const badge = document.createElement('span');
            badge.className = 'roulette-freq-badge';
            badge.textContent = `${freq[num]}`;
            el.appendChild(badge);
        }
    }

    // ------------------------------------
    // CONSOLE FREQUENCY REPORT
    // ------------------------------------
    function logFrequencyReport(freq) {
        const total = Object.values(freq).reduce((a, b) => a + b, 0);
        const sorted = Object.entries(freq)
            .map(([num, count]) => ({ num: Number(num), count }))
            .sort((a, b) => b.count - a.count);

        console.groupCollapsed('[Roulette Tracker] Frequency Report');
        console.log(`Total spins tracked: ${total}`);
        console.table(
            sorted.map(({ num, count }) => ({
                Number: num,
                Hits: count,
                Probability: ((count / total) * 100).toFixed(2) + '%'
            }))
        );
        console.groupEnd();
    }

    // ------------------------------------
    // SPECIAL STATS CALCULATION
    // ------------------------------------
    function calculateSpecialStats(freq) {
        const stats = {
            red: 0, black: 0, green: 0,
            dozen1: 0, dozen2: 0, dozen3: 0,
            low: 0, high: 0,
            even: 0, odd: 0,
            row1: 0, row2: 0, row3: 0
        };

        for (const [numStr, count] of Object.entries(freq)) {
            const num = Number(numStr);

            if (num === 0) {
                stats.green += count;
            } else {
                if (RED_NUMBERS.includes(num)) stats.red += count;
                if (BLACK_NUMBERS.includes(num)) stats.black += count;

                if (num >= 1 && num <= 12) stats.dozen1 += count;
                if (num >= 13 && num <= 24) stats.dozen2 += count;
                if (num >= 25 && num <= 36) stats.dozen3 += count;

                if (num >= 1 && num <= 18) stats.low += count;
                if (num >= 19 && num <= 36) stats.high += count;

                if (num % 2 === 0) stats.even += count;
                if (num % 2 === 1) stats.odd += count;

                if (num % 3 === 0) stats.row1 += count;
                if (num % 3 === 2) stats.row2 += count;
                if (num % 3 === 1) stats.row3 += count;
            }
        }

        return stats;
    }

    // ------------------------------------
    // DOM FREQUENCY TABLE
    // ------------------------------------
    function renderFreqTable(freq) {
        const total = Object.values(freq).reduce((a, b) => a + b, 0);
        const sorted = Object.entries(freq)
            .map(([num, count]) => ({ num: Number(num), count }))
            .sort((a, b) => b.count - a.count);

        const top10nums = getTop10Numbers(freq).map(Number);
        const topCount = top10nums.length ? Math.max(...top10nums.map(n => freq[String(n)])) : null;

        let panel = document.getElementById('rouletteFreqPanel');
        if (!panel) {
            panel = document.createElement('div');
            panel.id = 'rouletteFreqPanel';
            const container = document.getElementById('rouletteContainer');
            if (!container) return;
            container.insertAdjacentElement('afterend', panel);

            panel.addEventListener('click', (e) => {
                if (e.target.closest('.freq-settings')) {
                    e.stopPropagation();
                    const key = prompt('Enter your Torn API key (Full Access):', getAPIKey());
                    if (key !== null && key.trim() !== '') {
                        saveAPIKey(key.trim());
                        alert('API key saved successfully!');
                    }
                } else if (e.target.closest('.freq-header')) {
                    const container = e.target.closest('.freq-header').nextElementSibling;
                    const icon = e.target.closest('.freq-header').querySelector('.collapse-icon');
                    container.classList.toggle('collapsed');
                    icon.classList.toggle('collapsed');
                }
            }, true);
        }

        const rows = sorted.map(({ num, count }) => {
            const pct = ((count / total) * 100).toFixed(2);
            const isTop = top10nums.includes(num) && freq[String(num)] === topCount;
            const isHot = top10nums.includes(num) && !isTop;
            const cls = isTop ? 'freq-top' : isHot ? 'freq-hot' : '';

            let pillColor = 'black';
            if (num === 0) {
                pillColor = 'green';
            } else if (RED_NUMBERS.includes(num)) {
                pillColor = 'red';
            }

            return `<tr class="${cls}"><td><span class="num-pill ${pillColor}">${num}</span></td><td>${count}</td><td>${pct}%</td></tr>`;
        }).join('');

        const stats = calculateSpecialStats(freq);

        const colorCategory = [
            { label: 'Red', count: stats.red },
            { label: 'Black', count: stats.black },
            { label: 'Green (0)', count: stats.green }
        ].sort((a, b) => b.count - a.count);
        const colorMax = Math.max(...colorCategory.map(x => x.count));

        const dozenCategory = [
            { label: '1st 12', count: stats.dozen1 },
            { label: '2nd 12', count: stats.dozen2 },
            { label: '3rd 12', count: stats.dozen3 }
        ].sort((a, b) => b.count - a.count);
        const dozenMax = Math.max(...dozenCategory.map(x => x.count));

        const rangeCategory = [
            { label: '1-18', count: stats.low },
            { label: '19-36', count: stats.high }
        ].sort((a, b) => b.count - a.count);
        const rangeMax = Math.max(...rangeCategory.map(x => x.count));

        const parityCategory = [
            { label: 'Even', count: stats.even },
            { label: 'Odd', count: stats.odd }
        ].sort((a, b) => b.count - a.count);
        const parityMax = Math.max(...parityCategory.map(x => x.count));

        const rowCategory = [
            { label: 'Row 1 (3,6,9...)', count: stats.row1 },
            { label: 'Row 2 (2,5,8...)', count: stats.row2 },
            { label: 'Row 3 (1,4,7...)', count: stats.row3 }
        ].sort((a, b) => b.count - a.count);
        const rowMax = Math.max(...rowCategory.map(x => x.count));

        const renderCategory = (category, maxCount, isFirst = false) => {
            return category.map((item, idx) => {
                const pct = total > 0 ? ((item.count / total) * 100).toFixed(2) : '0.00';
                const isTop = item.count === maxCount && item.count > 0;
                const cls = (isTop ? 'special-top' : '') + (isFirst && idx === 0 ? ' category-separator' : '');

                let pillColor = 'gray';
                let pillText = item.label;

                if (item.label === 'Red') {
                    pillColor = 'red';
                    pillText = 'Red';
                } else if (item.label === 'Black') {
                    pillColor = 'black';
                    pillText = 'Black';
                } else if (item.label === 'Green (0)') {
                    pillColor = 'green';
                    pillText = '0';
                }

                const displayLabel = (pillColor !== 'gray')
                    ? `<span class="num-pill ${pillColor}">${pillText}</span>`
                    : `<span class="num-pill ${pillColor}">${item.label}</span>`;

                return `<tr class="${cls}"><td>${displayLabel}</td><td>${item.count}</td><td>${pct}%</td></tr>`;
            }).join('');
        };

        const specialRows =
            renderCategory(colorCategory, colorMax, false) +
            renderCategory(dozenCategory, dozenMax, true) +
            renderCategory(rangeCategory, rangeMax, true) +
            renderCategory(parityCategory, parityMax, true) +
            renderCategory(rowCategory, rowMax, true);

        panel.innerHTML = `
            <div class="freq-header">
                <div class="freq-header-left">
                    <span class="collapse-icon">▼</span>
                    <h3>Roulette Tracker</h3>
                    <span class="total-spins">(${total} spins)</span>
                </div>
                <span class="freq-settings" title="Configure API Key">⚙</span>
            </div>
            <div class="table-container">
                <table>
                    <thead><tr><th>Number</th><th>Hits</th><th>Probability</th></tr></thead>
                    <tbody>${rows}</tbody>
                </table>
            </div>

            <div class="freq-header" style="margin-top: 10px;">
                <div class="freq-header-left">
                    <span class="collapse-icon">▼</span>
                    <h3>Special Bets</h3>
                </div>
            </div>
            <div class="table-container">
                <table>
                    <thead><tr><th>Bet Type</th><th>Hits</th><th>Probability</th></tr></thead>
                    <tbody>${specialRows}</tbody>
                </table>
            </div>
        `;
    }

    // ------------------------------------
    // SPIN LISTENER
    // ------------------------------------
    function attachSpinListener() {
        const canvas = document.getElementById('rouletteCanvas');
        if (!canvas) return;

        canvas.addEventListener('click', async () => {
            const apiKey = getAPIKey();
            if (!apiKey) {
                alert('Please configure your API key by clicking the ⚙ icon in the Frequency Report table.');
                return;
            }
            try {
                await new Promise(resolve => setTimeout(resolve, 1000));
                const updated = await incrementalUpdate(apiKey);
                applyHighlights(updated);
                logFrequencyReport(updated);
                renderFreqTable(updated);
            } catch (err) {
                console.error('[Roulette Tracker] Update error:', err);
            }
        });

        console.log('[Roulette Tracker] Spin listener attached.');
    }

    // ------------------------------------
    // INITIALIZATION
    // ------------------------------------
    async function init() {
        let freq;
        const apiKey = getAPIKey();

        if (!apiKey) {
            console.warn('[Roulette Tracker] No API key set. Click the ⚙ icon to configure.');
            freq = getFrequency();
        } else {
            try {
                if (getLastTimestamp() === 0) {
                    console.log('[Roulette Tracker] No history found. Loading initial 100 results...');
                    freq = await initialLoad(apiKey);
                    console.log('[Roulette Tracker] Initial load complete.');
                } else {
                    freq = getFrequency();
                }
            } catch (err) {
                console.error('[Roulette Tracker] Init error:', err);
                freq = getFrequency();
            }
        }

        const waitForTable = setInterval(() => {
            if (!document.getElementById('bet1')) return;
            clearInterval(waitForTable);
            applyHighlights(freq);
            logFrequencyReport(freq);
            renderFreqTable(freq);
            attachSpinListener();
        }, 250);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();