Bonk.io Win/Loss Tracker (Bonkverse, verified)

Tracks your Bonk.io wins & losses with server-side verified accounts using BonkBot API. Secure sessions, rate limiting, and server-determined usernames only. Auto-cleans session on tab close.

// ==UserScript==
// @name         Bonk.io Win/Loss Tracker (Bonkverse, verified)
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Tracks your Bonk.io wins & losses with server-side verified accounts using BonkBot API. Secure sessions, rate limiting, and server-determined usernames only. Auto-cleans session on tab close.
// @author       you
// @match        https://bonk.io/gameframe-release.html
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  console.log("Loading Bonk.io Win/Loss Tracker (Bonkverse, verified)...");

  // ---------- Config ----------
  const API_BASE = "https://bonkverse.io";
  const API_REQUEST_VERIFICATION = `${API_BASE}/api/request_verification/`;
  const API_COMPLETE_VERIFICATION = `${API_BASE}/api/complete_verification/`;
  const API_WINS = `${API_BASE}/api/wins/`;
  const API_LOSSES = `${API_BASE}/api/losses/`;
  const API_HEARTBEAT = `${API_BASE}/api/heartbeat/`;
  const API_STOP = `${API_BASE}/api/stop_tracking/`;

  // Make sure to update version above as well
  const VERSION = "4.0";

  // ---------- State ----------
  let currentUser = null;
  let winsTotal = 0;
  let lossesTotal = 0;
  let lastWinName = null;
  let lastWinTime = 0;
  let lastLossName = null;
  let lastLossTime = 0;
  let statusMessage = "";
  let sessionToken = null;
  let heartbeatTimer = null;
  let verificationId = null;
  let verificationRoom = null;

  // ---------- Utils ----------
  function getCurrentMap() {
    const el = document.getElementById("newbonklobby_maptext");
    return el ? (el.textContent || "").trim() : null;
  }

  function setStatus(msg, color = "#aaa") {
    statusMessage = `<div style="margin-top:6px; font-size:12px; color:${color};">${msg}</div>`;
    updateUI();
  }

  // ---------- Verification Flow ----------
  function startVerification() {
    setStatus("⏳ Requesting verification room...");
    fetch(API_REQUEST_VERIFICATION, { method: "POST" })
      .then((res) => res.json())
      .then((data) => {
        if (data.success) {
          verificationId = data.verification_id;
          verificationRoom = data.room_url;
          setStatus(
            `🔗 Join room to verify: <a href="${verificationRoom}" target="_blank" style="color:#6cf">Click here</a>`,
            "#80ff80"
          );
          pollVerification();
        } else {
          setStatus(`❌ Failed: ${data.reason}`, "#ff4d4d");
        }
      })
      .catch((err) => {
        console.error(err);
        setStatus("❌ Error contacting server.", "#ff4d4d");
      });
  }

  function pollVerification() {
    const start = Date.now();
    const interval = setInterval(() => {
      fetch(API_COMPLETE_VERIFICATION, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ verification_id: verificationId }),
      })
        .then((res) => res.json())
        .then((data) => {
          if (data.success) {
            clearInterval(interval);
            currentUser = data.username;
            sessionToken = data.token;
            setStatus(`✅ Verified as ${currentUser}`, "#80ff80");

            document.getElementById("startTrackBtn").style.display = "none";
            document.getElementById("stopTrackBtn").style.display = "inline-block";

            heartbeatTimer = setInterval(() => {
              fetch(API_HEARTBEAT, {
                method: "POST",
                headers: { Authorization: "Bearer " + sessionToken },
              });
            }, 30000);
          } else {
            const reason = (data.reason || "").toLowerCase();
            if (reason.includes("not verified yet")) {
              // still pending
            } else {
              clearInterval(interval);
              setStatus(`❌ Verification failed: ${data.reason}`, "#ff4d4d");
              document.getElementById("stopTrackBtn").style.display = "none";
              document.getElementById("startTrackBtn").style.display = "inline-block";
            }
          }

          if (Date.now() - start > 70000) {
            clearInterval(interval);
            setStatus("❌ Verification timed out", "#ff4d4d");
            document.getElementById("stopTrackBtn").style.display = "none";
            document.getElementById("startTrackBtn").style.display = "inline-block";
          }
        })
        .catch((err) => {
          console.error("pollVerification error:", err);
          clearInterval(interval);
          setStatus("❌ Error contacting server.", "#ff4d4d");
          document.getElementById("stopTrackBtn").style.display = "none";
          document.getElementById("startTrackBtn").style.display = "inline-block";
        });
    }, 3000);
  }

  // ---------- Session Stop ----------
  function stopTracking(sync = false) {
    if (!sessionToken) return;
    if (sync && navigator.sendBeacon) {
      const headers = { type: "application/json" };
      const blob = new Blob([JSON.stringify({})], headers);
      navigator.sendBeacon(API_STOP, blob);
    } else {
      fetch(API_STOP, {
        method: "POST",
        headers: { Authorization: "Bearer " + sessionToken },
      }).finally(() => {
        clearInterval(heartbeatTimer);
        sessionToken = null;
        currentUser = null;
        setStatus("ℹ️ Tracking stopped", "#aaa");
        document.getElementById("stopTrackBtn").style.display = "none";
        document.getElementById("startTrackBtn").style.display = "inline-block";
      });
    }
  }

  // ---------- UI ----------
  function injectUI() {
    if (!document.getElementById("winTrackerBox")) {
      const box = document.createElement("div");
      box.id = "winTrackerBox";
      box.innerHTML = `
        <div id="winTrackerHeader">
          <span id="winTrackerTitle">🏆 Bonkverse Tracker v${VERSION}</span>
          <button id="winTrackerToggle">–</button>
        </div>
        <div id="winTrackerContent"></div>
        <div id="winTrackerControls" style="margin:10px; display:flex; gap:10px; justify-content:center;">
          <button id="startTrackBtn" class="bv-btn bv-btn-start">▶ Verify & Start</button>
          <button id="stopTrackBtn" class="bv-btn bv-btn-stop" style="display:none;">⏹ Stop</button>
        </div>
        <div style="margin-top:8px; font-size:12px; color:#aaa;">
          Check your rank:
          <a href="https://bonkverse.io/leaderboards/wins/today/" target="_blank" style="color:#00e6c3; text-decoration:underline;">
            Bonkverse Wins
          </a> |
          <a href="https://bonkverse.io/leaderboards/losses/today/" target="_blank" style="color:#ff4d4d; text-decoration:underline;">
            Bonkverse Losses
          </a>
        </div>
      `;

      Object.assign(box.style, {
        position: "fixed",
        top: "100px",
        right: "20px",
        background: "rgba(26,39,51,0.9)",
        border: "2px solid #009688",
        boxShadow: "0px 0px 12px rgba(0, 230, 195, 0.6)",
        borderRadius: "10px",
        fontFamily: '"Oxanium", sans-serif',
        color: "#ddd",
        zIndex: "99999",
        minWidth: "240px",
        textAlign: "center",
        userSelect: "none",
      });

      const header = box.querySelector("#winTrackerHeader");
      Object.assign(header.style, {
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        background: "rgba(0,150,136,0.2)",
        padding: "6px 10px",
        cursor: "move",
        borderBottom: "1px solid rgba(255,255,255,0.1)"
      });

      makeDraggable(box, header);

      const toggleBtn = box.querySelector("#winTrackerToggle");
      Object.assign(toggleBtn.style, {
        background: "none",
        border: "none",
        color: "#ddd",
        fontSize: "16px",
        cursor: "pointer"
      });
      toggleBtn.addEventListener("click", () => {
        const content = box.querySelector("#winTrackerContent");
        const controls = box.querySelector("#winTrackerControls");
        if (content.style.display === "none") {
          content.style.display = "block";
          controls.style.display = "flex";
          toggleBtn.textContent = "–";
        } else {
          content.style.display = "none";
          controls.style.display = "none";
          toggleBtn.textContent = "+";
        }
      });

      document.body.appendChild(box);

      document.getElementById("startTrackBtn").onclick = startVerification;
      document.getElementById("stopTrackBtn").onclick = () => stopTracking(false);
    }
  }

  function makeDraggable(el, handle) {
    let offsetX = 0, offsetY = 0, dragging = false;
    handle.onmousedown = (e) => {
      dragging = true;
      offsetX = e.clientX - el.offsetLeft;
      offsetY = e.clientY - el.offsetTop;
      document.onmousemove = (e) => {
        if (dragging) {
          el.style.left = e.clientX - offsetX + "px";
          el.style.top = e.clientY - offsetY + "px";
          el.style.right = "auto";
        }
      };
      document.onmouseup = () => {
        dragging = false;
        document.onmousemove = null;
        document.onmouseup = null;
      };
    };
  }

  function updateUI() {
    injectUI();
    const content = document.querySelector("#winTrackerContent");
    if (content) {
      if (currentUser) {
        const mapName = getCurrentMap() || "Unknown";
        content.innerHTML = `
          <div style="margin:8px 0;">👤 <span style="color:#6cf">${currentUser}</span></div>
          <div>🏆 Wins this session: <span style="color:#00ffcc">${winsTotal}</span></div>
          <div>💀 Losses this session: <span style="color:#ff4d4d">${lossesTotal}</span></div>
          <div>🗺 Map: <span style="color:#ffcc00">${mapName}</span></div>
          ${statusMessage}
        `;
      } else {
        content.innerHTML = `<div style="color:#ff4d4d">❌ Not verified</div>${statusMessage}`;
      }
    }
  }

  // ---------- API send ----------
  function sendWinToServer(username) {
    if (!sessionToken) return;
    const mapName = getCurrentMap();
    const now = Date.now();
    if (now - lastWinTime < 5000) return;
    lastWinTime = now;

    fetch(API_WINS, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + sessionToken,
      },
      body: JSON.stringify({ username, ts: now, map_name: mapName || "Unknown" }),
    });
  }

  function sendLossToServer(username) {
    if (!sessionToken) return;
    const mapName = getCurrentMap();
    const now = Date.now();
    if (now - lastLossTime < 5000) return;
    lastLossTime = now;

    fetch(API_LOSSES, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + sessionToken,
      },
      body: JSON.stringify({ username, ts: now, map_name: mapName || "Unknown" }),
    });
  }

  // ---------- Detect win/loss ----------
  function updateResult() {
    const topEl = document.getElementById("ingamewinner_top");
    if (!topEl) return;
    const winnerName = (topEl.textContent || "").trim();
    if (!currentUser || !winnerName) return;

      const teamNames = ["red team", "blue team", "yellow team", "green team"];

    if (winnerName === currentUser && lastWinName !== winnerName) {
      winsTotal++;
      lastWinName = winnerName;
      console.log(`Win detected for ${winnerName} → total=${winsTotal}`);
      updateUI();
      sendWinToServer(currentUser);
    } 
    else if (winnerName !== currentUser && lastLossName !== winnerName) {
      // 🛑 Skip team-based wins
      const lower = winnerName.toLowerCase();
      if (teamNames.includes(lower)) {
        console.log(`Ignoring team result: ${winnerName}`);
        return;
      }

      lossesTotal++;
      lastLossName = winnerName;
      console.log(`Loss detected for ${currentUser} → total=${lossesTotal}`);
      updateUI();
      sendLossToServer(currentUser);
    }

  }

  // ---------- Winner observer ----------
  function hookWinnerObserver() {
    const target = document.getElementById("ingamewinner");
    if (!target) {
      setTimeout(hookWinnerObserver, 1000);
      return;
    }
    const observer = new MutationObserver(() => {
      const visible = target.style.visibility !== "hidden";
      if (visible) updateResult();
      else {
        lastWinName = null;
        lastLossName = null;
      }
    });
    observer.observe(target, { attributes: true, attributeFilter: ["style"], subtree: true });
    console.log("Win/Loss Tracker: observer attached to #ingamewinner");
  }

  // ---------- Inject CSS ----------
  const style = document.createElement("style");
  style.textContent = `
    .bv-btn {
      padding: 8px 14px;
      border-radius: 10px;
      font-family: "Oxanium", sans-serif;
      font-size: 14px;
      font-weight: bold;
      cursor: pointer;
      transition: border-color .15s ease, transform .05s ease, box-shadow .2s ease;
      border: 1px solid rgba(0, 230, 195, .35);
      background: linear-gradient(180deg, rgba(0,150,136,.25), rgba(0,150,136,.15));
      color: #fff;
      text-shadow: 0 0 4px rgba(0,0,0,0.6);
    }
    .bv-btn:hover {
      border-color: rgba(0,230,195,.75);
      box-shadow: 0 0 8px rgba(0,230,195,.6);
    }
    .bv-btn:active {
      transform: translateY(1px) scale(.97);
    }
    .bv-btn-start {
      background: linear-gradient(180deg, rgba(40,167,69,.4), rgba(40,167,69,.2));
      border-color: rgba(40,167,69,.55);
    }
    .bv-btn-start:hover {
      border-color: rgba(40,167,69,.9);
      box-shadow: 0 0 10px rgba(40,167,69,.7);
    }
    .bv-btn-stop {
      background: linear-gradient(180deg, rgba(220,53,69,.4), rgba(220,53,69,.2));
      border-color: rgba(220,53,69,.55);
    }
    .bv-btn-stop:hover {
      border-color: rgba(220,53,69,.9);
      box-shadow: 0 0 10px rgba(220,53,69,.7);
    }
  `;
  document.head.appendChild(style);

  // ---------- Init ----------
  hookWinnerObserver();
  setInterval(updateUI, 2000);
  window.addEventListener("beforeunload", () => stopTracking(true));
})();