Torn Cooldown Tracker

A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Torn Cooldown Tracker
// @namespace    https://greasyfork.org/
// @version      1.2.3
// @description  A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.
// @author       Imtazking [2189762]
// @license      MIT
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  // ── Platform ───────────────────────────────────────────────────────────────
  const IS_PDA = typeof PDA_httpGet !== 'undefined';
  const STORAGE_KEY = 'tct_api_key';
  const WIDGET_ID = 'tct-widget';
  const REFRESH_SEC = 30;
  const POS_KEY_X = 'tct_pos_x';
  const POS_KEY_Y = 'tct_pos_y';

  let apiKey = IS_PDA ? '###PDA-APIKEY###' : (GM_getValue ? GM_getValue(STORAGE_KEY, '') : '');
  let userData = null;
  let tickTimer = null;
  let refreshTimer = null;
  let collapsed = GM_getValue ? (GM_getValue('tct_collapsed', false)) : false;

  // ── Utilities ──────────────────────────────────────────────────────────────
  const $ = id => document.getElementById(id);

  function saveKey(k) { if (!IS_PDA && GM_setValue) GM_setValue(STORAGE_KEY, k); }
  function saveCollapsed(v) { if (GM_setValue) GM_setValue('tct_collapsed', v); }
  function savePos(x, y) {
    if (GM_setValue) { GM_setValue(POS_KEY_X, x); GM_setValue(POS_KEY_Y, y); }
  }
  function loadPos() {
    const x = GM_getValue ? GM_getValue(POS_KEY_X, null) : null;
    const y = GM_getValue ? GM_getValue(POS_KEY_Y, null) : null;
    return (x !== null && y !== null) ? { x, y } : null;
  }

  function fmtCountdown(secs) {
    if (!secs || secs <= 0) return null;
    const h = Math.floor(secs / 3600);
    const m = Math.floor((secs % 3600) / 60);
    const s = secs % 60;
    if (h > 0) return `${h}h ${m.toString().padStart(2,'0')}m`;
    if (m > 0) return `${m}m ${s.toString().padStart(2,'0')}s`;
    return `${s}s`;
  }

  function readyAt(secs) {
    if (!secs || secs <= 0) return null;
    const d = new Date(Date.now() + secs * 1000);
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  }

  function ticksToFull(current, max, tickAmt, tickSecs) {
    if (!tickAmt || !tickSecs || current >= max) return 0;
    const needed = max - current;
    const ticks = Math.ceil(needed / tickAmt);
    return ticks * tickSecs;
  }

  function fmtToFull(secs) {
    if (secs <= 0) return 'Full';
    const h = Math.floor(secs / 3600);
    const m = Math.floor((secs % 3600) / 60);
    if (h > 0) return `${h}h ${m}m to full`;
    if (m > 0) return `${m}m to full`;
    return `<1m to full`;
  }

  // ── CSS ────────────────────────────────────────────────────────────────────
  const CSS = `
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');

  #${WIDGET_ID} {
    position: fixed;
    bottom: 80px;
    right: 24px;
    z-index: 99996;
    width: 220px;
    background: #0d0d0d;
    border: 1px solid #1e1e1e;
    border-radius: 10px;
    font-family: 'Inter', system-ui, sans-serif;
    font-size: 12px;
    color: #c0c0c0;
    overflow: hidden;
    box-shadow: 0 4px 24px rgba(0,0,0,0.5);
  }

  #tct-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 9px 12px;
    background: #0a0a0a;
    border-bottom: 1px solid #1a1a1a;
    cursor: grab;
    user-select: none;
  }
  #tct-header:active { cursor: grabbing; }
  #tct-widget { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
  #tct-widget.dragging { box-shadow: none; }
  #tct-header.clicking { cursor: pointer; }
  .tct-grip {
    display: flex; flex-direction: column; gap: 2px;
    flex-shrink: 0; margin-right: 7px; opacity: 0.25;
    transition: opacity 0.15s;
  }
  #tct-header:hover .tct-grip { opacity: 0.5; }
  .tct-grip span {
    display: block; width: 14px; height: 1.5px;
    background: #c8c8c8; border-radius: 1px;
  }
  #tct-header-left {
    display: flex;
    align-items: center;
    gap: 7px;
  }
  .tct-wordmark {
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: #c8963e;
  }
  .tct-player-name {
    font-size: 10px;
    color: #333;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 90px;
  }
  #tct-chevron {
    font-size: 9px;
    color: #333;
    transition: transform 0.2s;
    flex-shrink: 0;
  }
  #tct-chevron.up { transform: rotate(180deg); }

  #tct-body { padding: 10px 12px; }
  #tct-body.hidden { display: none; }

  /* ── Section labels ── */
  .tct-section-label {
    font-size: 9px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: #2a2a2a;
    margin: 10px 0 6px;
  }
  .tct-section-label:first-child { margin-top: 0; }

  /* ── Cooldown rows ── */
  .tct-cd-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 5px 0;
    border-bottom: 1px solid #111;
  }
  .tct-cd-row:last-child { border-bottom: none; }

  .tct-cd-left {
    display: flex;
    align-items: center;
    gap: 7px;
  }
  .tct-cd-icon {
    width: 22px;
    height: 22px;
    border-radius: 5px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
    flex-shrink: 0;
  }
  .tct-cd-icon.drug    { background: #1a1230; }
  .tct-cd-icon.booster { background: #1a1508; }
  .tct-cd-icon.medical { background: #0c1e18; }

  .tct-cd-name {
    font-size: 11px;
    color: #888;
    font-weight: 500;
  }
  .tct-cd-right { text-align: right; }
  .tct-cd-timer {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    font-weight: 500;
    line-height: 1.2;
  }
  .tct-cd-timer.active  { color: #c0622a; }
  .tct-cd-timer.ready   { color: #4d9e6e; animation: tct-pulse 2s ease-in-out infinite; }
  .tct-cd-timer.partial { color: #c8963e; }
  @keyframes tct-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
  }

  .tct-cd-sub {
    font-size: 9px;
    color: #2e2e2e;
    margin-top: 1px;
    font-family: 'JetBrains Mono', monospace;
  }

  /* ── Bar rows ── */
  .tct-bar-row {
    padding: 5px 0;
    border-bottom: 1px solid #111;
  }
  .tct-bar-row:last-child { border-bottom: none; }

  .tct-bar-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 5px;
  }
  .tct-bar-left {
    display: flex;
    align-items: center;
    gap: 5px;
  }
  .tct-bar-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .tct-bar-name {
    font-size: 11px;
    color: #666;
    font-weight: 500;
  }
  .tct-bar-val {
    font-family: 'JetBrains Mono', monospace;
    font-size: 11px;
    color: #999;
    font-weight: 500;
  }
  .tct-bar-val .tct-max { color: #333; font-size: 10px; }

  .tct-bar-track {
    height: 3px;
    background: #1a1a1a;
    border-radius: 2px;
    overflow: hidden;
  }
  .tct-bar-fill {
    height: 100%;
    border-radius: 2px;
    transition: width 0.6s ease;
  }
  .tct-bar-sub {
    font-size: 9px;
    color: #666;
    margin-top: 3px;
    font-family: 'JetBrains Mono', monospace;
  }

  /* ── Setup ── */
  #tct-setup {
    padding: 14px 12px;
    text-align: center;
  }
  #tct-setup p {
    font-size: 11px;
    color: #333;
    line-height: 1.5;
    margin: 0 0 10px;
  }
  #tct-key-input {
    width: 100%;
    background: #141414;
    border: 1px solid #222;
    border-radius: 6px;
    color: #c0c0c0;
    padding: 7px 10px;
    font-size: 11px;
    font-family: 'Inter', sans-serif;
    text-align: center;
    letter-spacing: 0.06em;
    box-sizing: border-box;
    margin-bottom: 8px;
  }
  #tct-key-input:focus { outline: none; border-color: #c8963e; }
  #tct-connect-btn {
    width: 100%;
    padding: 7px;
    border-radius: 6px;
    font-size: 11px;
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    cursor: pointer;
    background: #c8963e;
    color: #0a0a0a;
    border: none;
    letter-spacing: 0.04em;
  }
  #tct-connect-btn:hover { opacity: 0.88; }

  /* ── Footer ── */
  #tct-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 12px;
    border-top: 1px solid #141414;
    background: #0a0a0a;
  }

  .tct-reset-btn {
    font-size: 9px;
    color: #222;
    cursor: pointer;
    background: none;
    border: none;
    font-family: 'Inter', sans-serif;
    padding: 0;
    letter-spacing: 0.03em;
  }
  .tct-reset-btn:hover { color: #c8963e; }

  /* ── Loading / error ── */
  .tct-status-msg {
    padding: 14px 12px;
    font-size: 11px;
    color: #333;
    text-align: center;
    line-height: 1.5;
  }
  .tct-status-msg.error { color: #804040; }
  .tct-retry {
    display: inline-block;
    margin-top: 8px;
    font-size: 10px;
    color: #c8963e;
    cursor: pointer;
    background: none;
    border: none;
    font-family: 'Inter', sans-serif;
    padding: 0;
  }
  `;

  // ── API ────────────────────────────────────────────────────────────────────
  function apiCall(cb) {
    const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
    if (IS_PDA) {
      try {
        PDA_httpGet(url, r => {
          try {
            const d = JSON.parse(r);
            if (d.error) return cb(null, d.error.error || 'API error');
            cb(d, null);
          } catch(e) { cb(null, 'Parse error'); }
        });
      } catch(e) { cb(null, 'PDA_httpGet failed'); }
    } else {
      GM_xmlhttpRequest({
        method: 'GET', url,
        onload(r) {
          try {
            const d = JSON.parse(r.responseText);
            if (d.error) return cb(null, d.error.error || 'API error');
            cb(d, null);
          } catch(e) { cb(null, 'Parse error'); }
        },
        onerror() { cb(null, 'Network error'); }
      });
    }
  }

  // ── Tick: count down live without API calls ────────────────────────────────
  function startTick() {
    stopTick();
    let lastRender = Date.now();
    tickTimer = setInterval(() => {
      if (!userData) return;
      const elapsed = Math.round((Date.now() - lastRender) / 1000);
      lastRender = Date.now();

      // Decrement cooldowns
      const cd = userData.cooldowns;
      if (cd.drug    > 0) cd.drug    = Math.max(0, cd.drug    - elapsed);
      if (cd.booster > 0) cd.booster = Math.max(0, cd.booster - elapsed);
      if (cd.medical > 0) cd.medical = Math.max(0, cd.medical - elapsed);

      // Increment bars — bars are top-level on userData (not nested under .bars)
      ['energy','nerve','happy','life'].forEach(k => {
        const b = userData[k];
        if (b && b.current < b.maximum && b.increment && b.interval) {
          b.current = Math.min(b.maximum, b.current + (b.increment * elapsed / b.interval));
        }
      });

      renderBody();
    }, 1000);
  }

  function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }

  // ── Silent background refresh ──────────────────────────────────────────────
  function startRefreshCycle() {
    stopRefreshCycle();
    refreshTimer = setInterval(() => {
      // Silently fetch new data and update state without touching the DOM
      const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
      const doFetch = IS_PDA
        ? cb => { try { PDA_httpGet(url, r => { try { cb(JSON.parse(r)); } catch(e){} }); } catch(e){} }
        : cb => { GM_xmlhttpRequest({ method:'GET', url, onload(r){ try { cb(JSON.parse(r.responseText)); } catch(e){} } }); };
      doFetch(d => {
        if (!d || d.error) return;
        // Update cooldowns
        if (d.cooldowns) userData.cooldowns = d.cooldowns;
        // Update bars preserving the live-ticked current values only if API value differs significantly
        ['energy','nerve','happy','life'].forEach(k => {
          if (d[k] && userData[k]) {
            userData[k].maximum  = d[k].maximum;
            userData[k].increment = d[k].increment;
            userData[k].interval  = d[k].interval;
            userData[k].fulltime  = d[k].fulltime;
            // Only snap current if API differs by more than 1 tick (avoids jump from network delay)
            const diff = Math.abs(d[k].current - Math.floor(userData[k].current));
            if (diff > d[k].increment * 2) userData[k].current = d[k].current;
          } else if (d[k]) {
            userData[k] = d[k];
          }
        });
      });
    }, REFRESH_SEC * 1000);
  }
  function stopRefreshCycle() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }


  // ── Fetch ──────────────────────────────────────────────────────────────────
  function fetchData() {
    setBody(`<div class="tct-status-msg">Updating…</div>`);
    apiCall((d, err) => {
      if (err) {
        setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
        const rb = $('tct-retry-btn');
        if (rb) rb.onclick = fetchData;
        return;
      }
      userData = d;
      renderBody();
      startTick();
      startRefreshCycle();
    });
  }

  // ── Render ─────────────────────────────────────────────────────────────────
  function setBody(html) {
    const body = $('tct-body');
    if (body) body.innerHTML = html;
  }

  function cdRow(type, label, emoji, secs, maxSecs) {
    const active = secs > 0;
    const pct = maxSecs ? Math.min(100, Math.round((secs / maxSecs) * 100)) : 0;
    let timerText, timerClass, sub;

    if (!active) {
      timerText = 'Ready';
      timerClass = 'ready';
      sub = '';
    } else if (maxSecs && secs > maxSecs) {
      // Over cap — locked out
      timerText = fmtCountdown(secs - maxSecs) + ' locked';
      timerClass = 'active';
      sub = `cap in ${fmtCountdown(secs - maxSecs)}`;
    } else {
      timerText = fmtCountdown(secs);
      timerClass = secs > maxSecs * 0.5 ? 'active' : 'partial';
      sub = readyAt(secs) ? `ready at ${readyAt(secs)}` : '';
    }

    return `
      <div class="tct-cd-row">
        <div class="tct-cd-left">
          <div class="tct-cd-icon ${type}">${emoji}</div>
          <span class="tct-cd-name">${label}</span>
        </div>
        <div class="tct-cd-right">
          <div class="tct-cd-timer ${timerClass}">${timerText}</div>
          ${sub ? `<div class="tct-cd-sub">${sub}</div>` : ''}
        </div>
      </div>`;
  }

  function barRow(key, label, color, bar) {
    if (!bar) return '';
    const cur  = Math.floor(bar.current);
    const max  = bar.maximum;
    const pct  = max > 0 ? Math.min(100, Math.round((cur / max) * 100)) : 0;
    // API fields: increment = amount per tick, interval = seconds per tick, fulltime = secs to full
    const secsToFull = cur < max && bar.fulltime ? bar.fulltime : 0;
    const sub = cur >= max ? 'Full' : fmtToFull(secsToFull);

    return `
      <div class="tct-bar-row">
        <div class="tct-bar-header">
          <div class="tct-bar-left">
            <div class="tct-bar-dot" style="background:${color}"></div>
            <span class="tct-bar-name">${label}</span>
          </div>
          <span class="tct-bar-val">${cur}<span class="tct-max">/${max}</span></span>
        </div>
        <div class="tct-bar-track">
          <div class="tct-bar-fill" style="width:${pct}%;background:${color}"></div>
        </div>
        <div class="tct-bar-sub">${sub}</div>
      </div>`;
  }

  function renderBody() {
    if (!userData) return;
    const cd   = userData.cooldowns || {};
    // bars are top-level keys on userData

    // Drug cap is 0 (must hit 0 to use again), booster cap 24h, medical cap 6h
    const BOOSTER_CAP = 24 * 3600;
    const MEDICAL_CAP = 6  * 3600;

    const html = `
      <div class="tct-section-label">Cooldowns</div>
      ${cdRow('drug',    'Drug',    '💊', cd.drug    || 0, 0)}
      ${cdRow('booster', 'Booster', '⚡', cd.booster || 0, BOOSTER_CAP)}
      ${cdRow('medical', 'Medical', '🩹', cd.medical || 0, MEDICAL_CAP)}

      <div class="tct-section-label">Bars</div>
      ${barRow('energy', 'Energy', '#4d9e6e', userData.energy)}
      ${barRow('nerve',  'Nerve',  '#c0622a', userData.nerve)}
      ${barRow('happy',  'Happy',  '#c8963e', userData.happy)}
      ${barRow('life',   'Life',   '#7855be', userData.life)}
    `;

    setBody(html);
  }

  // ── Widget shell ───────────────────────────────────────────────────────────
  function buildWidget() {
    const w = document.createElement('div');
    w.id = WIDGET_ID;
    w.innerHTML = `
      <div id="tct-header">
        <div id="tct-header-left">
          <div class="tct-grip" aria-hidden="true">
            <span></span><span></span><span></span>
          </div>
          <span class="tct-wordmark">Cooldowns</span>
          <span class="tct-player-name" id="tct-player-name"></span>
        </div>
        <span id="tct-chevron">${collapsed ? '▲' : '▼'}</span>
      </div>
      <div id="tct-body" class="${collapsed ? 'hidden' : ''}">
        ${!apiKey || apiKey === '###PDA-APIKEY###' && !IS_PDA
          ? setupHTML()
          : '<div class="tct-status-msg">Loading…</div>'}
      </div>
      <div id="tct-footer">
        ${!IS_PDA ? `<button class="tct-reset-btn" id="tct-reset">Change key</button>` : ''}
      </div>
    `;
    document.body.appendChild(w);

    // Apply saved position
    const savedPos = loadPos();
    if (savedPos) {
      w.style.right = 'auto';
      w.style.bottom = 'auto';
      w.style.left = savedPos.x + 'px';
      w.style.top  = savedPos.y + 'px';
    }

    // Drag logic
    let isDragging = false;
    let dragStartX, dragStartY;
    let dragMoved = false;

    const header = $('tct-header');

    header.addEventListener('pointerdown', e => {
      if (e.button !== 0) return;
      isDragging = true;
      dragMoved = false;
      const rect = w.getBoundingClientRect();
      dragStartX = e.clientX - rect.left;
      dragStartY = e.clientY - rect.top;
      w.style.right  = 'auto';
      w.style.bottom = 'auto';
      w.style.left = rect.left + 'px';
      w.style.top  = rect.top  + 'px';
      header.setPointerCapture(e.pointerId);
      w.classList.add('dragging');
      header.style.cursor = 'grabbing';
      e.preventDefault();
    });

    header.addEventListener('pointermove', e => {
      if (!isDragging) return;
      dragMoved = true;
      const newX = Math.max(0, Math.min(window.innerWidth  - w.offsetWidth,  e.clientX - dragStartX));
      const newY = Math.max(0, Math.min(window.innerHeight - w.offsetHeight, e.clientY - dragStartY));
      w.style.left = newX + 'px';
      w.style.top  = newY + 'px';
    });

    header.addEventListener('pointerup', e => {
      if (!isDragging) return;
      isDragging = false;
      header.style.cursor = '';
      header.releasePointerCapture(e.pointerId);
      w.classList.remove('dragging');
      if (dragMoved) {
        const rect = w.getBoundingClientRect();
        savePos(Math.round(rect.left), Math.round(rect.top));
      }
    });

    // Header toggle — only fires if we didn't drag
    $('tct-header').onclick = () => {
      if (dragMoved) return;
      collapsed = !collapsed;
      saveCollapsed(collapsed);
      $('tct-body').classList.toggle('hidden', collapsed);
      $('tct-chevron').classList.toggle('up', !collapsed);
    };
    $('tct-chevron').classList.toggle('up', !collapsed);

    if (!IS_PDA) {
      const rb = $('tct-reset');
      if (rb) rb.onclick = e => {
        e.stopPropagation();
        apiKey = ''; saveKey('');
        userData = null; stopTick(); stopRefreshCycle();
        setBody(setupHTML());
        bindSetup();
      };
    }

    if (apiKey && !(apiKey === '###PDA-APIKEY###' && !IS_PDA)) {
      fetchData();
    } else if (!IS_PDA) {
      bindSetup();
    } else {
      // PDA with injected key
      fetchData();
    }
  }

  function setupHTML() {
    return `
      <div id="tct-setup">
        <p>Enter your Torn API key.<br>Requires Minimal Access.</p>
        <input id="tct-key-input" type="text" placeholder="API key" maxlength="16" autocomplete="off" spellcheck="false"/>
        <button id="tct-connect-btn">Connect</button>
      </div>`;
  }

  function bindSetup() {
    const input = $('tct-key-input');
    const btn   = $('tct-connect-btn');
    if (!input || !btn) return;
    btn.onclick = () => {
      const v = input.value.trim();
      if (v.length !== 16) { input.style.borderColor = '#c0622a'; return; }
      apiKey = v; saveKey(v);
      setBody('<div class="tct-status-msg">Connecting…</div>');
      fetchData();
    };
    input.onkeydown = e => { if (e.key === 'Enter') btn.click(); };
  }

  function updatePlayerName() {
    if (!userData) return;
    const el = $('tct-player-name');
    if (el && userData.name) el.textContent = userData.name;
  }

  // Patch fetchData to update player name after load
  const _fetchData = fetchData;

  // ── Init ───────────────────────────────────────────────────────────────────
  function injectStyles() {
    if ($('tct-styles')) return;
    const s = document.createElement('style');
    s.id = 'tct-styles';
    s.textContent = CSS;
    document.head.appendChild(s);
  }

  function init() {
    if ($(WIDGET_ID)) return;
    injectStyles();
    buildWidget();
  }

  // Patch apiCall success to update name
  const origApiCall = apiCall;
  function apiCallWithName(cb) {
    origApiCall((d, err) => {
      if (d && d.name) {
        const el = $('tct-player-name');
        if (el) el.textContent = d.name;
      }
      cb(d, err);
    });
  }

  // Override fetchData to use named version
  function fetchData() {
    setBody(`<div class="tct-status-msg">Updating…</div>`);
    apiCallWithName((d, err) => {
      if (err) {
        setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
        const rb = $('tct-retry-btn');
        if (rb) rb.onclick = fetchData;
        return;
      }
      userData = d;
      renderBody();
      startTick();
      startRefreshCycle();
    });
  }

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

})();