Greasy Fork is available in English.

Voxiom Bot Manager Basic- QQhacks

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.');
})();