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