Bumpyball Server Queue

Queue to join full servers when open.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Bumpyball Server Queue
// @namespace    https://greasyfork.org/en/users/1574974-dylko
// @version      1.2
// @description  Queue to join full servers when open.
// @author       Dylko
// @match        https://www.bumpyball.io/*
// @match        https://www.pucks.io/*
// @run-at       document-start
// @grant        none
// @license      All Rights Reserved
// ==/UserScript==

(() => {
  "use strict";

  /******************************************************************
   * STORAGE / CONFIG
   ******************************************************************/

  const STORAGE_KEY = "DYLKO_DYNAMIC_QUEUE_V5";

  const saved = (() => {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
    } catch {
      return {};
    }
  })();

  // Old versions saved target; ignore and remove it so reloads always start clean.
  if (Object.prototype.hasOwnProperty.call(saved, "target")) {
    try {
      delete saved.target;
      localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
    } catch {}
  }

  const CFG = {
    listingUrl: "https://nifty-condition-169823.appspot.com/Listing?Game=BumpyBall&ListingVersion=3",

    // Full list refresh. Keep this slower because it pings every lobby.
    scanIntervalMs: saved.scanIntervalMs || 5000,

    // Active queue check. This only pings the selected target lobby.
    // 200ms = 5 checks/sec.
    queuePollMs: saved.queuePollMs || 200,

    // Canvas-relative default home-screen Play button.
    // Calibrated from your canvas:
    // client 275,430 on canvas 943.2x825.6 => 0.29156064, 0.52083331
defaultPlayClickNorm: {
  x: 0.175,
  y: 0.5208333102307589,
  capturedAt: "default-home-play-normalized",
  label: "Default Play Button",
},

    // Optional custom trigger. If set, this overrides defaultPlayClickNorm.
    decoyClick: saved.decoyClick || null,

    requireOpenSlot: true,

    // Auto click retry behavior.
    decoyRetryMax: saved.decoyRetryMax || 12,
    decoyRetryDelayMs: saved.decoyRetryDelayMs || 550,

    // Keep the UI visible. Earlier versions hid the panel after firing the trigger,
    // which made it look like the queue was still running/disappearing instead of stopping.
    hidePanelOnTriggerFire: false,
    hidePanelOnRedirect: false,

    // Keep false unless debugging.
    dryRun: false,

    notify: saved.notify ?? true,
    beep: saved.beep ?? true,
  };

  const state = {
    uiReady: false,

    lobbies: [],
    // Do NOT restore saved target on page load. Each reload must start with no queued server.
    target: null,

    queueing: false,
    armed: false,
    captureMode: false,

    scanTimer: null,
    queueTimer: null,
    decoyTimer: null,

    lastTargetPing: null,
    lastStatus: "loading; choose a server",
    serversVisible: true,
    stoppedAfterJoin: false,

    redirects: [],
    websockets: [],
  };

  function save() {
    localStorage.setItem(
      STORAGE_KEY,
      JSON.stringify({
        scanIntervalMs: CFG.scanIntervalMs,
        queuePollMs: CFG.queuePollMs,
        decoyRetryMax: CFG.decoyRetryMax,
        decoyRetryDelayMs: CFG.decoyRetryDelayMs,
        defaultPlayClickNorm: CFG.defaultPlayClickNorm,
        decoyClick: CFG.decoyClick,
        hidePanelOnTriggerFire: CFG.hidePanelOnTriggerFire,
        hidePanelOnRedirect: CFG.hidePanelOnRedirect,
        notify: CFG.notify,
        beep: CFG.beep,
      })
    );
  }

  /******************************************************************
   * HELPERS
   ******************************************************************/

  const targetPingUrl = () => {
    if (!state.target) return null;
    return `https://${state.target.host}:${state.target.port}/Ping?nocache=${Date.now()}`;
  };

  const targetWsUrl = () => {
    if (!state.target) return null;
    return `wss://${state.target.host}:${state.target.port}/server`;
  };

  function getCanvas() {
    return document.getElementById("unity-canvas") || document.querySelector("canvas");
  }

  function resolveCanvasPoint(normPoint) {
    const canvas = getCanvas();
    if (!canvas || !normPoint) return null;

    const rect = canvas.getBoundingClientRect();

    const x = rect.left + rect.width * normPoint.x;
    const y = rect.top + rect.height * normPoint.y;

    return {
      x,
      y,
      screenX: window.screenX + x,
      screenY: window.screenY + y,
      label: normPoint.label || "Canvas-relative point",
      normX: normPoint.x,
      normY: normPoint.y,
      capturedAt: normPoint.capturedAt || "canvas-relative",
    };
  }

  function getTriggerClick() {
    // Custom captured trigger still wins.
    if (CFG.decoyClick) return CFG.decoyClick;

    // Default Play button is canvas-relative.
    return resolveCanvasPoint(CFG.defaultPlayClickNorm);
  }

  function getTriggerLabel() {
    return CFG.decoyClick ? "Custom trigger" : "Default Play button auto-detected";
  }

  function log(...args) {
    console.log("[DylkoDQ]", ...args);
  }

  function warn(...args) {
    console.warn("[DylkoDQ]", ...args);
  }

  async function fetchJson(url) {
    const res = await fetch(url, { cache: "no-store" });
    return await res.json();
  }

  function pointText(p) {
    if (!p) return "not set";

    const base = `${Math.round(p.x)}, ${Math.round(p.y)}`;

    if (Number.isFinite(p.normX) && Number.isFinite(p.normY)) {
      return `${base} / norm ${p.normX.toFixed(4)}, ${p.normY.toFixed(4)}`;
    }

    return base;
  }

  function hidePanel(reason = "hidden") {
    const panel = document.getElementById("dylko-dq-panel");
    if (panel) {
      panel.style.display = "none";
    }

    state.lastStatus = reason;
    warn("panel hidden:", reason);
  }

  function showPanel() {
    const panel = document.getElementById("dylko-dq-panel");
    if (panel) {
      panel.style.display = "block";
    }

    render();
    warn("panel shown");
  }

  function togglePanel() {
    const panel = document.getElementById("dylko-dq-panel");
    if (!panel) return;

    panel.style.display = panel.style.display === "none" ? "block" : "none";
    warn("toggled panel:", panel.style.display || "block");
  }

  function beep() {
    if (!CFG.beep) return;

    try {
      const AudioCtx = window.AudioContext || window.webkitAudioContext;
      const ctx = new AudioCtx();
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();

      osc.frequency.value = 880;
      gain.gain.value = 0.07;

      osc.connect(gain);
      gain.connect(ctx.destination);
      osc.start();

      setTimeout(() => {
        osc.stop();
        ctx.close();
      }, 160);
    } catch {}
  }

  function notify(title, text) {
    warn(title, text);
    beep();

    if (!CFG.notify) return;

    try {
      if ("Notification" in window && Notification.permission === "granted") {
        new Notification(title, { body: text });
      } else if ("Notification" in window && Notification.permission !== "denied") {
        Notification.requestPermission();
      }
    } catch {}
  }

  function showTriggerMarker() {
    const point = getTriggerClick();

    if (!point) {
      warn("No trigger point to mark");
      state.lastStatus = "no trigger point to mark";
      render();
      return;
    }

    let dot = document.getElementById("dylko-trigger-marker");

    if (!dot) {
      dot = document.createElement("div");
      dot.id = "dylko-trigger-marker";

      Object.assign(dot.style, {
        position: "fixed",
        width: "14px",
        height: "14px",
        borderRadius: "50%",
        background: "red",
        border: "2px solid white",
        zIndex: "1000000",
        pointerEvents: "none",
        transform: "translate(-50%, -50%)",
        boxShadow: "0 0 10px rgba(255,0,0,.9)",
      });

      document.body.appendChild(dot);
    }

    dot.style.left = `${point.x}px`;
    dot.style.top = `${point.y}px`;

    setTimeout(() => dot?.remove(), 2500);

    state.lastStatus = `marker shown for ${getTriggerLabel()}`;
    render();

    warn("trigger marker shown", {
      label: getTriggerLabel(),
      point,
    });
  }

  /******************************************************************
   * LOBBY SCANNER
   ******************************************************************/

  async function scanLobbies() {
    if (state.stoppedAfterJoin) {
      warn("scan skipped; script stopped after join");
      return [];
    }

    try {
      const listing = await fetchJson(CFG.listingUrl);
      const rows = [];

      for (const item of listing) {
        if (state.stoppedAfterJoin) break;

        const host = item.Address ?? item.address ?? item.Host ?? item.host;
        const port = Number(item.Port ?? item.port);

        if (!host || !port) continue;

        try {
          const ping = await fetchJson(`https://${host}:${port}/Ping?nocache=${Date.now()}`);
          const current = Number(ping.CurrentPlayers);
          const max = Number(ping.MaxPlayers);

          rows.push({
            host,
            port,
            name: item.Host || host,
            current,
            max,
            full: current >= max,
            open: current < max,
            public: item.Public,
            password: item.Password,
            secure: item.Secure,
            ws: `wss://${host}:${port}/server`,
            listingItem: item,
            ping,
          });
        } catch (e) {
          rows.push({
            host,
            port,
            name: item.Host || host,
            error: e.message,
            listingItem: item,
          });
        }
      }

      rows.sort((a, b) => {
        const af = a.full ? 0 : 1;
        const bf = b.full ? 0 : 1;
        if (af !== bf) return af - bf;

        const ap = Number.isFinite(a.current) ? -a.current : 999;
        const bp = Number.isFinite(b.current) ? -b.current : 999;
        if (ap !== bp) return ap - bp;

        const n = String(a.name).localeCompare(String(b.name));
        if (n) return n;

        return Number(a.port) - Number(b.port);
      });

      state.lobbies = rows;
      state.lastStatus = `scanned ${rows.length} lobbies`;
      render();

      return rows;
    } catch (e) {
      state.lastStatus = `scan error: ${e.message}`;
      warn("scan error", e);
      render();
      return [];
    }
  }

  /******************************************************************
   * QUEUE TARGET LOGIC
   ******************************************************************/

  async function checkTarget() {
    if (!state.target) return false;

    try {
      const ping = await fetchJson(targetPingUrl());

      const current = Number(ping.CurrentPlayers);
      const max = Number(ping.MaxPlayers);
      const open = Number.isFinite(current) && Number.isFinite(max) && current < max;

      state.lastTargetPing = {
        time: new Date().toISOString(),
        current,
        max,
        open,
        raw: ping,
      };

      state.lastStatus = open
        ? `target open: ${current}/${max}`
        : `queued: ${current}/${max}`;

      render();

      return open;
    } catch (e) {
      state.lastTargetPing = {
        time: new Date().toISOString(),
        error: e.message,
      };

      state.lastStatus = `target ping error: ${e.message}`;
      warn("target ping error", e);
      render();

      return false;
    }
  }

  async function queueTick() {
    if (state.stoppedAfterJoin) return;
    if (!state.queueing || !state.target) return;

    const open = await checkTarget();

    if (open && !state.armed) {
      armRedirect();
    }
  }

  function startQueue(target) {
    state.stoppedAfterJoin = false;

    state.target = {
      host: target.host,
      port: target.port,
      name: target.name,
      max: target.max,
      ws: target.ws,
    };

    state.queueing = true;
    state.armed = false;
    state.redirects = [];

    save();

    if (state.queueTimer) clearInterval(state.queueTimer);

    state.lastStatus = `queueing ${state.target.name}:${state.target.port}`;
    render();

    queueTick();
    state.queueTimer = setInterval(queueTick, CFG.queuePollMs);

    log("queue started", {
      target: state.target,
      queuePollMs: CFG.queuePollMs,
      trigger: getTriggerLabel(),
      triggerPoint: getTriggerClick(),
    });
  }

  function stopQueue() {
    state.queueing = false;
    state.armed = false;

    if (state.queueTimer) clearInterval(state.queueTimer);
    if (state.decoyTimer) clearTimeout(state.decoyTimer);

    state.queueTimer = null;
    state.decoyTimer = null;

    state.lastStatus = "queue stopped";
    render();

    log("queue stopped");
  }

  function completeQueue(reason = "joined target; queue stopped") {
    state.queueing = false;
    state.armed = false;

    if (state.queueTimer) clearInterval(state.queueTimer);
    if (state.decoyTimer) clearTimeout(state.decoyTimer);

    state.queueTimer = null;
    state.decoyTimer = null;

    state.lastStatus = reason;
    render();

    log("queue completed", reason);
  }

  function shutdownAfterJoin(reason = "joined target; script stopped") {
    state.queueing = false;
    state.armed = false;
    state.captureMode = false;
    state.stoppedAfterJoin = true;

    if (state.queueTimer) clearInterval(state.queueTimer);
    if (state.decoyTimer) clearTimeout(state.decoyTimer);
    if (state.scanTimer) clearInterval(state.scanTimer);

    state.queueTimer = null;
    state.decoyTimer = null;
    state.scanTimer = null;

    state.lastStatus = reason;
    render();
    hidePanel(reason);

    log("script stopped after join", reason);
  }

  /******************************************************************
   * TRIGGER CLICK + REDIRECT ARMING
   ******************************************************************/

  function fireClick(point, label = "trigger") {
    const canvas = getCanvas();

    if (!canvas) {
      state.lastStatus = `${label} click failed: no canvas`;
      warn("no canvas found");
      render();
      return false;
    }

    if (!point) {
      state.lastStatus = `${label} click failed: no point`;
      warn("no trigger point saved");
      render();
      return false;
    }

    const rect = canvas.getBoundingClientRect();

    const x = point.x;
    const y = point.y;

    const common = {
      bubbles: true,
      cancelable: true,
      composed: true,
      view: window,

      clientX: x,
      clientY: y,
      screenX: point.screenX || x,
      screenY: point.screenY || y,
      pageX: x + window.scrollX,
      pageY: y + window.scrollY,

      offsetX: x - rect.left,
      offsetY: y - rect.top,

      movementX: 0,
      movementY: 0,
      button: 0,
      buttons: 1,
    };

    try {
      window.focus();
      canvas.focus?.();

      canvas.dispatchEvent(new PointerEvent("pointerover", {
        ...common,
        pointerId: 1,
        pointerType: "mouse",
        isPrimary: true,
      }));

      canvas.dispatchEvent(new PointerEvent("pointermove", {
        ...common,
        pointerId: 1,
        pointerType: "mouse",
        isPrimary: true,
      }));

      canvas.dispatchEvent(new MouseEvent("mousemove", common));

      canvas.dispatchEvent(new PointerEvent("pointerdown", {
        ...common,
        pointerId: 1,
        pointerType: "mouse",
        isPrimary: true,
      }));

      canvas.dispatchEvent(new MouseEvent("mousedown", common));

      setTimeout(() => {
        const upCommon = { ...common, buttons: 0 };

        canvas.dispatchEvent(new PointerEvent("pointerup", {
          ...upCommon,
          pointerId: 1,
          pointerType: "mouse",
          isPrimary: true,
        }));

        canvas.dispatchEvent(new MouseEvent("mouseup", upCommon));
        canvas.dispatchEvent(new MouseEvent("click", upCommon));

        log(`${label} click dispatched`, {
          x,
          y,
          offsetX: common.offsetX,
          offsetY: common.offsetY,
        });
      }, 45);

      log(`fired ${label} click`, point);
      return true;
    } catch (e) {
      state.lastStatus = `${label} click error: ${e.message}`;
      warn(`${label} click error`, e);
      render();
      return false;
    }
  }

  function armRedirect() {
    if (!state.target) {
      warn("armRedirect failed: no target");
      return;
    }

    state.armed = true;
    state.lastStatus = `ARMED → ${state.target.host}:${state.target.port}`;

    const triggerPoint = getTriggerClick();

    warn("ARMING REDIRECT", {
      target: state.target,
      targetWs: targetWsUrl(),
      lastTargetPing: state.lastTargetPing,
      trigger: getTriggerLabel(),
      triggerPoint,
    });

    render();

    if (!triggerPoint) {
      state.lastStatus = "armed, but no trigger point available";
      warn("NO TRIGGER POINT AVAILABLE");
      notify("Need trigger click", "Queue is armed. Click any joinable button manually.");
      render();
      return;
    }

    notify(
      "Bumpyball queue armed",
      `Slot open: ${state.target.name || state.target.host}:${state.target.port}`
    );

    let tries = 0;

    const tryTrigger = () => {
      if (!state.armed) {
        warn("trigger loop stopped; redirect already fired or disarmed");
        return;
      }

      tries++;

      state.lastStatus = `armed; firing ${getTriggerLabel()} #${tries}`;
      render();

      const latestTriggerPoint = getTriggerClick();

      warn(`FIRING ${getTriggerLabel()} #${tries}`, latestTriggerPoint);

      let fired = false;

      try {
        fired = fireClick(latestTriggerPoint, getTriggerLabel());
      } catch (e) {
        warn("fireClick threw", e);
      }

      // Do not hide the panel here. Keep showing the UI until the WebSocket hook
      // confirms the join attempt and resets the Start/Stop button.

      if (tries < CFG.decoyRetryMax && state.armed) {
        state.decoyTimer = setTimeout(tryTrigger, CFG.decoyRetryDelayMs);
      } else if (state.armed) {
        state.lastStatus = "armed; auto-trigger failed, click Play/join manually";
        render();
        notify(
          "Auto-trigger did not start join",
          "Click Play or any joinable button manually; redirect is still armed."
        );
      }
    };

    state.decoyTimer = setTimeout(tryTrigger, 100);
  }

  /******************************************************************
   * WEBSOCKET REDIRECT HOOK
   ******************************************************************/

  function shouldRedirect(url) {
    const s = String(url);

    if (!state.armed) return false;
    if (!state.target) return false;

    if (!/\/server$/i.test(s)) return false;
    if (!/^wss:\/\/.*usemapsettings\.com:\d+\/server$/i.test(s)) return false;

    if (s === targetWsUrl()) return false;

    if (CFG.requireOpenSlot && !state.lastTargetPing?.open) return false;

    return true;
  }

  const NativeWebSocket = window.WebSocket;

  window.WebSocket = function(url, protocols) {
    const originalUrl = String(url);
    let finalUrl = originalUrl;

    const info = {
      time: new Date().toISOString(),
      originalUrl,
      finalUrl,
      armed: state.armed,
      target: state.target,
      redirected: false,
    };

    if (shouldRedirect(originalUrl)) {
      finalUrl = targetWsUrl();

      info.finalUrl = finalUrl;
      info.redirected = true;

      state.redirects.push(info);

      shutdownAfterJoin("redirect fired; joining target; script stopped");

      notify("Bumpyball redirect fired", `${originalUrl} → ${finalUrl}`);

      warn("REDIRECT FIRED", {
        from: originalUrl,
        to: finalUrl,
        target: state.target,
      });

      if (CFG.dryRun) {
        finalUrl = originalUrl;
      }
    }

    state.websockets.push(info);

    const ws = protocols === undefined
      ? new NativeWebSocket(finalUrl)
      : new NativeWebSocket(finalUrl, protocols);

    // If the game opens the target socket directly, or after redirect, consider the
    // queue finished immediately. This keeps the button from staying in STOP mode.
    if (state.target && String(finalUrl) === targetWsUrl() && (state.queueing || state.armed)) {
      shutdownAfterJoin("joining target; script stopped");
    } else {
      render();
    }

    return ws;
  };

  window.WebSocket.prototype = NativeWebSocket.prototype;
  window.WebSocket.CONNECTING = NativeWebSocket.CONNECTING;
  window.WebSocket.OPEN = NativeWebSocket.OPEN;
  window.WebSocket.CLOSING = NativeWebSocket.CLOSING;
  window.WebSocket.CLOSED = NativeWebSocket.CLOSED;

  /******************************************************************
   * CUSTOM TRIGGER CAPTURE
   ******************************************************************/

  function captureEvent(ev) {
    if (!state.captureMode) return;

    const panel = document.getElementById("dylko-dq-panel");
    if (panel?.contains(ev.target)) return;

    CFG.decoyClick = {
      x: ev.clientX,
      y: ev.clientY,
      screenX: ev.screenX,
      screenY: ev.screenY,
      capturedAt: new Date().toISOString(),
      label: "Custom Trigger",
    };

    state.captureMode = false;
    state.lastStatus = `captured custom trigger at ${pointText(CFG.decoyClick)}`;

    save();
    render();

    warn("captured custom trigger", CFG.decoyClick);

    ev.preventDefault();
    ev.stopPropagation();
    ev.stopImmediatePropagation();
  }

  for (const type of ["pointerdown", "mousedown", "mouseup", "click"]) {
    document.addEventListener(type, captureEvent, true);
  }

  /******************************************************************
   * HOTKEYS
   ******************************************************************/

  document.addEventListener("keydown", (ev) => {
    // Backslash toggles the queue UI. This replaces the old Shift+Q toggle.
    if (ev.key === "\\" || ev.code === "Backslash") {
      togglePanel();
      ev.preventDefault();
    }
  });

  /******************************************************************
   * UI
   ******************************************************************/

  function setAdvancedOpen(open) {
    const panel = document.getElementById("dq-advanced-panel");
    const toggle = document.getElementById("dq-advanced-toggle");

    if (!panel || !toggle) return;

    panel.style.display = open ? "block" : "none";
    toggle.textContent = open ? "Advanced ▾" : "Advanced ▸";
    toggle.setAttribute("aria-expanded", open ? "true" : "false");
  }

  function setServersVisible(open) {
    state.serversVisible = !!open;
    render();
  }

  function toggleServersVisible() {
    setServersVisible(!state.serversVisible);
  }

  function startSelectedTargetFromMainButton() {
    if (!state.target) {
      state.lastStatus = "choose a server from the visible list first";
      setAdvancedOpen(false);
      render();
      return;
    }

    startQueue({
      host: state.target.host,
      port: state.target.port,
      name: state.target.name,
      max: state.target.max,
      ws: state.target.ws || `wss://${state.target.host}:${state.target.port}/server`,
    });
  }

  function makeUI() {
    if (!document.body || document.getElementById("dylko-dq-panel")) return;

    const style = document.createElement("style");
    style.id = "dylko-dq-style";
    style.textContent = `
      #dylko-dq-panel,
      #dylko-dq-panel * {
        box-sizing: border-box;
      }

      #dylko-dq-panel {
        position: fixed;
        top: 10px;
        left: 50%;
        transform: translateX(-50%);
        width: 360px;
        max-width: calc(100vw - 16px);
        max-height: calc(100vh - 20px);
        overflow: auto;
        z-index: 999999;
        color: white;
        background: rgba(9, 10, 15, .92);
        border: 1px solid rgba(255,255,255,.16);
        border-radius: 14px;
        font-family: Arial, sans-serif;
        box-shadow: 0 8px 22px rgba(0,0,0,.36);
        backdrop-filter: blur(8px);
      }

      #dylko-dq-panel button {
        border: 0;
        border-radius: 8px;
        padding: 6px 8px;
        cursor: pointer;
        font-weight: 800;
        color: white;
        background: rgba(255,255,255,.14);
      }

      #dylko-dq-panel button:hover {
        background: rgba(255,255,255,.22);
      }

      #dq-main {
        padding: 8px;
        text-align: center;
      }

      #dq-title {
        font-size: 15px;
        font-weight: 900;
        letter-spacing: .2px;
        margin-bottom: 1px;
      }

      #dq-credit {
        font-size: 10px;
        font-weight: 700;
        opacity: .68;
        margin-bottom: 5px;
      }

      #dq-status {
        font-size: 12px;
        line-height: 1.3;
        margin-bottom: 5px;
        opacity: .92;
      }

      #dq-target {
        font-size: 12px;
        line-height: 1.25;
        opacity: .78;
        word-break: break-word;
        margin-bottom: 7px;
      }

      #dq-main-startstop {
        width: 100%;
        min-height: 36px;
        font-size: 14px;
        margin-bottom: 5px;
      }

      #dq-main-startstop.dq-start {
        background: rgba(40, 165, 85, .88) !important;
      }

      #dq-main-startstop.dq-stop {
        background: rgba(190, 55, 55, .86) !important;
      }

      #dq-toggle-servers {
        width: 100%;
        min-height: 30px;
        font-size: 11px;
        margin-bottom: 5px;
        background: rgba(255,255,255,.12) !important;
      }

      #dq-advanced-toggle {
        width: 100%;
        margin-top: 2px;
        background: transparent !important;
        color: rgba(255,255,255,.66) !important;
        padding: 4px 0 1px !important;
        font-size: 11px;
      }

      #dq-advanced-panel {
        display: none;
        border-top: 1px solid rgba(255,255,255,.11);
        padding: 8px;
        max-height: 28vh;
        overflow: auto;
        text-align: left;
        background: rgba(0,0,0,.18);
      }

      .dq-advanced-tag {
        display: inline-block;
        font-size: 9px;
        font-weight: 900;
        letter-spacing: .5px;
        text-transform: uppercase;
        color: rgba(255,255,255,.7);
        border: 1px solid rgba(255,255,255,.18);
        border-radius: 999px;
        padding: 1px 6px;
        margin-bottom: 5px;
      }

      .dq-muted {
        font-size: 10px;
        opacity: .7;
        line-height: 1.25;
        margin-bottom: 4px;
      }

      .dq-row {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 5px;
        margin-bottom: 5px;
      }

      #dq-advanced-panel #dq-scan {
        width: 100%;
        min-height: 30px;
        font-size: 11px;
        margin-bottom: 5px;
      }

      #dq-advanced-panel button {
        font-size: 11px;
        min-height: 30px;
      }

      #dq-server-note {
        display: none;
      }

      #dq-list {
        max-height: 250px;
        overflow: auto;
        border-top: 1px solid rgba(255,255,255,.14);
        padding-top: 5px;
        margin-top: 5px;
        text-align: left;
      }

      .dq-lobby-row {
        display: grid;
        grid-template-columns: 1fr 38px 54px;
        gap: 5px;
        align-items: center;
        margin: 3px 0;
        padding: 5px;
        border-radius: 8px;
        font-size: 11px;
      }

      .dq-lobby-name {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }

      .dq-lobby-row button {
        padding: 5px 6px !important;
        font-size: 11px;
        min-height: 28px;
      }

      #dq-toggle-hint {
        position: absolute;
        top: 8px;
        left: 8px;
        z-index: 2;
        padding: 4px 7px;
        border-radius: 7px;
        border: 1px solid rgba(255,255,255,.16);
        background: rgba(255,255,255,.07);
        color: rgba(255,255,255,.62);
        font-size: 10px;
        font-weight: 800;
        font-family: Arial, sans-serif;
        line-height: 1;
        cursor: pointer;
        user-select: none;
      }

      #dq-toggle-hint:hover {
        background: rgba(255,255,255,.13);
        color: white;
      }

    `;

    if (!document.getElementById("dylko-dq-style")) {
      document.head?.appendChild(style);
    }

    const box = document.createElement("div");
    box.id = "dylko-dq-panel";

    box.innerHTML = `
      <div id="dq-toggle-hint" title="Press backslash to show/hide Server Queue">&#92; to toggle</div>
      <div id="dq-main">
        <div id="dq-title">Server Queue</div>
        <div id="dq-credit">created by Dylko</div>
        <div id="dq-status">Status: loading</div>
        <div id="dq-target">Target: none</div>

        <button id="dq-main-startstop" class="dq-start">START</button>
        <button id="dq-toggle-servers">Hide Servers</button>
        <div id="dq-list"></div>

        <button id="dq-advanced-toggle" aria-expanded="false">Advanced ▸</button>
      </div>

      <div id="dq-advanced-panel">
        <div class="dq-advanced-tag">Advanced</div>

        <button id="dq-scan">Refresh Servers</button>

        <div class="dq-muted">
          Trigger tools, diagnostics, and custom setup live here.
        </div>

        <div class="dq-row">
          <button id="dq-capture">Capture Custom Trigger</button>
          <button id="dq-clear-custom">Clear Custom</button>
        </div>

        <div class="dq-row">
          <button id="dq-test-trigger">Test Trigger</button>
          <button id="dq-show-trigger">Show Trigger Marker</button>
        </div>

        <div id="dq-trigger" class="dq-muted"></div>
        <div id="dq-lastping" class="dq-muted"></div>
        <div class="dq-muted">Queue polling: ${CFG.queuePollMs}ms.</div>
      </div>
    `;

    document.body.appendChild(box);

    document.getElementById("dq-toggle-hint").onclick = togglePanel;

    document.getElementById("dq-main-startstop").onclick = () => {
      if (state.queueing || state.armed) {
        stopQueue();
      } else {
        startSelectedTargetFromMainButton();
      }
    };

    document.getElementById("dq-toggle-servers").onclick = toggleServersVisible;

    document.getElementById("dq-advanced-toggle").onclick = () => {
      const panel = document.getElementById("dq-advanced-panel");
      setAdvancedOpen(panel?.style.display === "none" || !panel?.style.display);
    };

    document.getElementById("dq-scan").onclick = scanLobbies;

    document.getElementById("dq-capture").onclick = () => {
      state.captureMode = true;
      state.lastStatus = "click the custom trigger spot now";
      render();
    };

    document.getElementById("dq-test-trigger").onclick = () => {
      fireClick(getTriggerClick(), getTriggerLabel());
    };

    document.getElementById("dq-show-trigger").onclick = showTriggerMarker;

    document.getElementById("dq-clear-custom").onclick = () => {
      CFG.decoyClick = null;
      save();
      state.lastStatus = "cleared custom trigger; using default Play";
      render();
    };

    state.uiReady = true;

    render();
    scanLobbies();

    if (state.scanTimer) clearInterval(state.scanTimer);
    state.scanTimer = setInterval(scanLobbies, CFG.scanIntervalMs);
  }

  function render() {
    if (!state.uiReady) return;

    const statusEl = document.getElementById("dq-status");
    const targetEl = document.getElementById("dq-target");
    const triggerEl = document.getElementById("dq-trigger");
    const pingEl = document.getElementById("dq-lastping");
    const listEl = document.getElementById("dq-list");
    const serverNoteEl = document.getElementById("dq-server-note");
    const panel = document.getElementById("dylko-dq-panel");
    const startStopBtn = document.getElementById("dq-main-startstop");
    const toggleServersBtn = document.getElementById("dq-toggle-servers");

    if (!statusEl || !targetEl || !triggerEl || !listEl) return;

    statusEl.textContent = `Status: ${state.lastStatus}`;

    targetEl.textContent = state.target
      ? `Target: ${state.target.name || state.target.host}:${state.target.port}`
      : "Target: none — pick a server below";

    triggerEl.textContent = `Trigger: ${getTriggerLabel()} at ${pointText(getTriggerClick())}`;

    if (startStopBtn) {
      const active = state.queueing || state.armed;
      startStopBtn.textContent = active ? "STOP" : "START";
      startStopBtn.classList.toggle("dq-stop", active);
      startStopBtn.classList.toggle("dq-start", !active);
    }

    if (toggleServersBtn) {
      toggleServersBtn.textContent = state.serversVisible ? "Hide Servers" : "Show Servers";
    }

    if (serverNoteEl) {
      serverNoteEl.style.display = state.serversVisible ? "block" : "none";
    }

    if (listEl) {
      listEl.style.display = state.serversVisible ? "block" : "none";
    }

    if (pingEl) {
      if (state.lastTargetPing?.error) {
        pingEl.textContent = `Target ping: error ${state.lastTargetPing.error}`;
      } else if (state.lastTargetPing) {
        pingEl.textContent = `Target ping: ${state.lastTargetPing.current}/${state.lastTargetPing.max} ${
          state.lastTargetPing.open ? "OPEN" : "FULL"
        }`;
      } else {
        pingEl.textContent = "Target ping: none";
      }
    }

    const rows = state.lobbies || [];

    if (!state.serversVisible) {
      listEl.innerHTML = "";
    } else if (!rows.length) {
      listEl.innerHTML = `<div class="dq-muted" style="text-align:center;margin:8px 0;">Scanning servers...</div>`;
    } else {
      listEl.innerHTML = rows
        .map((lobby, i) => {
        const players = lobby.error ? "ERR" : `${lobby.current}/${lobby.max}`;

        const bg = lobby.error
          ? "rgba(80,80,80,.45)"
          : lobby.full
            ? "rgba(120,40,40,.48)"
            : "rgba(40,100,45,.35)";

        const label = `${lobby.name || lobby.host}:${lobby.port}`;
        const privacy = lobby.password ? " 🔒" : "";
        const isTarget = state.target?.host === lobby.host && Number(state.target?.port) === Number(lobby.port);
        const btnText = isTarget ? "Queued" : "Queue";

        return `
          <div class="dq-lobby-row" style="background:${bg}; ${isTarget ? "outline:1px solid rgba(255,255,255,.55);" : ""}">
            <div class="dq-lobby-name" title="${lobby.host}:${lobby.port}">
              ${label}${privacy}
            </div>
            <div>${players}</div>
            <button data-dq-queue="${i}">${btnText}</button>
          </div>
        `;
        })
        .join("");
    }

    for (const btn of listEl.querySelectorAll("[data-dq-queue]")) {
      btn.onclick = () => {
        const idx = Number(btn.getAttribute("data-dq-queue"));
        const lobby = state.lobbies[idx];
        if (lobby) startQueue(lobby);
      };
    }

    if (panel) {
      if (state.armed) {
        panel.style.background = "rgba(0,80,25,.94)";
        panel.style.border = "1px solid rgba(100,255,150,.8)";
      } else if (state.queueing) {
        panel.style.background = "rgba(75,50,0,.94)";
        panel.style.border = "1px solid rgba(255,215,90,.75)";
      } else if (state.captureMode) {
        panel.style.background = "rgba(70,0,80,.94)";
        panel.style.border = "1px solid rgba(255,120,255,.75)";
      } else {
        panel.style.background = "rgba(9, 10, 15, .92)";
        panel.style.border = "1px solid rgba(255,255,255,.16)";
      }
    }
  }

  /******************************************************************
   * PUBLIC DEBUG OBJECT
   ******************************************************************/

  window.__DYLKO_DYNAMIC_QUEUE__ = {
    CFG,
    state,

    scanLobbies,
    startQueue,
    stopQueue,
    completeQueue,
    shutdownAfterJoin,

    checkTarget,
    armRedirect,

    fireClick,
    getTriggerClick,
    getTriggerLabel,

    getCanvas,
    resolveCanvasPoint,
    showTriggerMarker,
    setServersVisible,
    toggleServersVisible,

    hidePanel,
    showPanel,
    togglePanel,

    targetPingUrl,
    targetWsUrl,

    save,
  };

  /******************************************************************
   * BOOT
   ******************************************************************/

  const uiTimer = setInterval(() => {
    if (document.body) {
      clearInterval(uiTimer);
      makeUI();
    }
  }, 100);

  log("installed");
})();