Torn RR Daily Tracker

Today's Russian Roulette P/L as a Torn-style table under List of available games.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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();
})();