Hitbox Room Commander

Hitbox Room Commander – full control over game settings via chat commands.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Hitbox Room Commander
// @namespace    http://tampermonkey.net/
// @version      3.9
// @description  Hitbox Room Commander – full control over game settings via chat commands.
// @author       Mr-FaZ3a
// @match        https://heav.io/game.html
// @match        https://hitbox.io/game.html
// @match        https://heav.io/game2.html
// @match        https://hitbox.io/game2.html
// @match        https://hitbox.io/game-beta.html
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  class HitboxCommander {
    constructor() {
      this.ws = null;
      this.myid = -1;
      this.hostId = -2;
      this.gameSettings = null;        // current live settings
      this.roomDefaults = null;        // original room defaults (never changes)
      this.nativeSend = window.WebSocket.prototype.send;
      this.users = {};
      this.myName = '';
      this.gameSettingsObject = null;

      this.settingsMap = this.buildSettingsMap();
      this.nameToAbbr = {};
      this.abbrToName = new Map();
      this.categoryNames = ['bat', 'rk', 'push', 'eg', 'dash', 'others', 'unknown'];
      this.categoryOrder = ['Bat', 'RK', 'Push', 'EG', 'Dash', 'Others', 'Unknown'];
      this.buildMaps();

      this.chatHidden = localStorage.getItem('chatHidden') === 'true';
      this.tempChatVisible = false;
      this.chatMessages = [];
      this.chatDialog = null;
      this.userScrolled = false;

      this.init();
      this.initChatToggle();
      this.createChatDialog();
    }

    buildSettingsMap() {
      return [
        // Bat
        { name: 'bbRange', abbr: 'st', category: 'Bat' },
        { name: 'bbPower', abbr: 'it', category: 'Bat' },
        { name: 'bbHoldAmmoCost', abbr: 'rt', category: 'Bat' },
        { name: 'bbFireFramesLength', abbr: 'at', category: 'Bat' },
        { name: 'bbHideAfterFireFrames', abbr: 'lt', category: 'Bat' },
        { name: 'bbResetOn', abbr: 'ut', category: 'Bat' },
        { name: 'bbEnabled', abbr: 'ct', category: 'Bat' },
        { name: 'bbHoldUntil', abbr: 'et', category: 'Bat' },
        { name: 'bbFireOn', abbr: 'nt', category: 'Bat' },
        { name: 'bbAngleVariance', abbr: 'ht', category: 'Bat' },
        // RK
        { name: 'rkAmmoNeeded', abbr: 'kt', category: 'RK' },
        { name: 'rkVelocity', abbr: 'St', category: 'RK' },
        { name: 'rkAimRate', abbr: 'Nt', category: 'RK' },
        { name: 'rkBulletRound', abbr: 'Mt', category: 'RK' },
        { name: 'rkBulletRadius', abbr: 'Ct', category: 'RK' },
        { name: 'rkBulletAge', abbr: 'Tt', category: 'RK' },
        { name: 'rkBulletTurnRate', abbr: 'xt', category: 'RK' },
        { name: 'rkBulletGravityScale', abbr: '_t', category: 'RK' },
        { name: 'rkBulletBounces', abbr: 'Pt', category: 'RK' },
        { name: 'rkBulletRestitution', abbr: 'Et', category: 'RK' },
        { name: 'rkBulletBounceCountLimit', abbr: 'Bt', category: 'RK' },
        { name: 'rkSelfBatScale', abbr: 'It', category: 'RK' },
        { name: 'rkExplodeRadius', abbr: 'Ft', category: 'RK' },
        { name: 'rkExplodePush', abbr: 'At', category: 'RK' },
        // Push
        { name: 'pushPlayers', abbr: 'Y', category: 'Push' },
        { name: 'pushRange', abbr: 'q', category: 'Push' },
        { name: 'pushDelay', abbr: 'V', category: 'Push' },
        { name: 'pushMinAmmoToStartPush', abbr: 'X', category: 'Push' },
        { name: 'pushAmmoSpendIfPushing', abbr: 'Z', category: 'Push' },
        { name: 'pushAmmoSpendAtFirstPushFrame', abbr: '$', category: 'Push' },
        // EG
        { name: 'egEnabled', abbr: 'vt', category: 'EG' },
        { name: 'egSize', abbr: 'Rt', category: 'EG' },
        { name: 'egAge', abbr: 'Dt', category: 'EG' },
        { name: 'egGravityScale', abbr: 'Lt', category: 'EG' },
        { name: 'egRestitution', abbr: 'Ut', category: 'EG' },
        { name: 'egStartSpin', abbr: 'Wt', category: 'EG' },
        { name: 'egMaxThrowPower', abbr: 'Jt', category: 'EG' },
        { name: 'egAimRate', abbr: 'qt', category: 'EG' },
        { name: 'egAmmoNeeded', abbr: 'Gt', category: 'EG' },
        { name: 'egDelay1', abbr: 'Ht', category: 'EG' },
        { name: 'egDelay2', abbr: 'zt', category: 'EG' },
        { name: 'egDelayBeforeAmmoUse', abbr: 'Yt', category: 'EG' },
        { name: 'egShape', abbr: 'Vt', category: 'EG' },
        // Dash
        { name: 'dashInitDelay', abbr: 'dt', category: 'Dash' },
        { name: 'dashActiveLength', abbr: 'wt', category: 'Dash' },
        { name: 'dashCooldownAfterLength', abbr: 'ft', category: 'Dash' },
        { name: 'dashBoostSpeed', abbr: 'gt', category: 'Dash' },
        { name: 'dashAmmoCost', abbr: 'yt', category: 'Dash' },
        { name: 'dashEnabled', abbr: 'bt', category: 'Dash' },
        // Others
        { name: 'timeScale', abbr: 'J', category: 'Others' },
        { name: 'gravityOffset', abbr: 'G', category: 'Others' },
        { name: 'gameMode', abbr: 'H', category: 'Others' },
        { name: 'maxStamina', abbr: 'K', category: 'Others' },
        { name: 'parachuteEnabled', abbr: 'Ot', category: 'Others' },
        { name: 'playerSize', abbr: 'Kt', category: 'Others' },
        { name: 'objectiveLimit', abbr: 'Zt', category: 'Others' },
        { name: 'pushAmmoReplenish', abbr: 'tt', category: 'Others' },
        // Unknown
        { name: 'ht', abbr: 'ht', category: 'Unknown' },
        { name: 'et', abbr: 'et', category: 'Unknown' },
        { name: 'nt', abbr: 'nt', category: 'Unknown' },
        { name: 'ot', abbr: 'ot', category: 'Unknown' },
        { name: 'jt', abbr: 'jt', category: 'Unknown' },
        { name: 'Qt', abbr: 'Qt', category: 'Unknown' },
        { name: '$t', abbr: '$t', category: 'Unknown' },
        { name: 'ti', abbr: 'ti', category: 'Unknown' },
        { name: 'ii', abbr: 'ii', category: 'Unknown' },
        { name: 'si', abbr: 'si', category: 'Unknown' },
        { name: 'hi', abbr: 'hi', category: 'Unknown' },
        { name: 'ei', abbr: 'ei', category: 'Unknown' },
        { name: 'ni', abbr: 'ni', category: 'Unknown' },
        { name: 'oi', abbr: 'oi', category: 'Unknown' },
        { name: 'ri', abbr: 'ri', category: 'Unknown' },
        { name: 'Xt', abbr: 'Xt', category: 'Unknown' },
        { name: 'gsFightersCollide', abbr: 'gsFightersCollide', category: 'Unknown' },
        { name: 'recordMode', abbr: 'recordMode', category: 'Unknown' },
      ];
    }

    buildMaps() {
      this.settingsMap.forEach(item => {
        this.nameToAbbr[item.name.toLowerCase()] = item.abbr;
        this.abbrToName.set(item.abbr, item.name);
      });
    }

    // Get a setting value: current if exists, else default (roomDefaults)
    getSettingValue(abbr) {
      if (this.gameSettings && this.gameSettings[abbr] !== undefined) {
        return this.gameSettings[abbr];
      }
      if (this.roomDefaults && this.roomDefaults[abbr] !== undefined) {
        return this.roomDefaults[abbr];
      }
      return undefined;
    }

    init() {
      this.hookWebSocket();

      const findSettings = () => {
        if (window.multiplayerSession) {
          for (let key in window.multiplayerSession) {
            let obj = window.multiplayerSession[key];
            if (obj && obj.$L && typeof obj.$L === 'object') {
              this.gameSettings = obj.$L;
              this.gameSettingsObject = obj.$L;
              if (!this.roomDefaults) {
                this.roomDefaults = JSON.parse(JSON.stringify(this.gameSettings));
                //console.log('[Commander] Room defaults captured:', this.roomDefaults);
              }
              break;
            }
          }
        }
        if (!this.myName) {
          try {
            const localName = localStorage.getItem('playerName');
            if (localName) this.myName = localName;
          } catch (e) { }
        }
      };
      findSettings();
      const interval = setInterval(() => {
        if (this.gameSettings) {
          clearInterval(interval);
        } else {
          findSettings();
        }
      }, 500);
    }

    // Re‑apply current team assignments to all players
    restoreTeams() {
      for (let id in this.users) {
        let user = this.users[id];
        if (user && user.team !== undefined && user.team !== -1) {
          this.ws.send(`42[1,[47,{"i":${user.id},"t":${user.team}}]]`);
        }
      }
    }

    hookWebSocket() {
      const self = this;
      window.WebSocket.prototype.send = function (args) {
        if (this.url && this.url.includes('/socket.io/')) {
          if (!self.ws || self.ws !== this) {
            self.ws = this;
            this.addEventListener('message', (event) => self.processIncoming(event));
            this.addEventListener('close', () => {
              if (self.ws === this) {
                self.myid = -1;
                self.hostId = -2;
                self.ws = null;
                self.clearMessages();
              }
            });
          }
          try {
            if (typeof args === 'string' && args.startsWith('42[1,[')) {
              let packet = JSON.parse(args.slice(5, -1));
              if (packet[0] === 28) {
                let msg = packet[1].trim();
                if (msg.startsWith('!')) {
                  if (self.processCommand(null, msg, true)) {
                    return;
                  }
                } else {
                  let name = self.myName || 'You';
                  let user = self.users[self.myid];
                  if (user) name = user.name;
                  let nameColor = user ? self.getFontColor(user) : '#FFFFFF';
                  let borderColor = user ? self.getBorderColor(user) : '#FFFFFF';
                  let nameSpan = `<span style="color:${nameColor}; text-shadow:0.5px 0 0 ${borderColor}, -0.5px 0 0 ${borderColor}, 0 0.5px 0 ${borderColor}, 0 -0.5px 0 ${borderColor};">${self.escapeHtml(name)}</span>`;
                  let html = `${nameSpan}: <span>${self.escapeHtml(msg)}</span>`;
                  self.addMessage(html, true);
                }
              }
            }
          } catch (e) { }
        }
        return self.nativeSend.call(this, args);
      };
    }

    processIncoming(event) {
      if (typeof event.data !== 'string' || !event.data.startsWith('42[')) return;
      try {
        let packet = JSON.parse(event.data.slice(2));
        let type = packet[0];
        //console.log(`[Commander] Packet type ${type}`, packet);

        if (type === 7) {
          //console.log('[Commander] 🔵 FULL PLAYER LIST RECEIVED');
          this.clearMessages();
          this.myid = packet[1][0];
          this.hostId = packet[1][1];
          this.users = {};
          for (let p of packet[1][3]) {
            let userId = p[4];
            let customColor = p[7] ? (p[7][1] || p[7][0]) : 0xFFFFFF;
            let team = p[2];
            this.users[userId] = { id: userId, name: p[0], customColor: customColor, team: team };
            if (userId === this.myid) this.myName = p[0];
            //console.log(`[Commander]   Player ${p[0]} (ID ${userId}) team ${team} color 0x${customColor.toString(16)}`);
          }
          // Do NOT reset roomDefaults here
        } else if (type === 8) {
          let userId = packet[1][4];
          let userName = packet[1][0];
          let customColor = packet[7] ? (packet[7][1] || packet[7][0]) : 0xFFFFFF;
          let team = packet[1][2];
          this.users[userId] = { id: userId, name: userName, customColor: customColor, team: team };
          //console.log(`[Commander] Player joined: ${userName} (team ${team})`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} joined the game</span>`;
          this.addMessage(html, false);
        } else if (type === 9) {
          let userId = packet[1];
          let userName = this.users[userId]?.name || 'Unknown';
          this.hostId = packet[2];
          delete this.users[userId];
          //console.log(`[Commander] Player left: ${userName}`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} left the game</span>`;
          this.addMessage(html, false);
        } else if (type === 25) {
          let userId = packet[1];
          let newTeam = packet[2];
          if (this.users[userId]) {
            let oldTeam = this.users[userId].team;
            this.users[userId].team = newTeam;
            //console.log(`[Commander] 🔄 Team change: ${this.users[userId].name} from ${oldTeam} to ${newTeam}`);
          }
        } else if (type === 32) {
          let data = packet[1];
          let kickedUserId = data.id;
          let userName = this.users[kickedUserId]?.name || 'Unknown';
          let action = (data.ban === 1) ? 'banned' : 'kicked';
          //console.log(`[Commander] Player ${userName} ${action}`);
          let html = `<span style="color:#AAAAAA">${this.escapeHtml(userName)} was ${action}</span>`;
          this.addMessage(html, false);
        } else if (type === 42) {
          let mapId = packet[1];
          //console.log(`[Commander] Map changed to ${mapId}`);
          let html = `<span style="color:#AAAAAA">Game starting on new map (ID: ${mapId})</span>`;
          this.addMessage(html, false);
        } else if (type === 43) {
          //console.log(`[Commander] Game start`);
          let html = `<span style="color:#AAAAAA">Game has started!</span>`;
          this.addMessage(html, false);
        } else if (type === 45) {
          this.hostId = packet[1];
          //console.log(`[Commander] New host ID: ${this.hostId}`);
        } else if (type === 41) {
          let userId = packet[1];
          let newColor = packet[2]["1"];
          if (this.users[userId]) {
            let oldColor = this.users[userId].customColor;
            this.users[userId].customColor = newColor;
            //console.log(`[Commander] 🎨 Color change: ${this.users[userId].name} from 0x${oldColor.toString(16)} to 0x${newColor.toString(16)}`);
          }
        } else if (type === 63) {
          this.gameSettings = packet[1];
          this.gameSettingsObject = packet[1];
          if (!this.roomDefaults) {
            this.roomDefaults = JSON.parse(JSON.stringify(this.gameSettings));
            //console.log('[Commander] Room defaults captured from type 63:', this.roomDefaults);
          }
          if (this.gameSettings && this.gameSettings.H !== undefined) {
            //console.log(`[Commander] ⚙️ Game mode changed to: ${this.gameSettings.H}`);
          }
        } else if (type === 74) {
          let teamsEnabled = packet[1];
          let assignments = packet[2] || [];
          //console.log(`[Commander] 🏁 Team mode toggle: enabled=${teamsEnabled}, assignments=`, assignments);
          for (let assignment of assignments) {
            let playerId = assignment[0];
            let team = assignment[1];
            if (this.users[playerId]) {
              let oldTeam = this.users[playerId].team;
              this.users[playerId].team = team;
              //console.log(`[Commander]   Player ${this.users[playerId].name} team ${oldTeam} -> ${team}`);
            }
          }
        }

        if (type === 29) {
          let userId = packet[1];
          if (userId === this.myid) return;
          let msg = packet[2].trim();
          let user = this.users[userId];
          if (!user) return;
          let name = user.name;
          let nameColor = this.getFontColor(user);
          let borderColor = this.getBorderColor(user);
          let nameSpan = `<span style="color:${nameColor}; text-shadow:0.5px 0 0 ${borderColor}, -0.5px 0 0 ${borderColor}, 0 0.5px 0 ${borderColor}, 0 -0.5px 0 ${borderColor};">${this.escapeHtml(name)}</span>`;
          let html = `${nameSpan}: <span>${this.escapeHtml(msg)}</span>`;
          this.addMessage(html, false);
        }
      } catch (e) {
        console.error('[Commander] Error processing packet', e);
      }
    }

    getFontColor(user) {
      if (user.team === 0) return '#888888';
      if (user.team === 1) return this.rgbNumberToHex(user.customColor);
      let baseColor = (user.team === 2) ? '#FF0000' : '#0000FF';
      let luminance = this.getLuminance(user.customColor);
      let factor = Math.max(0.2, luminance);
      let baseR = parseInt(baseColor.slice(1, 3), 16);
      let baseG = parseInt(baseColor.slice(3, 5), 16);
      let baseB = parseInt(baseColor.slice(5, 7), 16);
      let newR = Math.round(baseR * factor);
      let newG = Math.round(baseG * factor);
      let newB = Math.round(baseB * factor);
      return '#' + ((1 << 24) + (newR << 16) + (newG << 8) + newB).toString(16).slice(1);
    }

    getBorderColor(user) {
      if (user.team === 2) return '#FF0000';
      if (user.team === 3) return '#0000FF';
      if (user.team === 0) return '#000000';
      return this.brightenColor(user.customColor);
    }

    getLuminance(customColorNum) {
      let hex = this.rgbNumberToHex(customColorNum);
      let r = parseInt(hex.slice(1, 3), 16);
      let g = parseInt(hex.slice(3, 5), 16);
      let b = parseInt(hex.slice(5, 7), 16);
      return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
    }

    brightenColor(customColorNum) {
      let hex = this.rgbNumberToHex(customColorNum);
      if (hex === '#000000') return '#FFFFFF';
      let r = parseInt(hex.slice(1, 3), 16);
      let g = parseInt(hex.slice(3, 5), 16);
      let b = parseInt(hex.slice(5, 7), 16);
      let max = Math.max(r, g, b);
      if (max < 128) {
        let factor = 255 / max;
        r = Math.min(255, Math.round(r * factor));
        g = Math.min(255, Math.round(g * factor));
        b = Math.min(255, Math.round(b * factor));
      }
      return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
    }

    rgbNumberToHex(rgb) {
      return '#' + ('000000' + ((rgb & 0xFFFFFF) >>> 0).toString(16)).slice(-6);
    }

    addMessage(html, isOwn = false) {
      this.chatMessages.push({ html, isOwn });
      if (this.chatDialog && this.chatDialog.style.display !== 'none') {
        this.updateChatDialog();
      }
    }

    clearMessages() {
      this.chatMessages = [];
      if (this.chatDialog && this.chatDialog.style.display !== 'none') {
        this.updateChatDialog();
      }
    }

    escapeHtml(unsafe) {
      return unsafe.replace(/[&<>"']/g, function (m) {
        if (m === '&') return '&amp;';
        if (m === '<') return '&lt;';
        if (m === '>') return '&gt;';
        if (m === '"') return '&quot;';
        if (m === "'") return '&#039;';
        return m;
      });
    }

    isValidValue(str) {
      if (str.toLowerCase() === 'true' || str.toLowerCase() === 'false') return true;
      return /^-?\d+(\.\d+)?$/.test(str);
    }

    findSettingByInput(input) {
      if (input.startsWith('@')) {
        let abbr = input.substring(1);
        if (this.abbrToName.has(abbr)) {
          return { type: 'setting', abbr: abbr, name: this.abbrToName.get(abbr) };
        }
        if (this.gameSettings && this.gameSettings.hasOwnProperty(abbr)) {
          return { type: 'setting', abbr: abbr, name: abbr };
        }
        return null;
      }
      let lowerInput = input.toLowerCase();
      if (this.nameToAbbr.hasOwnProperty(lowerInput)) {
        let abbr = this.nameToAbbr[lowerInput];
        return { type: 'setting', abbr: abbr, name: this.abbrToName.get(abbr) || abbr };
      }
      if (this.abbrToName.has(input)) {
        return { type: 'setting', abbr: input, name: this.abbrToName.get(input) };
      }
      const matchedCat = this.categoryNames.find(cat => cat === lowerInput);
      if (matchedCat) {
        let category = matchedCat.charAt(0).toUpperCase() + matchedCat.slice(1);
        return { type: 'category', category: category };
      }
      if (this.gameSettings && this.gameSettings.hasOwnProperty(input)) {
        return { type: 'setting', abbr: input, name: input };
      }
      return null;
    }

    processCommand(user, msg, isOutgoing) {
      if (!msg.startsWith('!')) return false;
      let parts = msg.slice(1).split(' ');
      let cmd = parts[0].toLowerCase();
      let args = parts.slice(1).join(' ').trim();

      if (cmd === 'help') {
        const helpText = [
          '<span style="color:#00FF00">!get &lt;setting|@abbr|category&gt;</span> – Show current value of a setting, or list all settings in a category.',
          '<span style="color:#00FF00">!set &lt;setting|@abbr&gt; &lt;value&gt;</span> – Change a setting (host only).',
          '<span style="color:#00FF00">!reset &lt;setting|@abbr&gt;</span> – Reset a specific setting to its initial value (host only).',
          '<span style="color:#00FF00">!getall</span> – List all known settings, showing current value (or default if not changed).',
          '<span style="color:#00FF00">!sync</span> – Adjust settings based on current timeScale.',
          '<span style="color:#00FF00">!dumpjo</span> – Log full settings object to console (F12).',
          '<span style="color:#AAAAAA">!togglechat</span> – Hide/show the in‑game message area. Hold Shift to see last messages.',
          '<span style="color:#00FF00">!help</span> – Show this message.'
        ];
        helpText.forEach(line => this.display(line, '#FFFFFF', true));
        return true;
      }

      if (cmd === 'set' || cmd === 'sync' || cmd === 'reset') {
        if (this.myid === -1 || this.hostId === -2) {
          this.display('Host status unknown. Try again in a moment.', '#FFA500');
          return true;
        }
        if (this.myid !== this.hostId) {
          this.display('Only the host can use this command.', '#FF0000');
          return true;
        }
      }

      if (cmd === 'get') {
        if (!args) {
          this.display('Usage: !get <setting|@abbr|category> (e.g., !get rkAimRate, !get @Nt, !get bat)', '#FFA500');
          return true;
        }
        let result = this.findSettingByInput(args);
        if (!result) return true;
        if (result.type === 'category') {
          let category = result.category;
          let items = this.settingsMap.filter(item => item.category.toLowerCase() === category.toLowerCase());
          if (items.length === 0) {
            this.display(`No settings found in category ${category}.`, '#FF0000');
            return true;
          }
          let lines = [];
          lines.push(`<span style="color:#FFA500; font-weight:bold;">--- ${category} ---</span>`);
          items.sort((a, b) => a.name.localeCompare(b.name));
          for (let item of items) {
            let val = this.getSettingValue(item.abbr);
            if (val === undefined) continue;
            if (typeof val === 'boolean') val = val ? 'true' : 'false';
            lines.push(`<span style="color:#00FF00">${item.name} (${item.abbr})</span>=<span style="color:#FFFF00">${val}</span>`);
          }
          this.sendChunked(lines);
          return true;
        } else {
          let value = this.getSettingValue(result.abbr);
          if (value === undefined) return true;
          if (typeof value === 'boolean') value = value ? 'true' : 'false';
          let html = `<span style="color:#00FF00">${result.name} (${result.abbr})</span> = <span style="color:#FFFF00">${value}</span>`;
          this.display(html, '#00FF00', true);
          return true;
        }
      }

      if (cmd === 'dumpjo') {
        if (this.gameSettings) {
          //console.log('=== Full game settings ===', this.gameSettings);
          this.display('Full settings object logged to console (F12).', '#AAAAAA');
        } else {
          this.display('gameSettings not available.', '#FF0000');
        }
        return true;
      }

      if (cmd === 'getall') {
        if (!this.gameSettings && !this.roomDefaults) {
          this.display('Settings not available yet. Try again in a moment.', '#FF0000');
          return true;
        }
        let lines = [];
        for (let cat of this.categoryOrder) {
          const items = this.settingsMap.filter(item => item.category.toLowerCase() === cat.toLowerCase());
          if (items.length === 0) continue;
          lines.push(`<span style="color:#FFA500; font-weight:bold;">--- ${cat} ---</span>`);
          items.sort((a, b) => a.name.localeCompare(b.name));
          for (let item of items) {
            let val = this.getSettingValue(item.abbr);
            let displayVal = (val !== undefined) ? (typeof val === 'boolean' ? (val ? 'true' : 'false') : val) : 'N/A';
            lines.push(`<span style="color:#00FF00">${item.name} (${item.abbr})</span>=<span style="color:#FFFF00">${displayVal}</span>`);
          }
        }
        if (lines.length === 0) {
          this.display('No settings found.', '#FF0000');
        } else {
          this.sendChunked(lines);
        }
        return true;
      }

      if (cmd === 'set') {
        let setParts = args.split(' ');
        if (setParts.length < 2) {
          this.display('Usage: !set <setting|@abbr> <value>', '#FFA500');
          return true;
        }
        let settingInput = setParts[0];
        let settingValue = setParts.slice(1).join(' ').trim();
        if (!this.isValidValue(settingValue)) return false;
        let result = this.findSettingByInput(settingInput);
        if (!result || result.type !== 'setting') return false;
        let numValue = parseFloat(settingValue);
        let finalValue = isNaN(numValue) ? settingValue : numValue;
        if (settingValue.toLowerCase() === 'true') finalValue = true;
        if (settingValue.toLowerCase() === 'false') finalValue = false;
        let update = {};
        update[result.abbr] = finalValue;
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, update]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }
        if (this.gameSettings) this.gameSettings[result.abbr] = finalValue;
        if (this.gameSettingsObject) this.gameSettingsObject[result.abbr] = finalValue;
        this.display(`Setting ${result.name} changed to ${finalValue}.`, '#00FF00');
        if (result.name.toLowerCase() === 'timescale') {
          setTimeout(() => {
            this.display('TimeScale changed. Use !sync to adjust related settings.', '#AAAAAA');
          }, 500);
        }
        return true;
      }

      if (cmd === 'reset') {
        if (!args) {
          this.display('Usage: !reset <setting|@abbr>', '#FFA500');
          return true;
        }
        let result = this.findSettingByInput(args);
        if (!result || result.type !== 'setting') return false;
        if (!this.roomDefaults) {
          this.display('Room defaults not available. Use !getall to populate.', '#FF0000');
          return true;
        }
        let initialValue = this.roomDefaults[result.abbr];
        if (initialValue === undefined) {
          this.display(`No default value found for ${result.name}.`, '#FF0000');
          return true;
        }
        let update = {};
        update[result.abbr] = initialValue;
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, update]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }
        if (this.gameSettings) this.gameSettings[result.abbr] = initialValue;
        if (this.gameSettingsObject) this.gameSettingsObject[result.abbr] = initialValue;
        this.display(`Setting ${result.name} reset to ${initialValue}.`, '#00FF00');
        return true;
      }

      if (cmd === 'sync') {
        if (!this.gameSettings) {
          this.display('gameSettings not fully loaded. Try again in a moment.', '#FF0000');
          return true;
        }
        let baseline = this.roomDefaults;
        if (!baseline) {
          this.display('Room defaults not available. Use !getall to populate.', '#FF0000');
          return true;
        }
        let currentTimeScale = this.gameSettings.J;
        if (currentTimeScale === undefined) {
          this.display('TimeScale not found.', '#FF0000');
          return true;
        }
        let factorUp = 30 / currentTimeScale;
        let factorDown = currentTimeScale / 30;

        let baseRkAimRate = baseline.Nt;
        let basePushReplenish = baseline.tt;
        let baseBbFireFramesLength = baseline.at;
        let baseBbHideAfterFireFrames = baseline.lt;
        let baseBbHoldAmmoCost = baseline.rt;
        let baseBbResetOn = baseline.ut;
        let baseBbFireOn = baseline.nt;

        const missing = [];
        if (baseRkAimRate === undefined) missing.push('rkAimRate (Nt)');
        if (basePushReplenish === undefined) missing.push('pushAmmoReplenish (tt)');
        if (baseBbFireFramesLength === undefined) missing.push('bbFireFramesLength (at)');
        if (baseBbHideAfterFireFrames === undefined) missing.push('bbHideAfterFireFrames (lt)');
        if (baseBbHoldAmmoCost === undefined) missing.push('bbHoldAmmoCost (rt)');
        if (baseBbResetOn === undefined) missing.push('bbResetOn (ut)');
        if (baseBbFireOn === undefined) missing.push('bbFireOn (nt)');

        if (missing.length > 0) {
          this.display(`Missing baseline settings: ${missing.join(', ')}. They may not exist in this game mode.`, '#FF0000');
          return true;
        }

        let newRkAimRate = baseRkAimRate * factorUp;
        let newPushReplenish = basePushReplenish * factorUp;
        let newBbFireFramesLength = baseBbFireFramesLength * factorDown;
        let newBbHideAfterFireFrames = baseBbHideAfterFireFrames * factorDown;
        let newBbHoldAmmoCost = baseBbHoldAmmoCost * factorUp;
        let newBbResetOn = Math.round(baseBbResetOn * factorDown);
        let newBbFireOn = Math.round(baseBbFireOn * factorDown);

        let updates = {
          Nt: newRkAimRate,
          tt: newPushReplenish,
          at: newBbFireFramesLength,
          lt: newBbHideAfterFireFrames,
          rt: newBbHoldAmmoCost,
          ut: newBbResetOn,
          nt: newBbFireOn
        };
        //console.log('[Commander] Sending sync updates:', updates);
        if (this.ws) {
          this.ws.send('42' + JSON.stringify([1, [62, updates]]));
          setTimeout(() => this.restoreTeams(), 50);
        } else {
          this.display('WebSocket not available.', '#FF0000');
          return true;
        }

        if (this.gameSettings) {
          this.gameSettings.Nt = newRkAimRate;
          this.gameSettings.tt = newPushReplenish;
          this.gameSettings.at = newBbFireFramesLength;
          this.gameSettings.lt = newBbHideAfterFireFrames;
          this.gameSettings.rt = newBbHoldAmmoCost;
          this.gameSettings.ut = newBbResetOn;
          this.gameSettings.nt = newBbFireOn;
        }
        if (this.gameSettingsObject) {
          this.gameSettingsObject.Nt = newRkAimRate;
          this.gameSettingsObject.tt = newPushReplenish;
          this.gameSettingsObject.at = newBbFireFramesLength;
          this.gameSettingsObject.lt = newBbHideAfterFireFrames;
          this.gameSettingsObject.rt = newBbHoldAmmoCost;
          this.gameSettingsObject.ut = newBbResetOn;
          this.gameSettingsObject.nt = newBbFireOn;
        }

        this.display(`Synced: rkAimRate = ${newRkAimRate.toFixed(2)}, pushAmmoReplenish = ${newPushReplenish.toFixed(2)}, bbFireFramesLength = ${newBbFireFramesLength.toFixed(2)}, bbHideAfterFireFrames = ${newBbHideAfterFireFrames.toFixed(2)}, bbHoldAmmoCost = ${newBbHoldAmmoCost.toFixed(2)}, bbResetOn = ${newBbResetOn}, bbFireOn = ${newBbFireOn} (timeScale = ${currentTimeScale})`, '#00FF00');
        return true;
      }

      if (cmd === 'togglechat') {
        this.chatHidden = !this.chatHidden;
        localStorage.setItem('chatHidden', this.chatHidden);
        this.applyChatHidden();
        this.display(`In-game message area ${this.chatHidden ? 'hidden' : 'visible'}. Input always visible.`, '#AAAAAA');
        return true;
      }

      return false;
    }

    sendChunked(lines) {
      const CHUNK_SIZE = 8;
      for (let i = 0; i < lines.length; i += CHUNK_SIZE) {
        let chunk = lines.slice(i, i + CHUNK_SIZE).join('<br>');
        this.display(chunk, '#00FF00', true);
      }
    }

    display(text, color, isHtml = false) {
      let chatBoxes = document.querySelectorAll('.chatBox .content, .inGameChat .content');
      if (!chatBoxes.length) return;
      let div = document.createElement('div');
      div.classList.add('statusContainer');
      let span = document.createElement('span');
      span.classList.add('status');
      span.style.backgroundColor = 'rgba(37, 38, 42, 0.768627451)';
      span.style.borderRadius = '7px';
      span.style.padding = '2px 5px';
      span.style.display = 'inline-block';
      span.style.margin = '2px 0';
      span.style.whiteSpace = 'pre-wrap';
      span.style.wordBreak = 'break-word';
      if (isHtml) {
        span.innerHTML = `<span style="color:purple">[Bot]</span> ${text}`;
      } else {
        span.innerHTML = `<span style="color:purple">[Bot]</span> <span style="color:${color}">${this.escapeHtml(text)}</span>`;
      }
      div.appendChild(span);
      let botHtml = span.innerHTML;
      this.addMessage(botHtml, false);
      for (let box of chatBoxes) {
        let clone = div.cloneNode(true);
        box.appendChild(clone);
        box.scrollTop = box.scrollHeight;
        if (box.closest('.inGameChat')) {
          setTimeout(() => {
            if (clone.parentNode) clone.remove();
          }, 5000);
        }
      }
    }

    initChatToggle() {
      //console.log('[Commander] initChatToggle - chatHidden =', this.chatHidden);
      this.applyChatHidden();
      setInterval(() => {
        if (this.chatHidden && !this.tempChatVisible) {
          this.applyChatHidden();
        }
      }, 1000);
      window.addEventListener('keydown', (e) => {
        if (e.key === 'Shift') {
          //console.log('[Commander] Shift down');
          this.showChatDialog();
        }
      });
      window.addEventListener('keyup', (e) => {
        if (e.key === 'Shift') {
          //console.log('[Commander] Shift up');
          this.hideChatDialog();
        }
      });
    }

    applyChatHidden() {
      const chatContent = document.querySelector('.inGameChat .content');
      if (!chatContent) {
        //console.log('[Commander] Chat content not found');
        return;
      }
      if (this.chatHidden && !this.tempChatVisible) {
        chatContent.style.setProperty('display', 'none', 'important');
        //console.log('[Commander] Messages hidden');
      } else {
        chatContent.style.display = '';
        //console.log('[Commander] Messages visible');
      }
    }

    createChatDialog() {
      this.chatDialog = document.createElement('div');
      this.chatDialog.id = 'commander-chat-dialog';
      this.chatDialog.style.position = 'fixed';
      this.chatDialog.style.bottom = '60px';
      this.chatDialog.style.left = '10px';
      this.chatDialog.style.width = '300px';
      this.chatDialog.style.maxHeight = '200px';
      this.chatDialog.style.overflowY = 'auto';
      this.chatDialog.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
      this.chatDialog.style.color = 'white';
      this.chatDialog.style.fontFamily = 'Arial, sans-serif';
      this.chatDialog.style.fontSize = '14px';
      this.chatDialog.style.padding = '8px';
      this.chatDialog.style.borderRadius = '8px';
      this.chatDialog.style.backdropFilter = 'blur(2px)';
      this.chatDialog.style.zIndex = '10000';
      this.chatDialog.style.display = 'none';
      this.chatDialog.style.pointerEvents = 'auto';
      this.chatDialog.addEventListener('scroll', () => {
        if (this.chatDialog) {
          const atBottom = this.chatDialog.scrollHeight - this.chatDialog.scrollTop <= this.chatDialog.clientHeight + 5;
          this.userScrolled = !atBottom;
        }
      });
      document.body.appendChild(this.chatDialog);
    }

    updateChatDialog() {
      if (!this.chatDialog) return;
      let html = '';
      for (let m of this.chatMessages) {
        let extraStyle = m.isOwn ? 'border-left: 2px solid gold; background-color: rgba(255,215,0,0.1); padding-left: 4px;' : '';
        html += `<div style="margin-bottom: 2px; ${extraStyle}">${m.html}</div>`;
      }
      this.chatDialog.innerHTML = html || '<i>No messages yet</i>';
      if (!this.userScrolled) {
        setTimeout(() => {
          if (this.chatDialog) {
            this.chatDialog.scrollTop = this.chatDialog.scrollHeight;
          }
        }, 0);
      }
    }

    showChatDialog() {
      if (!this.chatDialog) return;
      this.userScrolled = false;
      this.updateChatDialog();
      this.chatDialog.style.display = 'block';
    }

    hideChatDialog() {
      if (!this.chatDialog) return;
      this.chatDialog.style.display = 'none';
    }
  }

  // ---- Modified keydown handler to allow non-mod keys even when input is focused ----
  const originalAddEventListener = window.addEventListener;
  window.addEventListener = function (type, listener, options) {
    if (type === 'keydown') {
      const wrappedListener = function (e) {
        const isModKey = (e.which == 65 && e.shiftKey) || (e.which == 83 && e.shiftKey) || (e.which == 70 && e.shiftKey) ||
          (() => {
            if (typeof macro_scripts !== 'undefined' && macro_scripts) {
              let comb = ((e.shiftKey ? 'shift+' : '') + (e.altKey ? 'alt+' : '') + e.key.toLowerCase()).split('+');
              for (let i in macro_scripts) {
                let keycomb = i.toLowerCase().split('+');
                let kc = true;
                for (let y of keycomb) if (!comb.includes(y)) kc = false;
                for (let y of comb) if (!keycomb.includes(y)) kc = false;
                if (kc) return true;
              }
            }
            return false;
          })();
        if (document.activeElement && document.activeElement.tagName == "INPUT" && !isModKey && e.which != 13) {
          e.stopImmediatePropagation();
          return;
        }
        listener(e);
      };
      return originalAddEventListener.call(this, type, wrappedListener, options);
    }
    return originalAddEventListener.call(this, type, listener, options);
  };

  if (document.readyState === 'complete') {
    new HitboxCommander();
  } else {
    window.addEventListener('load', () => new HitboxCommander());
  }
})();