Torn RR Daily Tracker

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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