Klavia Competitive Patch (Browser HUD)

Browser HUD for Klavia: live Points, WPM, Accuracy, Races, Races Needed, Rank, and Above/Below racer status

// ==UserScript==
// @name         Klavia Competitive Patch (Browser HUD)
// @namespace    https://playklavia.com/
// @version      1.5.0
// @description  Browser HUD for Klavia: live Points, WPM, Accuracy, Races, Races Needed, Rank, and Above/Below racer status
// @author       Yodex
// @license      MIT
// @match        https://playklavia.com/*
// @match        https://www.playklavia.com/*
// @match        https://playklavia.com/race
// @match        https://playklavia.com/lobbies/*
// @run-at       document-idle
// @icon         none
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      playklavia.com
// @connect      klavia.io
//
// OPTIONAL (fill these in after you host the file to enable auto-updates):
// ==/UserScript==

(function () {
  "use strict";

  // --------------------- Storage & Defaults ---------------------
  const EMBEDDED_TOKEN = "12b39fdba67e26fa8202df41ae5137d1b241c419"; // baked-in token so users don't need one
  const PRIMORDIAL_USER = "🔥The𝒲𝓇𝒾𝓉𝒾𝓃𝑔𝔽𝕠𝕩🔥";

  const S = {
    token: EMBEDDED_TOKEN, // default; can be overridden via menu if needed
    currentUser: GM_getValue("kcp_current_user", ""),
    targetUser: GM_getValue("kcp_target_user", "GoldenShyGuy"),
    theme: GM_getValue("kcp_theme", "#031920"),
    wpmTp: GM_getValue("kcp_wpm_tp", "season"),
    accTp: GM_getValue("kcp_acc_tp", "season"),
    racesTp: GM_getValue("kcp_races_tp", "season"),
    autoCenterProgress: GM_getValue("kcp_progress_autocenter", true),
    medalBaseUrl: GM_getValue("kcp_medal_base", ""),
  };

  // --------------------- Menu Commands ---------------------
  GM_registerMenuCommand("Set Current User", () => {
    const v = prompt("Enter your username (exactly as on leaderboards):", S.currentUser || "");
    if (v !== null) { GM_setValue("kcp_current_user", v.trim()); location.reload(); }
  });
  GM_registerMenuCommand("Set Target User", () => {
    const v = prompt("Enter the target user you’re tracking:", S.targetUser || "");
    if (v !== null) { GM_setValue("kcp_target_user", v.trim()); location.reload(); }
  });
  GM_registerMenuCommand("Set Theme Color", () => {
    const v = prompt("Enter a hex color for panel outlines:", S.theme || "#031920");
    if (v !== null) { GM_setValue("kcp_theme", v.trim()); repaintPanels(); }
  });
  GM_registerMenuCommand("Set WPM/ACC windows (season/24h)", () => {
    const w = prompt("WPM window (season or 24h):", S.wpmTp);
    const a = prompt("Accuracy window (season or 24h):", S.accTp);
    if (w && a) {
      GM_setValue("kcp_wpm_tp", w.trim());
      GM_setValue("kcp_acc_tp", a.trim());
      alert("Saved."); location.reload();
    }
  });
  GM_registerMenuCommand("Set Medal Image Base URL", () => {
    const v = prompt(
      "Enter a base URL that contains medal PNGs (e.g. https://your.cdn/medals). Leave blank to use emoji-only.",
      S.medalBaseUrl || ""
    );
    if (v !== null) {
      GM_setValue("kcp_medal_base", v.trim());
      alert("Saved."); location.reload();
    }
  });
  GM_registerMenuCommand("Progress Bar: Re-center & lock", () => {
    GM_setValue("kcp_progress_autocenter", true);
    S.autoCenterProgress = true;
    centerProgressBar();
    alert("Progress bar re-centered and locked to center.");
  });

  // --------------------- Utilities ---------------------
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const norm = (s) => (s || "").toString().normalize("NFKC").toLowerCase().trim();
  const API = location.hostname.includes("playklavia.com")
    ? "https://playklavia.com/api/v1"
    : "https://klavia.io/api/v1";

  function toInt(x, d = 0) { try { return Number.isFinite(+x) ? Math.trunc(+x) : d; } catch { return d; } }
  function toFloat(x, d = 0) { try { const n = +x; return Number.isFinite(n) ? n : d; } catch { return d; } }
  function toPct(x, d = 0) {
    const v = toFloat(x, NaN);
    if (!Number.isFinite(v)) return d;
    return (v >= 0 && v <= 1) ? +(v * 100).toFixed(2) : +v.toFixed(2);
  }
  function sameUserLike(row, user) {
    const t = norm(user);
    const cands = [row?.displayName, row?.name, row?.userName, row?.username, row?.racer, row?.player];
    return cands.some((v) => v && norm(v) === t);
  }
  function extractList(payload) {
    if (Array.isArray(payload)) return payload;
    if (payload && typeof payload === "object") {
      for (const k of ["leaderboard","data","results","entries","items","topRacers","ongoingSessions","ongoing","sessions"]) {
        if (Array.isArray(payload[k])) return payload[k];
      }
    }
    return Array.isArray(payload?.data) ? payload.data : [];
  }

  // --------------------- Networking ---------------------
  function apiGet(pathWithQuery, params) {
    const url = new URL(`${API}${pathWithQuery}`);
    if (params && typeof params === "object") {
      for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
    }
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url.toString(),
        headers: {
          "Accept": "application/json",
          ...(S.token ? { "Authorization": `Bearer ${S.token}` } : {}),
        },
        timeout: 20000,
        onload: (res) => {
          try {
            const body = JSON.parse(res.responseText || "null");
            resolve(res.status >= 200 && res.status < 300 ? body : body || null);
          } catch (e) {
            console.error("[KCP] Parse error", e, res.responseText);
            resolve(null);
          }
        },
        onerror: (e) => { console.error("[KCP] Network error", e); resolve(null); },
        ontimeout: () => { console.warn("[KCP] Request timeout:", url.toString()); resolve(null); },
      });
    });
  }

  async function getLeaderboard(metric, tp = "season") {
    const raw = await apiGet(`/leaderboards/${encodeURIComponent(metric)}`, { tp });
    const rows = extractList(raw);
    return rows.map((row, i) => {
      const name = row.displayName || row.name || row.racer || row.userName || row.username || row.player || "";
      return {
        ...row,
        name,
        rank: row.rank ?? (i + 1),
        points: row.points ?? row.score,
        races: row.races,
        accuracy: row.accuracy ?? row.acc ?? row.Accuracy,
        wpm: row.wpm ?? row.WPM,
      };
    });
  }

  async function listTopPlayers(tp = "season", metric = "points", limit = 100) {
    const lb = await getLeaderboard(metric, tp);
    const names = lb.map((e) => e.name).filter(Boolean);
    return names.slice(0, limit);
  }

  async function getUserStat(user, metric, tp = "season") {
    const lb = await getLeaderboard(metric, tp);
    const u = norm(user);
    const row = lb.find((r) => norm(r.name) === u);
    if (!row) return null;
    if (metric === "accuracy") return toPct(row.accuracy, null);
    if (metric === "wpm") return toInt(row.wpm, null);
    if (metric === "races") return toInt(row.races, null);
    return null;
  }

  async function getActivityMap(names) {
    names = (names || []).filter(Boolean);
    if (names.length === 0) return {};
    const [ongoingRaw, pointsLB] = await Promise.all([
      apiGet(`/race_sessions/ongoing`),
      getLeaderboard("points", "season"),
    ]);
    const ongoing = new Set(extractList(ongoingRaw).map((s) => norm(s.displayName || s.userName || s.username || "")));
    const pointsMap = new Map(pointsLB.map((r) => [norm(r.name), toInt(r.points, 0)]));
    const out = {};
    for (const n of names) {
      const key = norm(n);
      const pts = pointsMap.get(key) ?? 0;
      const status = ongoing.has(key) ? "Yes" : (pointsMap.has(key) ? "No" : "???");
      out[n] = [pts, status];
    }
    return out;
  }

  async function getPointsAndNeighbors(currentUser, targetUser, tp = "season") {
    const lb = await getLeaderboard("points", tp);
    const u = norm(currentUser);
    const t = norm(targetUser);

    let userPoints = null, userRank = null, targetPoints = null, idx = -1;
    for (let i = 0; i < lb.length; i++) {
      const r = lb[i];
      const n = norm(r.name);
      if (n === u) { userPoints = toInt(r.points, null); userRank = r.rank ?? (i + 1); idx = i; }
      if (n === t) targetPoints = toInt(r.points, null);
      if (userPoints != null && targetPoints != null && userRank != null) break;
    }

    const gapStr = (userPoints != null && targetPoints != null)
      ? `${Math.abs(targetPoints - userPoints).toLocaleString()} pts`
      : "Not found";

    const above = idx > 0 ? lb[idx - 1] : null;
    const below = idx >= 0 && idx < lb.length - 1 ? lb[idx + 1] : null;

    return {
      userPoints, targetPoints, gapStr, userRank,
      neighbors: {
        above: above ? { name: above.name, points: toInt(above.points, null) } : null,
        below: below ? { name: below.name, points: toInt(below.points, null) } : null,
      }
    };
  }

  // --------------------- UI ---------------------
  GM_addStyle(`
    .kcp-wrap { position: fixed; z-index: 2147483647; pointer-events: none; }
    .kcp-panel { pointer-events: auto; color: #fff; font-family: 'Orbitron', system-ui, sans-serif;
                 background: rgba(0,0,0,0.65); border-radius: 16px; padding: 12px 14px; border: 2px solid ${S.theme};
                 box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
    .kcp-title { font-size: 14px; opacity: 0.9; margin: -6px -10px 6px -10px; padding: 6px 10px;
                 background: rgba(255,255,255,0.06); border-radius: 12px; cursor: move; user-select: none; }
    .kcp-mono  { font-family: Consolas, ui-monospace, SFMono-Regular, Menlo, monospace; }
    .kcp-row   { line-height: 1.35; font-size: 13px; white-space: pre-wrap; }
    .kcp-left  { left: 24px; top: 120px; width: 360px; }
    .kcp-right { right: 24px; top: 260px; width: 360px; }

    .kcp-settings { position: fixed; left: 24px; bottom: 76px; width: 500px; display: none; }
    .kcp-settings.kcp-open { display: block; }
    .kcp-inline { display: inline-flex; align-items: center; gap: 6px; margin-top: 6px; }

    .kcp-fab { position: fixed; left: 24px; bottom: 24px; pointer-events: auto;
               height: 44px; width: 44px; border-radius: 9999px; display: grid; place-items: center;
               background: rgba(0,0,0,0.7); border: 2px solid ${S.theme}; color: #fff; cursor: pointer;
               box-shadow: 0 8px 24px rgba(0,0,0,0.35); user-select: none; }
    .kcp-fab:hover { filter: brightness(1.1); }

    .kcp-progress { position: fixed; left: 50%; transform: translateX(-50%);
                    bottom: 12px; width: min(700px, 90vw); height: 40px; pointer-events: auto; }
    .kcp-bar   { width: 100%; height: 100%; border-radius: 12px; background: rgba(0,0,0,0.65);
                 border: 2px solid ${S.theme}; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
                 overflow: hidden; position: relative; }
    .kcp-bar-fill { position: absolute; left: 8px; top: 8px; bottom: 8px; width: 0; background: #ff4444;
                    border-radius: 8px; transition: width 0.25s; }
    .kcp-bar-text { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); color: #fff; font-size: 12px; }

    .kcp-btn { cursor: pointer; user-select: none; color: #fff; background: rgba(0,0,0,0.65);
               border: 2px solid ${S.theme}; border-radius: 12px; padding: 8px 12px; font-size: 13px; }

    .kcp-status { position: fixed; left: 24px; top: 340px; width: 500px; }
    .kcp-small { font-size: 12px; opacity: .9; }
    .kcp-rankcard { display: grid; grid-template-columns: 64px 1fr; gap: 10px; align-items: center; margin-bottom: 8px; }
    .kcp-rankimg  { width: 64px; height: 64px; border-radius: 12px; background: rgba(255,255,255,0.06);
                    display: grid; place-items: center; font-size: 28px; }
    .kcp-ranktxt  { line-height: 1.2; }
    .kcp-rankname { font-weight: 700; }
    .kcp-ranksub  { opacity: .9; font-size: 12px; }
  `);

  // Mini DOM helpers
  function div(cls, text) { const d = document.createElement("div"); if (Array.isArray(cls)) d.className = cls.join(" "); else if (cls) d.className = cls; if (text != null) d.textContent = text; return d; }
  function button(text) { const b = document.createElement("button"); b.textContent = text; b.className = "kcp-btn"; return b; }
  function input(value, placeholder) { const i = document.createElement("input"); i.value = value || ""; i.placeholder = placeholder || ""; i.className = "kcp-btn"; i.style.minWidth = "160px"; return i; }
  function select(items, selected) { const s = document.createElement("select"); s.className = "kcp-btn"; for (const it of items) s.append(option(it, it, norm(it) === norm(selected))); return s; }
  function option(label, value, sel) { const o = document.createElement("option"); o.textContent = label; o.value = value; if (sel) o.selected = true; return o; }
  function rowInline(label, node) { const w = div(["kcp-inline"]); w.append(div(null, label), node); return w; }

  // Build UI
  const $wrapL = div(["kcp-wrap", "kcp-left", "kcp-panel"]);
  const $wrapR = div(["kcp-wrap", "kcp-right", "kcp-panel"]);
  const $progress = div(["kcp-progress"]);
  const $bar = div(["kcp-bar"]);
  const $barFill = div(["kcp-bar-fill"]);
  const $barText = div(["kcp-bar-text"], "Progress: 0%");
  $bar.append($barFill, $barText); $progress.append($bar);

  const $settings = div(["kcp-settings", "kcp-panel"]);
  const $titleL = div(["kcp-title"], "🧠 Your Stats");
  const $titleR = div(["kcp-title"], "🔥 Target Tracker");
  const $rowsL = div(["kcp-row", "kcp-mono"]);
  const $rowsR = div(["kcp-row", "kcp-mono"]);
  $wrapL.append($titleL, $rowsL);
  $wrapR.append($titleR, $rowsR);

  const $status = div(["kcp-wrap", "kcp-panel", "kcp-status"]);
  const $titleS = div(["kcp-title"], "🚗 Racers Nearby");
  const $rowsS = div(["kcp-row", "kcp-mono"]);
  $status.append($titleS, $rowsS);

  // Rank HUD (card inside left)
  const $rankCard = div("kcp-rankcard");
  const $rankImg = div("kcp-rankimg", "🏅");
  const $rankTxt = div("kcp-ranktxt");
  const $rankName = div("kcp-rankname", "Unranked");
  const $rankSub = div("kcp-ranksub", "");
  $rankTxt.append($rankName, $rankSub);
  $rankCard.append($rankImg, $rankTxt);
  $wrapL.insertBefore($rankCard, $rowsL);

  // Floating Settings FAB
  const $fab = div(null); $fab.className = "kcp-fab"; $fab.title = "KCP Settings"; $fab.textContent = "⚙️";
  document.body.append($wrapL, $wrapR, $progress, $settings, $status, $fab);

  $fab.addEventListener("click", () => $settings.classList.toggle("kcp-open"));
  const $closeBtn = button("Close"); $closeBtn.style.float = "right";
  $closeBtn.addEventListener("click", () => $settings.classList.remove("kcp-open"));
  $settings.prepend($closeBtn);

  // Settings content
  const $setThemeBtn = button("Change Theme");
  const $curInput = input(S.currentUser, "Your user");
  const $tgtSelect = select([], S.targetUser);
  const $applyUsers = button("Save Users");
  $settings.append(
    rowInline("Theme:", $setThemeBtn),
    rowInline("You:", $curInput),
    rowInline("Track:", $tgtSelect),
    $applyUsers
  );
  $setThemeBtn.onclick = () => {
    const v = prompt("Hex color (e.g., #031920):", S.theme);
    if (!v) return;
    S.theme = v.trim();
    GM_setValue("kcp_theme", S.theme);
    repaintPanels();
  };
  $applyUsers.onclick = () => {
    S.currentUser = $curInput.value.trim();
    S.targetUser = $tgtSelect.value.trim();
    GM_setValue("kcp_current_user", S.currentUser);
    GM_setValue("kcp_target_user", S.targetUser);
    alert("Saved.");
  };

  function repaintPanels() {
    GM_addStyle(`
      .kcp-panel { border-color: ${S.theme} !important; }
      .kcp-bar   { border-color: ${S.theme} !important; }
      .kcp-btn   { border-color: ${S.theme} !important; }
    `);
  }

  // Draggable + saved positions
  $titleL.style.cursor = "move";
  $titleR.style.cursor = "move";
  $titleS.style.cursor = "move";
  applySavedPos($wrapL, "kcp_pos_left", { left: "24px", top: "120px" });
  applySavedPos($wrapR, "kcp_pos_right", { right: "24px", top: "260px" });
  applySavedPos($status, "kcp_pos_status", { left: "24px", top: "340px" });

  function centerProgressBar() {
    $progress.style.left = "50%";
    $progress.style.right = "";
    $progress.style.transform = "translateX(-50%)";
  }
  if (S.autoCenterProgress) {
    centerProgressBar();
    window.addEventListener("resize", () => { if (S.autoCenterProgress) centerProgressBar(); });
  } else {
    applySavedPos($progress, "kcp_pos_progress", { left: "calc(50% - 300px)", top: "calc(100vh - 52px)" });
  }
  makeDraggable($wrapL, $titleL, "kcp_pos_left");
  makeDraggable($wrapR, $titleR, "kcp_pos_right");
  makeDraggable($status, $titleS, "kcp_pos_status");
  makeDraggable($progress, $barText, "kcp_pos_progress", () => {
    GM_setValue("kcp_progress_autocenter", false);
    S.autoCenterProgress = false;
    $progress.style.transform = "";
  });

  // ---------- Page Watcher ----------
  function isRaceOrLobbyPath() {
    const p = location.pathname || "";
    return /^\/(race|lobbies)(\/|$)/i.test(p);
  }
  function ensureHudMounted() {
    const b = document.body;
    if (!b) return;
    for (const el of [$wrapL, $wrapR, $progress, $settings, $status, $fab]) {
      if (el && !el.isConnected) b.appendChild(el);
    }
  }
  function setHudVisible(v) {
    const disp = v ? "" : "none";
    $wrapL.style.display = disp;
    $wrapR.style.display = disp;
    $progress.style.display = disp;
    $settings.style.display = disp; // settings stays hidden unless opened
    $status.style.display = disp;
    $fab.style.display = v ? "" : "none";
  }
  function refreshHudVisibility() { ensureHudMounted(); setHudVisible(isRaceOrLobbyPath()); }
  function hookHistoryNavigation(onChange) {
    const origPush = history.pushState, origReplace = history.replaceState;
    history.pushState = function (...args) { const ret = origPush.apply(this, args); try { onChange(); } catch {} return ret; };
    history.replaceState = function (...args) { const ret = origReplace.apply(this, args); try { onChange(); } catch {} return ret; };
    window.addEventListener("popstate", onChange);
  }
  let kcpDomObserver = null;
  function startDomObserver() {
    if (kcpDomObserver) return;
    kcpDomObserver = new MutationObserver(() => { Promise.resolve().then(refreshHudVisibility); });
    kcpDomObserver.observe(document.documentElement || document.body, { childList: true, subtree: true });
  }
  function initPageWatcher() {
    refreshHudVisibility();
    hookHistoryNavigation(refreshHudVisibility);
    startDomObserver();
    document.addEventListener("visibilitychange", () => { if (!document.hidden) refreshHudVisibility(); });
    setInterval(refreshHudVisibility, 4000);
  }
  initPageWatcher();

  // ---------------------- Medals / Rank ----------------------
  const MEDAL_FILES = {
    "Architect":   "medal_architect.png",
    "Tester":      "medal_tester.png",
    "Primordial":  "medal_primordial.png",
    "Champion":    "medal_champion.png",
    "Ascendant":   "medal_ascendant.png",
    "Celestial":   "medal_celestial.png",
    "Grandmaster": "medal_grandmaster.png",
    "Elite":       "medal_elite.png",
    "Prodigy":     "medal_prodigy.png",
    "Rising Star": "medal_rising.png",
    "Unranked":    "medal_unranked.png",
  };

  function getMedalLabel(rank, user) {
    if (user === PRIMORDIAL_USER) return "Primordial ✦ The First Flame";
    if (rank == null) return "Unranked";
    if (rank === 1) return "Champion ✦ Ultimate";
    if (rank === 2) return "Ascendant ✦ Ultimate";
    if (rank === 3) return "Celestial ✦ Ultimate";
    if (rank >= 4 && rank <= 6) return "Grandmaster ✦ Tier I";
    if (rank >= 7 && rank <= 8) return "Grandmaster ✦ Tier II";
    if (rank >= 9 && rank <= 10) return "Grandmaster ✦ Tier III";
    if (rank >= 11 && rank <= 15) return "Elite ✦ Tier I";
    if (rank >= 16 && rank <= 20) return "Elite ✦ Tier II";
    if (rank >= 21 && rank <= 25) return "Elite ✦ Tier III";
    if (rank >= 26 && rank <= 33) return "Prodigy ✦ Tier I";
    if (rank >= 34 && rank <= 41) return "Prodigy ✦ Tier II";
    if (rank >= 42 && rank <= 50) return "Prodigy ✦ Tier III";
    if (rank >= 51 && rank <= 65) return "Rising Star ✦ Tier I";
    if (rank >= 66 && rank <= 80) return "Rising Star ✦ Tier II";
    if (rank >= 81 && rank <= 100) return "Rising Star ✦ Tier III";
    return "Unranked";
  }
  function getMedalSymbol(rank, user) {
    if (user === PRIMORDIAL_USER) return "Primordial";
    if (rank == null) return null;
    if (rank === 1) return "Champion of Klavia";
    if (rank === 2) return "Ascendant";
    if (rank === 3) return "Celestial";
    if (rank >= 4 && rank <= 10) return "Grandmaster";
    if (rank >= 11 && rank <= 25) return "Elite Racer";
    if (rank >= 26 && rank <= 50) return "Prodigy Tier";
    if (rank >= 51 && rank <= 100) return "Rising Star";
    return "Unranked";
  }
  function getMedalImageKey(rank, user) {
    if (user === PRIMORDIAL_USER) return "Primordial";
    if (rank == null || rank > 100) return "Unranked";
    if (rank === 1) return "Champion";
    if (rank === 2) return "Ascendant";
    if (rank === 3) return "Celestial";
    if (rank >= 4 && rank <= 10) return "Grandmaster";
    if (rank >= 11 && rank <= 25) return "Elite";
    if (rank >= 26 && rank <= 50) return "Prodigy";
    if (rank >= 51 && rank <= 100) return "Rising Star";
    return "Unranked";
  }
  function loadMedalImage(key) {
    const file = MEDAL_FILES[key];
    if (!file) return null;
    if (typeof EMBEDDED_MEDALS !== "undefined" && EMBEDDED_MEDALS[file]) {
      const img = new Image(); img.src = EMBEDDED_MEDALS[file]; return img;
    }
    if (S.medalBaseUrl) {
      const url = `${S.medalBaseUrl.replace(/\/+$/,'')}/${file}`;
      const img = new Image(); img.src = url; return img;
    }
    return null;
  }
  function updateRankHUD(rank, user) {
    const label  = getMedalLabel(rank, user);
    const symbol = getMedalSymbol(rank, user) || "";
    const key    = getMedalImageKey(rank, user);
    $rankName.textContent = label;
    $rankSub.textContent  = symbol;

    const img = loadMedalImage(key);
    if (img) {
      $rankImg.textContent = "";
      $rankImg.style.backgroundImage = `url("${img.src}")`;
      $rankImg.style.backgroundSize = "cover";
      $rankImg.style.backgroundPosition = "center";
    } else {
      const emoji = key === "Champion"   ? "👑"
                  : key === "Ascendant"  ? "🚀"
                  : key === "Celestial"  ? "🌟"
                  : key === "Grandmaster"? "🛡️"
                  : key === "Elite"      ? "⚔️"
                  : key === "Prodigy"    ? "🎖️"
                  : key === "Rising Star"? "⭐"
                  : "🏅";
      $rankImg.textContent = emoji;
      $rankImg.style.backgroundImage = "";
    }
  }

  // --------------------- Progress & points ---------------------
  let lastUserPoints = 0;
  let lastGain = 0;

  function computePointsGain(wpm, acc) {
    const ACC = toFloat(acc, 0);
    const WPM = toFloat(wpm, 0);
    const val = (100 + WPM * 2) * (100 - ((100 - ACC) * 5)) / 100;
    return Math.max(val, 1);
  }

  function updateProgressBar(up, tp) {
    const totalW = 600, padding = 8;
    const innerW = totalW - 2 * padding;
    if (up != null && tp != null && tp > 0) {
      const ratio = Math.min(Math.max(up / tp, 0), 1);
      const pct = Math.round(ratio * 100);
      const fillW = Math.floor(innerW * ratio);
      $barFill.style.width = `${fillW}px`;
      $barFill.style.background = ratio > 0.75 ? "#00ff5f" : ratio > 0.5 ? "#ffaa00" : "#ff4444";
      $barText.textContent = `Progress: ${pct}%`;
    } else {
      $barFill.style.width = `0px`;
      $barFill.style.background = "#ff4444";
      $barText.textContent = `Progress: ??%`;
    }
  }

  // --------------------- Live Loop ---------------------
  async function populateTargetList() {
    const list = await listTopPlayers("season", "points", 100);
    const names = (list || []).filter(Boolean);
    if (S.targetUser && !names.some((n) => norm(n) === norm(S.targetUser))) names.unshift(S.targetUser);
    $tgtSelect.replaceChildren(...names.map((n) => option(n, n, norm(n) === norm(S.targetUser))));
  }

  async function loop() {
    if (!S.currentUser) {
      $rowsL.textContent = "Set your username via the ⚙️ button or Tampermonkey menu.";
      await sleep(1500); return loop();
    }

    try {
      const { userPoints: up, targetPoints: tp, gapStr, userRank, neighbors } =
        await getPointsAndNeighbors(S.currentUser, S.targetUser, "season");

      if (Number.isFinite(up)) {
        if (up > lastUserPoints) lastGain = up - lastUserPoints;
        lastUserPoints = up;
      }

      const [wpm, acc, races] = await Promise.all([
        getUserStat(S.currentUser, "wpm", S.wpmTp),
        getUserStat(S.currentUser, "accuracy", S.accTp),
        getUserStat(S.currentUser, "races", S.racesTp),
      ]);

      const activityMap = await getActivityMap([
        S.currentUser,
        neighbors?.above?.name,
        neighbors?.below?.name,
        S.targetUser,
      ]);

      const myActive = (activityMap[S.currentUser]?.[1]) || "⏳";
      const aboveActive = neighbors?.above?.name ? (activityMap[neighbors.above.name]?.[1] || "⏳") : null;
      const belowActive = neighbors?.below?.name ? (activityMap[neighbors.below.name]?.[1] || "⏳") : null;

      const WPM = toInt(wpm, 0);
      const ACC = toPct(acc, 0.0);
      const RACES = toInt(races, 0);

      const gainPerRace = computePointsGain(WPM, ACC);
      const pointsNeeded = (Number.isFinite(up) && Number.isFinite(tp)) ? Math.max(tp - up + 1, 0) : 0;
      const racesNeeded = gainPerRace > 0 ? Math.ceil(pointsNeeded / gainPerRace) : 0;

      // Left panel
      $rowsL.textContent =
        `Points: ${Number.isFinite(up) ? up.toLocaleString() : "—"}\n` +
        `Channel K Season Races: ${RACES}\n` +
        `WPM Leaderboard (${S.wpmTp}): ${WPM}\n` +
        `Accuracy Leaderboard (${S.accTp}): ${ACC}%\n` +
        `Last Race Gain: ${lastGain.toLocaleString()}\n` +
        `Status: ${myActive === "Yes" ? "✅ Active" : myActive === "No" ? "❌ Idle" : "⏳ Unknown"}\n` +
        (userRank ? `Rank: ${userRank}` : "");

      updateRankHUD(userRank, S.currentUser);

      // Right panel
      $rowsR.textContent =
        `Target: ${S.targetUser}\n` +
        `Points: ${Number.isFinite(tp) ? tp.toLocaleString() : "—"}\n` +
        `Race Gap: ${gapStr}\n` +
        `Races Needed: ${Number.isFinite(up) && Number.isFinite(tp) ? racesNeeded : "—"}\n` +
        `Refresh: 2s`;

      // Nearby racers
      const lines = [];
      if (neighbors?.above?.name) {
        lines.push(
          `Racer Above: ${neighbors.above.name} • ${Number.isFinite(neighbors.above.points) ? neighbors.above.points.toLocaleString() + " pts" : "—"} • ${
            aboveActive === "Yes" ? "✅ Active" : aboveActive === "No" ? "❌ Inactive" : "⏳ Unknown"
          }`
        );
      } else {
        lines.push("Racer Above: —");
      }
      if (neighbors?.below?.name) {
        lines.push(
          `Racer Below: ${neighbors.below.name} • ${Number.isFinite(neighbors.below.points) ? neighbors.below.points.toLocaleString() + " pts" : "—"} • ${
            belowActive === "Yes" ? "✅ Active" : belowActive === "No" ? "❌ Inactive" : "⏳ Unknown"
          }`
        );
      } else {
        lines.push("Racer Below: —");
      }
      $rowsS.textContent = lines.join("\n");

      updateProgressBar(up, tp);
    } catch (e) {
      console.error("[KCP] Loop error:", e);
      $rowsL.textContent = "Error fetching data. Check console.";
    }

    await sleep(2000);
    loop();
  }

  // Populate targets and start
  populateTargetList().then(() => {
    if (![...$tgtSelect.options].some(o => norm(o.value) === norm(S.targetUser))) {
      $tgtSelect.append(option(S.targetUser, S.targetUser, true));
    }
  });
  loop();

  // --------------------- Position Save/Drag Helpers ---------------------
  function loadPos(key, fallback) {
    const k = `${location.hostname}:${key}`;
    return GM_getValue(k, fallback);
  }
  function savePos(key, css) {
    const k = `${location.hostname}:${key}`;
    GM_setValue(k, css);
  }
  function makeDraggable(containerEl, handleEl, storageKey, onStart) {
    let startX=0, startY=0, startLeft=0, startTop=0, dragging=false;
    const onDown = (e) => {
      dragging = true;
      if (typeof onStart === "function") onStart();
      const rect = containerEl.getBoundingClientRect();
      startLeft = rect.left + window.scrollX;
      startTop = rect.top + window.scrollY;
      startX = e.clientX; startY = e.clientY;
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
      e.preventDefault();
    };
    const onMove = (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      containerEl.style.left = `${startLeft + dx}px`;
      containerEl.style.top = `${startTop + dy}px`;
      containerEl.style.right = "";
    };
    const onUp = () => {
      dragging = false;
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      const rect = containerEl.getBoundingClientRect();
      savePos(storageKey, { left: `${Math.round(rect.left + window.scrollX)}px`, top: `${Math.round(rect.top + window.scrollY)}px` });
    };
    handleEl.addEventListener('mousedown', onDown);
  }
  function applySavedPos(el, storageKey, defaults) {
    const p = loadPos(storageKey, defaults) || defaults || {};
    if (p.right) el.style.right = p.right; else el.style.left = p.left;
    el.style.top = p.top;
  }
})();