Today's Russian Roulette P/L as a Torn-style table under List of available games.
// ==UserScript==
// @name Torn RR Daily Tracker
// @namespace https://github.com/DRSCP10/Torn-RR-Daily-Tracker-Userscript
// @version 1.0.2
// @description Today's Russian Roulette P/L as a Torn-style table under List of available games.
// @author DRSCP10
// @match https://www.torn.com/*
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM.addStyle
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @grant GM.registerMenuCommand
// @connect api.torn.com
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const PANEL_ID = "torn-rr-pl-panel";
const LOG_START = 8390;
const LOG_JOIN = 8391;
const LOG_WIN = 8395;
const LOG_LOSE = 8396;
const LOG_TIMEOUT_REFUND = 8399;
const LOG_LEAVE = 8400;
const CACHE_MS = 25000;
const STYLE = `
#torn-rr-pl-panel {
clear: both;
margin-top: 10px;
position: static;
}
#torn-rr-pl-panel .rrpl-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
#torn-rr-pl-panel .rrpl-refresh {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
text-decoration: underline;
}
#torn-rr-pl-panel .rrpl-table {
width: 100%;
border-collapse: collapse;
background: #fff;
color: #333;
font-size: 12px;
}
#torn-rr-pl-panel .rrpl-table th,
#torn-rr-pl-panel .rrpl-table td {
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid #ddd;
font-weight: normal;
}
#torn-rr-pl-panel .rrpl-table th {
background: #ececec;
color: #444;
}
#torn-rr-pl-panel.rrpl-up [data-role="net"] {
color: #1a7f1a;
font-weight: bold;
}
#torn-rr-pl-panel.rrpl-down [data-role="net"] {
color: #b42323;
font-weight: bold;
}
#torn-rr-pl-panel.rrpl-error [data-role="net"] {
color: #b42323;
}
`;
let cache = { key: "", from: 0, at: 0, data: null };
function gmCall(legacyName, modernName, args) {
if (typeof window[legacyName] === "function") {
return window[legacyName](...args);
}
if (typeof GM !== "undefined" && typeof GM[modernName] === "function") {
return GM[modernName](...args);
}
return undefined;
}
function getValue(name, fallback) {
const value = gmCall("GM_getValue", "getValue", [name, fallback]);
return Promise.resolve(value === undefined ? fallback : value);
}
function setValue(name, value) {
const result = gmCall("GM_setValue", "setValue", [name, value]);
return Promise.resolve(result);
}
function addStyle(css) {
const result = gmCall("GM_addStyle", "addStyle", [css]);
if (result) return;
const style = document.createElement("style");
style.textContent = css;
document.documentElement.appendChild(style);
}
function registerMenu(title, fn) {
gmCall("GM_registerMenuCommand", "registerMenuCommand", [title, fn]);
}
function xmlRequest(details) {
const fn =
typeof GM_xmlhttpRequest === "function"
? GM_xmlhttpRequest
: typeof GM !== "undefined"
? GM.xmlHttpRequest
: null;
if (!fn) {
return fetch(details.url).then(async (res) => ({
status: res.status,
responseText: await res.text(),
}));
}
return new Promise((resolve, reject) => {
fn({
method: details.method || "GET",
url: details.url,
headers: details.headers,
onload: resolve,
onerror: reject,
ontimeout: reject,
});
});
}
function utcDayBounds(now = new Date()) {
const start = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0,
0,
0
);
return { from: Math.floor(start / 1000), to: Math.floor(now.getTime() / 1000) };
}
function money(n) {
const value = Math.round(Number(n) || 0);
const sign = value < 0 ? "-" : "";
return `${sign}$${Math.abs(value).toLocaleString("en-US")}`;
}
async function tornGet(path, key) {
const url = `https://api.torn.com${path}${path.includes("?") ? "&" : "?"}key=${encodeURIComponent(key)}&comment=rrpanel`;
const res = await xmlRequest({ url });
if (res.status && res.status !== 200) {
throw new Error(`Torn HTTP ${res.status}`);
}
const data = JSON.parse(res.responseText || "{}");
if (data.error) {
throw new Error(data.error.error || `Torn error ${data.error.code}`);
}
return data;
}
async function paginateLog(key, logId, from, to) {
const items = [];
const seen = new Set();
let cursor = to;
for (let page = 0; page < 40; page += 1) {
const data = await tornGet(
`/v2/user/log?log=${logId}&from=${from}&to=${cursor}&limit=100&sort=desc`,
key
);
const batch = data.log || [];
for (const row of batch) {
const ts = row.timestamp || 0;
if (ts < from || ts > to) continue;
if (seen.has(row.id)) continue;
seen.add(row.id);
items.push(row);
}
if (batch.length < 100) break;
const oldest = Math.min(...batch.map((row) => row.timestamp));
if (oldest <= from) break;
cursor = oldest;
}
return items;
}
function summarize(logs, from, to) {
const starts = logs[LOG_START];
const joins = logs[LOG_JOIN];
const wins = logs[LOG_WIN];
const losses = logs[LOG_LOSE];
const leaves = logs[LOG_LEAVE];
const timeouts = logs[LOG_TIMEOUT_REFUND];
const buyin = [...starts, ...joins].reduce(
(sum, row) => sum + (row.data?.bet_amount || 0),
0
);
const potsWon = wins.reduce((sum, row) => sum + (row.data?.pot || 0), 0);
let refunds = leaves.reduce((sum, row) => sum + (row.data?.refund || 0), 0);
for (const row of timeouts) {
refunds += row.data?.refund || row.data?.bet_amount || row.data?.pot || 0;
}
const net = potsWon - buyin + refunds;
const finished = new Set(
[...wins, ...losses].map((row) => row.data?.game_id).filter(Boolean)
);
const unfinished = [...starts, ...joins].filter(
(row) => row.data?.game_id && !finished.has(row.data.game_id)
).length;
return {
date: new Date(from * 1000).toISOString().slice(0, 10),
wins: wins.length,
losses: losses.length,
games: wins.length + losses.length,
unfinished,
net,
netLabel: money(net),
buyinLabel: money(buyin),
returnedLabel: money(potsWon + refunds),
record: `${wins.length}-${losses.length}`,
};
}
async function computeToday(key) {
const { from, to } = utcDayBounds();
if (cache.data && cache.key === key && cache.from === from && Date.now() - cache.at < CACHE_MS) {
return cache.data;
}
const ids = [LOG_START, LOG_JOIN, LOG_WIN, LOG_LOSE, LOG_TIMEOUT_REFUND, LOG_LEAVE];
const logs = {};
for (const id of ids) {
logs[id] = await paginateLog(key, id, from, to);
}
const data = summarize(logs, from, to);
cache = { key, from, at: Date.now(), data };
return data;
}
function isRrPage() {
try {
const sid = new URL(location.href).searchParams.get("sid") || "";
return sid === "russianRoulette" || sid.startsWith("russianRoulette");
} catch {
return /sid=russianRoulette/i.test(location.href);
}
}
function textOf(el) {
return (el.textContent || "").replace(/\s+/g, " ").trim();
}
function isOurPanel(el) {
return Boolean(el?.closest?.(`#${PANEL_ID}`) || el?.id === PANEL_ID);
}
function findAvailableGamesHeading() {
const nodes = document.querySelectorAll("div, span, h2, h3, h4, legend, b, strong, p");
for (const el of nodes) {
if (isOurPanel(el)) continue;
if (textOf(el) === "List of available games") return el;
}
for (const el of nodes) {
if (isOurPanel(el)) continue;
const text = textOf(el);
if (text.length < 48 && /list of available games/i.test(text)) return el;
}
return null;
}
function findGamesList() {
const heading = findAvailableGamesHeading();
if (heading) {
const titleBar =
heading.closest(".title-black, [class*='title-black'], [class*='title']") || heading;
const afterTitle = titleBar.nextElementSibling;
if (
afterTitle &&
!isOurPanel(afterTitle) &&
(afterTitle.matches("table") || afterTitle.querySelector("table"))
) {
return afterTitle;
}
let node = heading;
for (let i = 0; i < 6 && node; i += 1) {
const parent = node.parentElement;
if (!parent || parent === document.body) break;
if (
parent.matches("#mainContainer, .content-wrapper, body, html") ||
/start a new game/i.test(textOf(parent))
) {
break;
}
if (parent.querySelector("table")) return parent;
node = parent;
}
return afterTitle || titleBar;
}
const tables = [...document.querySelectorAll("table")].filter((table) => !isOurPanel(table));
return tables.at(-1) || null;
}
function ensurePanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement("div");
panel.id = PANEL_ID;
panel.setAttribute("data-torn-rr-pl", "true");
panel.innerHTML = `
<div class="title-black top-round m-top10 rrpl-title">
<span>Today's RR</span>
<button type="button" class="rrpl-refresh">Refresh</button>
</div>
<div class="cont-gray bottom-round rrpl-body">
<table class="rrpl-table">
<thead>
<tr>
<th>Net</th>
<th>Record W - L</th>
<th>Games</th>
<th>Buy-in</th>
<th>Returned</th>
</tr>
</thead>
<tbody>
<tr>
<td data-role="net">Loading…</td>
<td data-role="record">—</td>
<td data-role="games">—</td>
<td data-role="buyin">—</td>
<td data-role="returned">—</td>
</tr>
</tbody>
</table>
</div>
`;
panel.querySelector(".rrpl-refresh").addEventListener("click", (event) => {
event.preventDefault();
loadStats();
});
return panel;
}
function placePanel(panel) {
if (!isRrPage()) {
panel.remove();
return false;
}
const anchor = findGamesList();
if (!anchor || !anchor.parentElement) return false;
if (anchor === panel) return false;
if (panel.previousElementSibling === anchor && panel.isConnected) return true;
anchor.insertAdjacentElement("afterend", panel);
return true;
}
function render(state) {
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
const net = panel.querySelector('[data-role="net"]');
const record = panel.querySelector('[data-role="record"]');
const games = panel.querySelector('[data-role="games"]');
const buyin = panel.querySelector('[data-role="buyin"]');
const returned = panel.querySelector('[data-role="returned"]');
panel.classList.remove("rrpl-up", "rrpl-down", "rrpl-even", "rrpl-error");
if (state.error === "missing_key") {
panel.classList.add("rrpl-error");
net.textContent = "Add API key";
record.textContent = "—";
games.textContent = "—";
buyin.textContent = "—";
returned.textContent = "Tampermonkey menu → Set Torn API key";
return;
}
if (state.error) {
panel.classList.add("rrpl-error");
net.textContent = "Could not load";
record.textContent = "—";
games.textContent = "—";
buyin.textContent = state.error;
returned.textContent = "—";
return;
}
const stats = state.stats;
const tone = stats.net > 0 ? "up" : stats.net < 0 ? "down" : "even";
panel.classList.add(`rrpl-${tone}`);
net.textContent = stats.net === 0 ? "$0" : stats.netLabel;
record.textContent = stats.record;
games.textContent = String(stats.games);
buyin.textContent = stats.buyinLabel;
returned.textContent = stats.returnedLabel;
}
async function loadStats() {
const panel = ensurePanel();
if (!placePanel(panel)) return;
const key = String((await getValue("tornApiKey", "")) || "").trim();
if (!key) {
render({ error: "missing_key" });
return;
}
try {
const stats = await computeToday(key);
render({ stats });
} catch (error) {
render({ error: error.message || "fetch_failed" });
}
}
function tick() {
if (!isRrPage()) {
document.getElementById(PANEL_ID)?.remove();
return;
}
const panel = ensurePanel();
const placed = placePanel(panel);
if (placed && !panel.dataset.loaded) {
panel.dataset.loaded = "1";
loadStats();
}
}
async function promptForKey() {
const current = String((await getValue("tornApiKey", "")) || "");
const next = window.prompt("Torn API key (user log access):", current);
if (next === null) return;
await setValue("tornApiKey", next.trim());
cache = { key: "", from: 0, at: 0, data: null };
const panel = document.getElementById(PANEL_ID);
if (panel) panel.dataset.loaded = "";
loadStats();
}
addStyle(STYLE);
registerMenu("Set Torn API key", promptForKey);
registerMenu("Refresh RR stats", loadStats);
let timer = 0;
const observer = new MutationObserver(() => {
if (!isRrPage()) return;
window.clearTimeout(timer);
timer = window.setTimeout(tick, 200);
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setInterval(tick, 2000);
tick();
})();