Voxiom Bot Manager Basic- QQhacks

Voxiom.io WebSocket client, auth, session, ping, and reconnection.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Voxiom Bot Manager Basic- QQhacks
// @namespace    https://voxiom.io/
// @version      2.0.0
// @description  Voxiom.io WebSocket client, auth, session, ping, and reconnection.
// @author       QQhacks + Kaleb Danovich
// @match        https://voxiom.io/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const UI_ID = 'voxiom-bot-mgr';
  const DEFAULT_WS_TIMEOUT = 10000;

  if (document.getElementById(UI_ID)) return;

  function el(tag, attrs = {}, ...children) {
    const e = document.createElement(tag);
    for (const k in attrs) {
      if (k === 'style') Object.assign(e.style, attrs[k]);
      else if (k.startsWith('on') && typeof attrs[k] === 'function') e.addEventListener(k.slice(2), attrs[k]);
      else e.setAttribute(k, attrs[k]);
    }
    for (const c of children) {
      if (typeof c === 'string') e.appendChild(document.createTextNode(c));
      else if (c) e.appendChild(c);
    }
    return e;
  }

  const style = document.createElement('style');
  style.textContent = `
    #${UI_ID} { position: fixed; right: 12px; top: 12px; width: 320px; z-index: 999999; font-family: Arial, Helvetica, sans-serif; }
    #${UI_ID} .panel { background: rgba(20,20,20,0.92); color: #eee; border-radius: 8px; padding: 10px; box-shadow: 0 6px 18px rgba(0,0,0,0.6); font-size: 13px; }
    #${UI_ID} .header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
    #${UI_ID} .title { font-weight:700; letter-spacing:0.4px; }
    #${UI_ID} .controls { margin-top:8px; display:grid; grid-template-columns: 1fr 80px; gap:8px; align-items:center; }
    #${UI_ID} input[type="text"], #${UI_ID} input[type="number"], #${UI_ID} textarea { width:100%; padding:6px 8px; border-radius:4px; border:1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02); color: #eee; box-sizing:border-box; font-size:13px; }
    #${UI_ID} textarea { height:64px; resize:vertical; }
    #${UI_ID} button { background:#2b8cff; color:white; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; font-weight:600; }
    #${UI_ID} button.secondary { background:#444; }
    #${UI_ID} .small { font-size:12px; color:#ccc; }
    #${UI_ID} .log { margin-top:8px; height:120px; overflow:auto; background: rgba(0,0,0,0.35); padding:8px; border-radius:6px; font-family: monospace; font-size:12px; color:#bfe; }
    #${UI_ID} .status { margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
    #${UI_ID} .pill { background: rgba(255,255,255,0.04); padding:4px 8px; border-radius:999px; font-size:12px; color:#ddd; }
    #${UI_ID} .footer { margin-top:8px; display:flex; gap:8px; justify-content:space-between; align-items:center; }
    #${UI_ID} .collapse-btn { background:transparent; border:1px solid rgba(255,255,255,0.06); color:#ddd; padding:4px 6px; border-radius:4px; cursor:pointer; }

    #${UI_ID} .modes { margin-top:8px; display:flex; gap:8px; }
    #${UI_ID} .mode-btn { flex:1; padding:8px 6px; border-radius:6px; color:#fff; font-weight:700; cursor:pointer; border:2px solid rgba(255,255,255,0.06); text-align:center; box-shadow: 0 2px 6px rgba(0,0,0,0.4); transition: box-shadow 160ms, transform 120ms; }
    #${UI_ID} .mode-btn:active { transform: translateY(1px); }
    #${UI_ID} .mode-pill { font-size:11px; color:#ddd; padding:4px 8px; border-radius:999px; background: rgba(255,255,255,0.03); }

    #${UI_ID} .mode-pillar { background: linear-gradient(180deg,#2b8cff,#1a6fe6); }
    #${UI_ID} .mode-freeroam { background: linear-gradient(180deg,#2ecc71,#22b85a); }
    #${UI_ID} .mode-dig { background: linear-gradient(180deg,#ff9a3c,#ff7a00); }

    #${UI_ID} .mode-active {
      box-shadow: 0 0 18px rgba(255,255,255,0.06), 0 0 28px currentColor;
      outline: 2px solid rgba(255,255,255,0.06);
      transform: translateY(-2px);
    }
  `;
  document.head.appendChild(style);

  const container = el('div', { id: UI_ID });
  const panel = el('div', { class: 'panel' });

  const header = el('div', { class: 'header' },
    el('div', { class: 'title' }, 'VOXIOM BOT MGR'),
    el('div', {},
      el('button', { class: 'collapse-btn', id: `${UI_ID}-toggle` }, 'Hide')
    )
  );

  const gameCodeInput = el('input', { type: 'text', placeholder: 'Game code (e.g. X4Ixz)', id: `${UI_ID}-gamecode` });
  const wsUrlInput = el('input', { type: 'text', placeholder: 'WebSocket URL (capture or paste)', id: `${UI_ID}-wsurl` });
  const botsInput = el('input', { type: 'number', min: 1, value: 10, id: `${UI_ID}-bots` });
  const joinPayloadInput = el('textarea', { placeholder: 'Optional join payload (JSON or raw). Leave empty to just connect.', id: `${UI_ID}-payload` });

  const captureBtn = el('button', { id: `${UI_ID}-capture`, class: 'secondary' }, 'Capture WS');
  const deployBtn = el('button', { id: `${UI_ID}-deploy` }, 'Deploy');
  const stopBtn = el('button', { id: `${UI_ID}-stop`, class: 'secondary' }, 'Stop');
  const killBtn = el('button', { id: `${UI_ID}-kill`, class: 'secondary' }, 'Kill All');

  const cycleCheckbox = el('input', { type: 'checkbox', id: `${UI_ID}-cycle` });
  const cycleLabel = el('label', { for: `${UI_ID}-cycle`, class: 'small' }, 'Cycle (redeploy on disconnect)');

  const statusBar = el('div', { class: 'status' },
    el('div', { class: 'pill', id: `${UI_ID}-active` }, 'Active: 0'),
    el('div', { class: 'pill', id: `${UI_ID}-total` }, 'Total: 0'),
    el('div', { class: 'pill', id: `${UI_ID}-dead` }, 'Dead: 0')
  );

  const logBox = el('div', { class: 'log', id: `${UI_ID}-log` });

  const controls = el('div', { class: 'controls' },
    gameCodeInput,
    botsInput
  );

  const controls2 = el('div', { style: { marginTop: '8px' } },
    wsUrlInput
  );

  const controls3 = el('div', { style: { marginTop: '8px' } },
    joinPayloadInput
  );

  const pillarBtn = el('div', { class: 'mode-btn mode-pillar', id: `${UI_ID}-mode-pillar`, role: 'button', tabindex: 0 }, 'Pillar Mode');
  const freeRoamBtn = el('div', { class: 'mode-btn mode-freeroam', id: `${UI_ID}-mode-freeroam`, role: 'button', tabindex: 0 }, 'Free Roam');
  const digBtn = el('div', { class: 'mode-btn mode-dig', id: `${UI_ID}-mode-dig`, role: 'button', tabindex: 0 }, 'Dig Down');

  const modesRow = el('div', { class: 'modes' }, pillarBtn, freeRoamBtn, digBtn);

  const footer = el('div', { class: 'footer' },
    el('div', {}, captureBtn, ' ', deployBtn, ' ', stopBtn, ' ', killBtn),
    el('div', {}, cycleCheckbox, ' ', cycleLabel)
  );

  panel.appendChild(header);
  panel.appendChild(controls);
  panel.appendChild(controls2);
  panel.appendChild(controls3);
  panel.appendChild(modesRow);
  panel.appendChild(statusBar);
  panel.appendChild(logBox);
  panel.appendChild(footer);
  container.appendChild(panel);
  document.body.appendChild(container);

  const toggleBtn = document.getElementById(`${UI_ID}-toggle`);
  let collapsed = false;
  toggleBtn.addEventListener('click', () => {
    collapsed = !collapsed;
    if (collapsed) {
      panel.style.opacity = '0.9';
      panel.style.transform = 'translateX(6px)';
      panel.style.width = '160px';
      Array.from(panel.children).forEach((c, i) => { if (i > 0) c.style.display = 'none'; });
      toggleBtn.textContent = 'Show';
    } else {
      Array.from(panel.children).forEach((c) => c.style.display = '');
      toggleBtn.textContent = 'Hide';
      panel.style.width = '320px';
    }
  });

  function log(msg) {
    const box = document.getElementById(`${UI_ID}-log`);
    const time = new Date().toLocaleTimeString();
    box.appendChild(document.createTextNode(`[${time}] ${msg}\n`));
    box.scrollTop = box.scrollHeight;
  }

  function setStatus(active, total, dead) {
    document.getElementById(`${UI_ID}-active`).textContent = `Active: ${active}`;
    document.getElementById(`${UI_ID}-total`).textContent = `Total: ${total}`;
    document.getElementById(`${UI_ID}-dead`).textContent = `Dead: ${dead}`;
  }

  let bots = new Map();
  let stats = { active: 0, total: 0, dead: 0 };
  let nextBotId = 1;
  let capturedWsUrl = null;
  let captureTimeoutHandle = null;

  const MODES = {
    PILLAR: 'pillar',
    FREEROAM: 'freeroam',
    DIG: 'dig'
  };
  let currentMode = MODES.PILLAR;

  class GameWebSocketClient {
    constructor(options) {
      this.url = options.url;
      this.authToken = options.authToken || null;
      this.sessionId = null;
      this.ws = null;
      this.connected = false;
      this.closing = false;
      this.messageHandlers = new Map();
      this.pingInterval = options.pingInterval || 15000;
      this.reconnectDelay = options.reconnectDelay || 2000;
      this.maxReconnectDelay = options.maxReconnectDelay || 30000;
      this.reconnectAttempts = 0;
      this.pingTimer = null;
      this.handshakeTimeout = options.handshakeTimeout || 8000;
      this.handshakeTimer = null;
      this.initialPayload = options.initialPayload || null;
      this.mode = options.mode || MODES.PILLAR;
      this.onOpen = options.onOpen || (() => {});
      this.onClose = options.onClose || (() => {});
      this.onError = options.onError || (() => {});
      this.onSession = options.onSession || (() => {});
      this.onEvent = options.onEvent || (() => {});
      this.onReconnect = options.onReconnect || (() => {});
      this.onDisconnect = options.onDisconnect || (() => {});
    }

    connect() {
      if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
      this.closing = false;
      this.ws = new WebSocket(this.url);
      this.ws.binaryType = 'arraybuffer';
      this.ws.onopen = () => {
        this.connected = true;
        this.reconnectAttempts = 0;
        this.onOpen();
        this.startHandshake();
      };
      this.ws.onmessage = e => {
        if (typeof e.data === 'string') return;
        this.handleBinaryMessage(e.data);
      };
      this.ws.onerror = err => {
        this.onError(err);
      };
      this.ws.onclose = ev => {
        const wasConnected = this.connected;
        this.connected = false;
        this.clearTimers();
        this.onClose(ev);
        this.onDisconnect(ev);
        if (!this.closing && wasConnected) this.scheduleReconnect();
        else if (!this.closing && !wasConnected) this.scheduleReconnect();
      };
    }

    startHandshake() {
      this.sendAuth();
      this.handshakeTimer = setTimeout(() => {
        if (!this.sessionId) {
          this.onError(new Error('Handshake timeout'));
          if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close(4000, 'handshake timeout');
        }
      }, this.handshakeTimeout);
    }

    sendAuth() {
      const payload = this.buildAuthPayload();
      this.sendBinary(payload);
    }

    buildAuthPayload() {
      const tokenBytes = new TextEncoder().encode(this.authToken || '');
      const modeBytes = new TextEncoder().encode(this.mode || '');
      const len = 1 + 4 + tokenBytes.length + 4 + modeBytes.length;
      const buf = new ArrayBuffer(len);
      const view = new DataView(buf);
      let offset = 0;
      view.setUint8(offset, 0x01);
      offset += 1;
      view.setUint32(offset, tokenBytes.length, true);
      offset += 4;
      new Uint8Array(buf, offset, tokenBytes.length).set(tokenBytes);
      offset += tokenBytes.length;
      view.setUint32(offset, modeBytes.length, true);
      offset += 4;
      new Uint8Array(buf, offset, modeBytes.length).set(modeBytes);
      return buf;
    }

    buildSessionAckPayload(sessionId) {
      const idBytes = new TextEncoder().encode(sessionId);
      const len = 1 + 4 + idBytes.length;
      const buf = new ArrayBuffer(len);
      const view = new DataView(buf);
      let offset = 0;
      view.setUint8(offset, 0x02);
      offset += 1;
      view.setUint32(offset, idBytes.length, true);
      offset += 4;
      new Uint8Array(buf, offset, idBytes.length).set(idBytes);
      return buf;
    }

    buildPingPayload() {
      const buf = new ArrayBuffer(1);
      const view = new DataView(buf);
      view.setUint8(0, 0x03);
      return buf;
    }

    buildEventPayload(type, data) {
      const typeBytes = new TextEncoder().encode(type);
      const dataBytes = new TextEncoder().encode(JSON.stringify(data || {}));
      const len = 1 + 4 + typeBytes.length + 4 + dataBytes.length;
      const buf = new ArrayBuffer(len);
      const view = new DataView(buf);
      let offset = 0;
      view.setUint8(offset, 0x10);
      offset += 1;
      view.setUint32(offset, typeBytes.length, true);
      offset += 4;
      new Uint8Array(buf, offset, typeBytes.length).set(typeBytes);
      offset += typeBytes.length;
      view.setUint32(offset, dataBytes.length, true);
      offset += 4;
      new Uint8Array(buf, offset, dataBytes.length).set(dataBytes);
      return buf;
    }

    handleBinaryMessage(buffer) {
      const view = new DataView(buffer);
      let offset = 0;
      const opcode = view.getUint8(offset);
      offset += 1;
      if (opcode === 0x11) {
        const len = view.getUint32(offset, true);
        offset += 4;
        const idBytes = new Uint8Array(buffer, offset, len);
        const sessionId = new TextDecoder().decode(idBytes);
        this.sessionId = sessionId;
        this.onSession(sessionId);
        const ack = this.buildSessionAckPayload(sessionId);
        this.sendBinary(ack);
        if (this.initialPayload) {
          let payloadData = null;
          try {
            payloadData = JSON.parse(this.initialPayload);
          } catch (_) {
            payloadData = { raw: this.initialPayload };
          }
          this.sendEvent('join', payloadData);
        }
        this.startPing();
        if (this.handshakeTimer) {
          clearTimeout(this.handshakeTimer);
          this.handshakeTimer = null;
        }
      } else if (opcode === 0x12) {
        const lenType = view.getUint32(offset, true);
        offset += 4;
        const typeBytes = new Uint8Array(buffer, offset, lenType);
        const type = new TextDecoder().decode(typeBytes);
        offset += lenType;
        const lenData = view.getUint32(offset, true);
        offset += 4;
        const dataBytes = new Uint8Array(buffer, offset, lenData);
        const json = new TextDecoder().decode(dataBytes);
        let parsed = null;
        try {
          parsed = JSON.parse(json);
        } catch (_) {
          parsed = null;
        }
        this.onEvent({ type, data: parsed });
        const handler = this.messageHandlers.get(type);
        if (handler) handler(parsed);
      } else if (opcode === 0x13) {
        this.handleServerPing();
      } else if (opcode === 0x20) {
        const code = view.getUint16(offset, true);
        offset += 2;
        const lenMsg = view.getUint32(offset, true);
        offset += 4;
        const msgBytes = new Uint8Array(buffer, offset, lenMsg);
        const msg = new TextDecoder().decode(msgBytes);
        this.onError(new Error(`Server error ${code}: ${msg}`));
      }
    }

    handleServerPing() {
      const pong = this.buildPingPayload();
      this.sendBinary(pong);
    }

    startPing() {
      this.clearPing();
      this.pingTimer = setInterval(() => {
        if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
        const ping = this.buildPingPayload();
        this.sendBinary(ping);
      }, this.pingInterval);
    }

    clearPing() {
      if (this.pingTimer) {
        clearInterval(this.pingTimer);
        this.pingTimer = null;
      }
    }

    clearTimers() {
      this.clearPing();
      if (this.handshakeTimer) {
        clearTimeout(this.handshakeTimer);
        this.handshakeTimer = null;
      }
    }

    sendBinary(buffer) {
      if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
      try {
        this.ws.send(buffer);
      } catch (e) {
        this.onError(e);
      }
    }

    sendEvent(type, data) {
      const payload = this.buildEventPayload(type, data);
      this.sendBinary(payload);
    }

    on(type, handler) {
      this.messageHandlers.set(type, handler);
    }

    off(type) {
      this.messageHandlers.delete(type);
    }

    scheduleReconnect() {
      if (this.closing) return;
      this.reconnectAttempts += 1;
      const delay = Math.min(this.reconnectDelay * this.reconnectAttempts, this.maxReconnectDelay);
      this.onReconnect({ attempt: this.reconnectAttempts, delay });
      setTimeout(() => {
        if (this.closing) return;
        this.connect();
      }, delay);
    }

    close() {
      this.closing = true;
      this.clearTimers();
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.close(1000, 'client closing');
      }
      this.ws = null;
      this.connected = false;
    }
  }

  function updateStats() {
    setStatus(stats.active, stats.total, stats.dead);
  }

  function captureWebSocketUrl(timeoutMs = 8000) {
    return new Promise((resolve, reject) => {
      if (capturedWsUrl) {
        resolve(capturedWsUrl);
        return;
      }

      const OriginalWS = window.WebSocket;
      let captured = null;
      function PatchedWS(url, protocols) {
        try {
          if (!captured) {
            captured = url;
            capturedWsUrl = url;
            log('Captured WebSocket URL: ' + url);
            cleanup();
            resolve(url);
          }
        } catch (e) {}
        return new OriginalWS(url, protocols);
      }
      PatchedWS.prototype = OriginalWS.prototype;
      PatchedWS.CONNECTING = OriginalWS.CONNECTING;
      PatchedWS.OPEN = OriginalWS.OPEN;
      PatchedWS.CLOSING = OriginalWS.CLOSING;
      PatchedWS.CLOSED = OriginalWS.CLOSED;

      window.WebSocket = PatchedWS;

      captureTimeoutHandle = setTimeout(() => {
        cleanup();
        if (captured) resolve(captured);
        else reject(new Error('No WebSocket connection detected within timeout'));
      }, timeoutMs);

      function cleanup() {
        if (captureTimeoutHandle) {
          clearTimeout(captureTimeoutHandle);
          captureTimeoutHandle = null;
        }
        try { window.WebSocket = OriginalWS; } catch (e) {}
      }
    });
  }

  function setMode(mode) {
    if (!Object.values(MODES).includes(mode)) return;
    currentMode = mode;
    pillarBtn.classList.toggle('mode-active', mode === MODES.PILLAR);
    freeRoamBtn.classList.toggle('mode-active', mode === MODES.FREEROAM);
    digBtn.classList.toggle('mode-active', mode === MODES.DIG);
    log('Mode set to: ' + mode);
  }

  function getMode() {
    return currentMode;
  }

  pillarBtn.addEventListener('click', () => setMode(MODES.PILLAR));
  freeRoamBtn.addEventListener('click', () => setMode(MODES.FREEROAM));
  digBtn.addEventListener('click', () => setMode(MODES.DIG));

  [pillarBtn, freeRoamBtn, digBtn].forEach((btn) => {
    btn.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        btn.click();
      }
    });
  });

  setMode(currentMode);

  function spawnBots(count, url, payload, cycle) {
    for (let i = 0; i < count; i++) {
      const id = nextBotId++;
      stats.total++;
      updateStats();
      const client = new GameWebSocketClient({
        url,
        authToken: gameCodeInput.value.trim() || null,
        pingInterval: 10000,
        reconnectDelay: 2000,
        maxReconnectDelay: 20000,
        handshakeTimeout: DEFAULT_WS_TIMEOUT,
        initialPayload: payload || null,
        mode: currentMode,
        onOpen: () => {
          log(`#${id} socket open`);
        },
        onSession: (sessionId) => {
          stats.active++;
          updateStats();
          log(`#${id} session established: ${sessionId}`);
        },
        onEvent: (evt) => {},
        onError: (err) => {
          stats.dead++;
          updateStats();
          log(`#${id} error: ${err.message || err}`);
        },
        onClose: (ev) => {
          if (stats.active > 0) stats.active--;
          stats.dead++;
          updateStats();
          log(`#${id} closed (${ev.code} ${ev.reason || ''})`);
        },
        onReconnect: (info) => {
          log(`#${id} reconnect attempt ${info.attempt}, delay ${info.delay}ms`);
        },
        onDisconnect: () => {
          if (cycle) {
            log(`#${id} cycling: redeploying...`);
            setTimeout(() => {
              spawnBots(1, url, payload, cycle);
            }, 500);
          }
        }
      });
      bots.set(id, client);
      client.connect();
    }
  }

  function stopAllBots() {
    for (const [id, client] of bots.entries()) {
      try {
        client.close();
      } catch (e) {}
    }
    log('Stop command sent to all bots');
  }

  function killAllBots() {
    for (const [id, client] of bots.entries()) {
      try {
        client.close();
      } catch (e) {}
      bots.delete(id);
    }
    stats = { active: 0, total: 0, dead: 0 };
    updateStats();
    log('All bots terminated');
  }

  captureBtn.addEventListener('click', async () => {
    log('Attempting to capture WebSocket URL (will temporarily wrap window.WebSocket)...');
    try {
      const url = await captureWebSocketUrl(8000);
      wsUrlInput.value = url;
      log('Captured WS URL: ' + url);
    } catch (e) {
      log('Capture failed: ' + e.message);
      alert('Capture failed: ' + e.message + '\n\nIf the game already connected before you clicked Capture, try reloading the page and click Capture immediately.');
    }
  });

  deployBtn.addEventListener('click', () => {
    const gameCode = document.getElementById(`${UI_ID}-gamecode`).value.trim();
    let url = document.getElementById(`${UI_ID}-wsurl`).value.trim();
    const botsCount = parseInt(document.getElementById(`${UI_ID}-bots`).value, 10) || 0;
    const payload = document.getElementById(`${UI_ID}-payload`).value.trim();
    const cycle = document.getElementById(`${UI_ID}-cycle`).checked;

    if (!url) {
      if (gameCode) {
        const host = location.hostname;
        url = `wss://${host}/`;
        log('No WS URL provided; using default derived URL: ' + url + ' (you may need to paste the exact WS URL)');
      } else {
        alert('Please provide a WebSocket URL or a game code.');
        return;
      }
    }

    if (!botsCount || botsCount <= 0) {
      alert('Enter a positive number of bots to deploy.');
      return;
    }

    log(`Deploying ${botsCount} bots to ${url} (mode: ${currentMode})`);
    spawnBots(botsCount, url, payload || null, cycle);
  });

  stopBtn.addEventListener('click', () => {
    stopAllBots();
  });

  killBtn.addEventListener('click', () => {
    if (!confirm('Terminate all bot clients now?')) return;
    killAllBots();
  });

  window.VoxiomBotMgr = {
    spawn: (n, url, payload, cycle) => spawnBots(n, url, payload, cycle),
    stopAll: stopAllBots,
    killAll: killAllBots,
    captureWs: captureWebSocketUrl,
    getStats: () => ({ ...stats }),
    setMode: setMode,
    getMode: getMode
  };
  //must default update otherwise engine will not work
  updateStats();
  log('Voxiom Bot Manager ready. Use Capture WS to detect server or paste WS URL manually.');
})();