GTH Quick Bar Call/Help

Profile anchored quick bar + attack floating quick bar with detached global settings panel

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GTH Quick Bar Call/Help
// @namespace    zulu.torn.quickbar.hybridstable
// @version      1.0.1
// @description  Profile anchored quick bar + attack floating quick bar with detached global settings panel
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      api.torn.com
// @connect      www.lol-manager.com
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const APP_ID = 'zqb-hybrid';
  const HOLD_MS = 2000;
  const SCRIPT_VERSION = '1.0.0';
  const PREDICTION_VALIDITY_DAYS = 5;

  const FAIL = 0;
  const SUCCESS = 1;
  const TOO_WEAK = 2;
  const TOO_STRONG = 3;
  const MODEL_ERROR = 4;
  const HOF = 5;
  const FFATTACKS = 6;

  const StorageKey = {
    PrimaryAPIKey: 'tdup.battleStatsPredictor.PrimaryAPIKey',
    IsPrimaryAPIKeyValid: 'tdup.battleStatsPredictor.IsPrimaryAPIKeyValid',
    PlayerId: 'tdup.battleStatsPredictor.PlayerId',
    BSPPrediction: 'tdup.battleStatsPredictor.cache.prediction.',
    ChatHoldEnabled: 'zqb.chat.hold.enabled.v1',
    LockAttackPosition: 'zqb.attack.lock.v1',
    AttackPos: 'zqb.attack.position.v1'
  };

  const state = {
    lastHref: location.href,
    armedText: '',
    armedType: '',
    pendingPrediction: new Map(),
    chatHoldMap: new WeakMap(),
    drag: {
      active: false,
      startX: 0,
      startY: 0,
      baseX: 0,
      baseY: 0
    },
    openSettingsFor: null
  };

  function gmRequest(opts) {
    const fn =
      (typeof GM_xmlhttpRequest === 'function' && GM_xmlhttpRequest) ||
      (typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function' && GM.xmlHttpRequest.bind(GM));

    if (!fn) throw new Error('GM xmlHttpRequest not available');
    return fn(opts);
  }

  function q(v) {
    return String(v || '').replace(/\s+/g, ' ').trim();
  }

  function jsonParse(v) {
    try {
      return JSON.parse(v);
    } catch {
      return null;
    }
  }

  function getStorage(key) {
    return localStorage.getItem(key);
  }

  function setStorage(key, value) {
    try {
      localStorage.setItem(key, String(value));
    } catch {}
  }

  function getBool(key, def = false) {
    const v = getStorage(key);
    if (v === 'true') return true;
    if (v === 'false') return false;
    return def;
  }

  function setBool(key, value) {
    setStorage(key, value ? 'true' : 'false');
  }

  function getObj(key, fallback) {
    const raw = getStorage(key);
    const obj = raw ? jsonParse(raw) : null;
    return obj && typeof obj === 'object' ? obj : fallback;
  }

  function setObj(key, value) {
    setStorage(key, JSON.stringify(value));
  }

  function isProfilePage() {
    const href = location.href;
    return /profiles\.php/i.test(href) || /pda\.php\?sid=profile/i.test(href);
  }

  function isAttackPage() {
    return /sid=attack/i.test(location.href);
  }

  function getPageType() {
    if (isProfilePage()) return 'profile';
    if (isAttackPage()) return 'attack';
    return null;
  }

  function getTargetId() {
    try {
      const u = new URL(location.href);
      return (
        u.searchParams.get('XID') ||
        u.searchParams.get('user2ID') ||
        ''
      ).trim();
    } catch {
      return '';
    }
  }

  function getProfileName() {
    const title = q(document.title);
    const m = title.match(/^(.+?)'s Profile/i);
    if (m?.[1]) return m[1].trim();

    const h = document.querySelector('h4, h3, h2');
    if (h) {
      const txt = q(h.textContent).replace(/'s Profile/i, '').trim();
      if (txt) return txt;
    }

    return '';
  }

  function findProfileAnchor() {
    return document.querySelector('.content-title')
      || document.querySelector('#sidebar')
      || document.querySelector('#mainContainer h4')
      || document.querySelector('h4')
      || document.querySelector('h3');
  }

  function findAttackAnchor() {
    const arr = Array.from(document.querySelectorAll('h4,div,span'));
    return arr.find(el => q(el.textContent) === 'Attacking') || null;
  }

  function getStatusEl() {
    return document.querySelector('#' + APP_ID + ' .zqb-status');
  }

  function setStatus(text, good = true) {
    const el = getStatusEl();
    if (!el) return;
    el.textContent = text || '';
    el.style.color = good ? '#9fe0aa' : '#ff9b9b';
  }

  function clearStatusSoon() {
    setTimeout(() => {
      const el = getStatusEl();
      if (!el) return;
      if (el.textContent === 'Armed C' || el.textContent === 'Armed H' || el.textContent === 'Pasted' || el.textContent === 'Saved') {
        el.textContent = '';
        renderArmedState();
      }
    }, 1200);
  }

  function renderArmedState() {
    const buttons = document.querySelectorAll('#' + APP_ID + ' .zqb-btn');
    buttons.forEach(b => b.classList.remove('zqb-armed'));

    if (state.armedType) {
      const active = document.querySelector(`#${APP_ID} .zqb-btn[data-type="${state.armedType}"]`);
      if (active) active.classList.add('zqb-armed');
    }

    const status = getStatusEl();
    if (status && !status.textContent) {
      status.textContent = state.armedText ? ('Armed ' + state.armedType) : 'Ready';
    }
  }

  function formatBattleStats(number) {
    const n = Number(number);
    if (!isFinite(n)) return '';
    const localized = n.toLocaleString('en-US');
    const parts = localized.split(',');
    if (parts.length < 1) return '';
    let out = parts[0];
    if (n < 1000) return String(Math.round(n));
    if (parseInt(out, 10) < 10 && parts[1] && parseInt(parts[1][0], 10) !== 0) {
      out += '.' + parts[1][0];
    }
    switch (parts.length) {
      case 2: out += 'k'; break;
      case 3: out += 'm'; break;
      case 4: out += 'b'; break;
      case 5: out += 't'; break;
      case 6: out += 'q'; break;
    }
    return out;
  }

  function getPredictionFromCache(playerId) {
    const raw = getStorage(StorageKey.BSPPrediction + playerId);
    if (!raw || raw === '[object Object]') return undefined;
    return jsonParse(raw);
  }

  function setPredictionInCache(playerId, prediction) {
    if (!prediction) return;
    if (prediction.Result === FAIL || prediction.Result === MODEL_ERROR) return;
    setStorage(StorageKey.BSPPrediction + playerId, JSON.stringify(prediction));
  }

  function isPredictionValid(prediction) {
    if (!prediction) return false;
    let predictionDate = prediction.PredictionDate ? new Date(prediction.PredictionDate) : null;
    if (prediction.DateFetched) predictionDate = new Date(prediction.DateFetched);
    if (!predictionDate || isNaN(predictionDate.getTime())) return false;

    const expirationDate = new Date();
    expirationDate.setDate(expirationDate.getDate() - PREDICTION_VALIDITY_DAYS);
    return predictionDate >= expirationDate;
  }

  function getPredictionTBS(prediction) {
    if (!prediction) return '';
    if (prediction.Result === FAIL || prediction.Result === MODEL_ERROR) return '';

    if (
      prediction.Result === TOO_WEAK ||
      prediction.Result === TOO_STRONG ||
      prediction.Result === HOF ||
      prediction.Result === FFATTACKS ||
      prediction.Result === SUCCESS
    ) {
      const tbsNum = parseInt(String(prediction.TBS || '').replace(/,/g, ''), 10);
      if (isFinite(tbsNum) && tbsNum > 0) return formatBattleStats(tbsNum);
    }

    return '';
  }

  function getBSPServer() {
    return 'http://www.lol-manager.com/api';
  }

  function canQueryAnyAPI() {
    return document.visibilityState === 'visible' && document.hasFocus();
  }

  function verifyPrimaryAPIKey() {
    return new Promise((resolve) => {
      if (!canQueryAnyAPI()) return resolve({ ok: false, reason: 'Page not focused' });

      const key = q(getStorage(StorageKey.PrimaryAPIKey));
      if (!key) return resolve({ ok: false, reason: 'Missing API key' });

      const url =
        'https://api.torn.com/v2/user/personalstats,profile?key=' +
        encodeURIComponent(key) +
        '&cat=all&comment=BSPAuth';

      gmRequest({
        method: 'GET',
        url,
        onload: (r) => {
          const j = jsonParse(r.responseText);
          if (!j) return resolve({ ok: false, reason: "Couldn't check (unexpected response)" });
          if (j.error && j.error.code > 0) return resolve({ ok: false, reason: j.error.error });
          if (j.status !== undefined && !j.status) return resolve({ ok: false, reason: 'unknown issue' });

          if (
            !j.personalstats ||
            !j.personalstats.attacking ||
            !j.personalstats.attacking.attacks ||
            j.personalstats.attacking.attacks.won === undefined
          ) {
            return resolve({ ok: false, reason: 'Key missing required permissions' });
          }

          setStorage(StorageKey.PlayerId, j.player_id);
          setBool(StorageKey.IsPrimaryAPIKeyValid, true);
          resolve({ ok: true, reason: 'API key verified' });
        },
        onabort: () => resolve({ ok: false, reason: "Couldn't check (aborted)" }),
        onerror: () => resolve({ ok: false, reason: "Couldn't check (error)" }),
        ontimeout: () => resolve({ ok: false, reason: "Couldn't check (timeout)" })
      });
    });
  }

  async function ensureValidatedKey() {
    const key = q(getStorage(StorageKey.PrimaryAPIKey));
    if (!key) return { ok: false, reason: 'Missing key' };

    const valid = getBool(StorageKey.IsPrimaryAPIKeyValid, false);
    if (valid) return { ok: true, reason: 'Already valid' };

    return await verifyPrimaryAPIKey();
  }

  function fetchScoreAndTBS(targetId) {
    return new Promise((resolve) => {
      if (!canQueryAnyAPI()) return resolve(undefined);

      const key = q(getStorage(StorageKey.PrimaryAPIKey));
      if (!key) return resolve(undefined);

      const url = `${getBSPServer()}/battlestats/${encodeURIComponent(key)}/${encodeURIComponent(targetId)}/${SCRIPT_VERSION}`;

      gmRequest({
        method: 'GET',
        url,
        headers: { 'Content-Type': 'application/json' },
        onload: (response) => {
          try {
            resolve(JSON.parse(response.responseText));
          } catch {
            resolve(undefined);
          }
        },
        onerror: () => resolve(undefined),
        onabort: () => resolve(undefined),
        ontimeout: () => resolve(undefined)
      });
    });
  }

  async function getPredictionForPlayer(targetId) {
    if (!targetId) return undefined;

    const cached = getPredictionFromCache(targetId);
    if (cached && isPredictionValid(cached)) return cached;

    if (state.pendingPrediction.has(targetId)) {
      return state.pendingPrediction.get(targetId);
    }

    const p = (async () => {
      const fresh = await fetchScoreAndTBS(targetId);
      if (fresh) {
        fresh.DateFetched = new Date();
        setPredictionInCache(targetId, fresh);
      }
      return fresh;
    })();

    state.pendingPrediction.set(targetId, p);

    try {
      return await p;
    } finally {
      state.pendingPrediction.delete(targetId);
    }
  }

  async function prepareC() {
    const name = getProfileName();
    if (!name) {
      state.armedText = '';
      state.armedType = '';
      setStatus('No name', false);
      renderArmedState();
      return;
    }

    state.armedText = '! ' + name + ' in 5';
    state.armedType = 'C';
    closeGlobalSettings();
    setStatus('Armed C', true);
    renderArmedState();
    clearStatusSoon();
  }

  async function prepareH() {
    const id = getTargetId();
    if (!id) {
      state.armedText = '';
      state.armedType = '';
      setStatus('No target', false);
      renderArmedState();
      return;
    }

    const key = q(getStorage(StorageKey.PrimaryAPIKey));
    if (!key) {
      state.armedText = '! Set BSP API key';
      state.armedType = 'H';
      setStatus('Missing key', false);
      renderArmedState();
      return;
    }

    closeGlobalSettings();
    setStatus('Checking key...', true);
    const validation = await ensureValidatedKey();
    if (!validation.ok) {
      state.armedText = '! Validate BSP API key';
      state.armedType = 'H';
      setStatus(validation.reason || 'Validation failed', false);
      renderArmedState();
      return;
    }

    setStatus('Fetching...', true);

    const prediction = await getPredictionForPlayer(id);
    const bsp = getPredictionTBS(prediction);

    let msg = '! Help me https://www.torn.com/page.php?sid=attack&user2ID=' + id;
    if (bsp) msg += ' BSP:' + bsp;

    state.armedText = msg;
    state.armedType = 'H';
    setStatus('Armed H', true);
    renderArmedState();
    clearStatusSoon();
  }

  async function copyText(text) {
    if (!text) return false;

    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(text);
        return true;
      }
    } catch {}

    const ta = document.createElement('textarea');
    ta.value = text;
    ta.setAttribute('readonly', 'readonly');
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    ta.style.top = '0';
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    ta.setSelectionRange(0, ta.value.length);

    let ok = false;
    try { ok = document.execCommand('copy'); } catch {}
    ta.remove();
    return !!ok;
  }

  function dispatchInputEvents(el) {
    try {
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ' ' }));
    } catch {}
  }

  function insertIntoElement(el, text) {
    if (!el || !text) return false;

    try { el.focus({ preventScroll: true }); } catch {}

    if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
      try {
        const setter = Object.getOwnPropertyDescriptor(el.constructor.prototype, 'value')?.set;
        if (setter) setter.call(el, text);
        else el.value = text;
        dispatchInputEvents(el);
        return true;
      } catch {}
    }

    if (el.isContentEditable) {
      try {
        const sel = window.getSelection();
        const range = document.createRange();
        range.selectNodeContents(el);
        range.collapse(false);
        sel.removeAllRanges();
        sel.addRange(range);

        const ok = document.execCommand('insertText', false, text);
        if (!ok) el.textContent = text;
        dispatchInputEvents(el);
        return true;
      } catch {
        try {
          el.textContent = text;
          dispatchInputEvents(el);
          return true;
        } catch {}
      }
    }

    return false;
  }

  async function insertArmedIntoChat(targetEl) {
    if (!state.armedText) {
      setStatus('No message', false);
      return false;
    }

    const inserted = insertIntoElement(targetEl, state.armedText);
    const copied = await copyText(state.armedText);

    if (inserted) setStatus('Pasted', true);
    else setStatus(copied ? 'Copied only' : 'Paste failed', copied);

    state.armedText = '';
    state.armedType = '';
    renderArmedState();
    clearStatusSoon();

    return inserted;
  }

  function isWritableChatElement(el) {
    if (!el || !(el instanceof Element)) return false;

    if (el instanceof HTMLTextAreaElement || (el instanceof HTMLInputElement && el.type === 'text')) {
      if (el.disabled || el.readOnly) return false;
      return true;
    }

    if (el.isContentEditable) return true;
    return false;
  }

  function attachHoldToChatElement(el) {
    if (!isWritableChatElement(el)) return;
    if (state.chatHoldMap.has(el)) return;

    const data = { timer: null, active: false, moved: false };

    const clear = () => {
      if (data.timer) {
        clearTimeout(data.timer);
        data.timer = null;
      }
      data.active = false;
      data.moved = false;
    };

    const start = () => {
      if (!state.armedText) return;
      data.active = true;
      data.moved = false;
      data.timer = setTimeout(async () => {
        if (!data.moved && data.active) await insertArmedIntoChat(el);
      }, HOLD_MS);
    };

    const move = () => {
      data.moved = true;
      if (data.timer) {
        clearTimeout(data.timer);
        data.timer = null;
      }
    };

    const end = () => clear();

    el.addEventListener('touchstart', start, { passive: true });
    el.addEventListener('touchmove', move, { passive: true });
    el.addEventListener('touchend', end, { passive: true });
    el.addEventListener('touchcancel', end, { passive: true });

    el.addEventListener('mousedown', start);
    el.addEventListener('mousemove', move);
    el.addEventListener('mouseup', end);
    el.addEventListener('mouseleave', end);

    state.chatHoldMap.set(el, true);
  }

  function scanAndAttachChatHold() {
    if (!getBool(StorageKey.ChatHoldEnabled, true)) return;

    const candidates = [
      ...Array.from(document.querySelectorAll('textarea')),
      ...Array.from(document.querySelectorAll('input[type="text"]')),
      ...Array.from(document.querySelectorAll('[contenteditable="true"]'))
    ];

    for (const el of candidates) {
      attachHoldToChatElement(el);
    }
  }

  function getAttackRoot() {
    return document.getElementById(APP_ID + '-attack-root');
  }

  function getAttackPos() {
    return getObj(StorageKey.AttackPos, { x: 16, y: 150 });
  }

  function setAttackPos(x, y) {
    setObj(StorageKey.AttackPos, { x, y });
  }

  function clampAttackRoot(root, x, y) {
    const rect = root.getBoundingClientRect();
    const maxX = Math.max(4, window.innerWidth - rect.width - 4);
    const maxY = Math.max(4, window.innerHeight - rect.height - 4);
    return {
      x: Math.min(Math.max(4, x), maxX),
      y: Math.min(Math.max(4, y), maxY)
    };
  }

  function applyAttackPosition() {
    const root = getAttackRoot();
    if (!root) return;

    const p = getAttackPos();
    const clamped = clampAttackRoot(root, p.x, p.y);

    root.style.position = 'fixed';
    root.style.left = Math.round(clamped.x) + 'px';
    root.style.top = Math.round(clamped.y) + 'px';

    setAttackPos(clamped.x, clamped.y);
  }

  function captureAttackPosition() {
    const root = getAttackRoot();
    if (!root) return;
    const rect = root.getBoundingClientRect();
    setAttackPos(rect.left, rect.top);
  }

  function enableAttackDrag(handle) {
    const start = (clientX, clientY) => {
      if (getBool(StorageKey.LockAttackPosition, false)) return;
      const root = getAttackRoot();
      if (!root) return;

      captureAttackPosition();
      const p = getAttackPos();

      state.drag.active = true;
      state.drag.startX = clientX;
      state.drag.startY = clientY;
      state.drag.baseX = p.x;
      state.drag.baseY = p.y;
    };

    const move = (clientX, clientY) => {
      if (!state.drag.active) return;
      if (getBool(StorageKey.LockAttackPosition, false)) return;

      const dx = clientX - state.drag.startX;
      const dy = clientY - state.drag.startY;

      setAttackPos(state.drag.baseX + dx, state.drag.baseY + dy);
      applyAttackPosition();
    };

    const end = () => {
      if (!state.drag.active) return;
      state.drag.active = false;
      captureAttackPosition();
    };

    handle.addEventListener('touchstart', (e) => {
      if (getBool(StorageKey.LockAttackPosition, false)) return;
      const t = e.touches[0];
      if (!t) return;
      start(t.clientX, t.clientY);
    }, { passive: true });

    handle.addEventListener('touchmove', (e) => {
      if (getBool(StorageKey.LockAttackPosition, false)) return;
      const t = e.touches[0];
      if (!t) return;
      move(t.clientX, t.clientY);
    }, { passive: true });

    handle.addEventListener('touchend', end, { passive: true });
    handle.addEventListener('touchcancel', end, { passive: true });

    handle.addEventListener('mousedown', (e) => {
      if (getBool(StorageKey.LockAttackPosition, false)) return;
      start(e.clientX, e.clientY);
    });

    window.addEventListener('mousemove', (e) => {
      move(e.clientX, e.clientY);
    });

    window.addEventListener('mouseup', end);
  }

  function getGlobalPanel() {
    return document.getElementById(APP_ID + '-global-panel');
  }

  function closeGlobalSettings() {
    const panel = getGlobalPanel();
    if (panel) panel.remove();
    state.openSettingsFor = null;
  }

  function openGlobalSettings(root, pageType) {
    closeGlobalSettings();
    state.openSettingsFor = root;

    const panel = document.createElement('div');
    panel.id = APP_ID + '-global-panel';
    panel.className = 'zqb-global-panel';
    panel.innerHTML = `
      <div class="zqb-field">
        <label>Primary API key</label>
        <input type="password" class="zqb-api" value="${escapeHtml(q(getStorage(StorageKey.PrimaryAPIKey)))}">
      </div>
      <div class="zqb-field">
        <label><input type="checkbox" class="zqb-holdchat" ${getBool(StorageKey.ChatHoldEnabled, true) ? 'checked' : ''}> long press on chat = paste</label>
      </div>
      ${pageType === 'attack' ? `<div class="zqb-field">
        <label><input type="checkbox" class="zqb-lockattack" ${getBool(StorageKey.LockAttackPosition, false) ? 'checked' : ''}> lock position</label>
      </div>` : ''}
      <button type="button" class="zqb-save">Save</button>
      <div class="zqb-hint">Use the same API key already used by BSP.</div>
    `;

    panel.addEventListener('click', (e) => e.stopPropagation());
    panel.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });

    document.body.appendChild(panel);

    const btn = root.querySelector('.zqb-btn[data-type="SETTINGS"]');
    const btnRect = btn.getBoundingClientRect();
    const panelRect = panel.getBoundingClientRect();
    const gap = 8;
    const vw = window.innerWidth;
    const vh = window.innerHeight;

    let left = btnRect.right + gap;
    let top = btnRect.top;

    if (left + panelRect.width > vw - 4) {
      left = btnRect.left - panelRect.width - gap;
    }
    if (left < 4) {
      left = Math.max(4, Math.min(btnRect.left, vw - panelRect.width - 4));
      top = btnRect.bottom + gap;
    }
    if (top + panelRect.height > vh - 4) {
      top = Math.max(4, vh - panelRect.height - 4);
    }

    panel.style.left = Math.round(left) + 'px';
    panel.style.top = Math.round(top) + 'px';

    const saveBtn = panel.querySelector('.zqb-save');
    if (saveBtn) {
      saveBtn.onclick = () => {
        const api = panel.querySelector('.zqb-api');
        const holdChat = panel.querySelector('.zqb-holdchat');
        const lockAttack = panel.querySelector('.zqb-lockattack');

        const oldKey = q(getStorage(StorageKey.PrimaryAPIKey));
        const newKey = q(api?.value);

        if (newKey !== oldKey) {
          setStorage(StorageKey.PrimaryAPIKey, newKey);
          setBool(StorageKey.IsPrimaryAPIKeyValid, false);
        } else {
          setStorage(StorageKey.PrimaryAPIKey, newKey);
        }

        setBool(StorageKey.ChatHoldEnabled, !!holdChat?.checked);
        if (lockAttack) setBool(StorageKey.LockAttackPosition, !!lockAttack?.checked);

        applyAttackPosition();
        setStatus('Saved', true);
        clearStatusSoon();
        scanAndAttachChatHold();
        closeGlobalSettings();
      };
    }
  }

  function bindButton(btn, type, root, pageType) {
    if (type === 'SETTINGS') {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const panel = getGlobalPanel();
        if (panel && state.openSettingsFor === root) {
          closeGlobalSettings();
        } else {
          openGlobalSettings(root, pageType);
        }
      });
      return;
    }

    btn.addEventListener('click', async () => {
      closeGlobalSettings();
      if (type === 'C') await prepareC();
      if (type === 'H') await prepareH();
    });
  }

  function injectCSS() {
    if (document.getElementById(APP_ID + '-css')) return;

    const s = document.createElement('style');
    s.id = APP_ID + '-css';
    s.textContent = `
#${APP_ID},
#${APP_ID}-attack-root{
font-family:Arial,sans-serif;
z-index:999999;
}
#${APP_ID} .zqb-box{
display:inline-block;
background:rgba(7,10,14,.86);
border:1px solid rgba(255,255,255,.06);
border-radius:6px;
padding:3px;
box-shadow:0 6px 16px rgba(0,0,0,.22);
backdrop-filter:blur(4px);
}
#${APP_ID} .zqb-dragbar{
display:flex;
gap:4px;
padding:0;
touch-action:none;
}
#${APP_ID} .zqb-btn{
width:16px;
height:22px;
border-radius:2px;
display:flex;
align-items:center;
justify-content:center;
font-size:11px;
font-weight:900;
color:#fff;
border:1px solid rgba(255,255,255,.10);
padding:0;
line-height:1;
box-shadow:none;
margin:0;
}
#${APP_ID} .zqb-armed{
outline:1px solid rgba(255,255,255,.22);
}
#${APP_ID} .zqb-c{background:#e09100;color:#111;}
#${APP_ID} .zqb-h{background:#c41724;}
#${APP_ID} .zqb-s{background:#4a5563;}
#${APP_ID} .zqb-status{
margin-top:2px;
font-size:8px;
color:#9fe0aa;
min-height:9px;
}
.zqb-global-panel{
position:fixed;
z-index:2147483647;
width:180px;
padding:6px;
background:#0d0f14f2;
border:1px solid rgba(255,255,255,.08);
border-radius:6px;
font-size:10px;
color:#dce3ea;
box-shadow:0 8px 20px rgba(0,0,0,.35);
font-family:Arial,sans-serif;
}
.zqb-global-panel .zqb-field{
margin-bottom:5px;
}
.zqb-global-panel .zqb-field label{
display:block;
margin-bottom:2px;
font-size:10px;
}
.zqb-global-panel .zqb-field input[type="password"]{
width:100%;
box-sizing:border-box;
background:#05070c;
color:#fff;
border:1px solid rgba(255,255,255,.08);
border-radius:4px;
padding:5px;
font-size:10px;
}
.zqb-global-panel .zqb-field input[type="checkbox"]{
margin-right:5px;
}
.zqb-global-panel .zqb-save{
display:block;
width:100%;
margin-top:4px;
border:0;
border-radius:4px;
padding:6px;
font-size:10px;
font-weight:700;
text-align:center;
background:#2563eb;
color:#fff;
}
.zqb-global-panel .zqb-hint{
margin-top:5px;
font-size:9px;
line-height:1.3;
color:#b8c1cb;
}
`;
    (document.head || document.documentElement).appendChild(s);
  }

  function createButton(txt, cls, type, root, pageType) {
    const b = document.createElement('button');
    b.className = 'zqb-btn ' + cls;
    b.dataset.type = type;
    b.textContent = txt;
    bindButton(b, type, root, pageType);
    return b;
  }

  function buildInnerUI(pageType) {
    const root = document.createElement('div');
    root.id = APP_ID;

    const box = document.createElement('div');
    box.className = 'zqb-box';

    const dragbar = document.createElement('div');
    dragbar.className = 'zqb-dragbar';

    if (pageType === 'profile') {
      dragbar.appendChild(createButton('C', 'zqb-c', 'C', root, pageType));
    }

    dragbar.appendChild(createButton('H', 'zqb-h', 'H', root, pageType));
    dragbar.appendChild(createButton('•', 'zqb-s', 'SETTINGS', root, pageType));

    const status = document.createElement('div');
    status.className = 'zqb-status';
    status.textContent = 'Ready';

    box.appendChild(dragbar);
    box.appendChild(status);
    root.appendChild(box);

    root.addEventListener('click', (e) => e.stopPropagation());
    root.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });

    return root;
  }

  function escapeHtml(str) {
    return String(str).replace(/[&<>"']/g, (m) => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;'
    }[m]));
  }

  function mountProfile() {
    if (document.getElementById(APP_ID + '-profile-wrap')) return;
    const anchor = findProfileAnchor();
    if (!anchor) return;

    const wrap = document.createElement('div');
    wrap.id = APP_ID + '-profile-wrap';
    wrap.style.position = 'relative';
    wrap.style.margin = '6px 0';

    const ui = buildInnerUI('profile');
    wrap.appendChild(ui);

    if (anchor.classList && anchor.classList.contains('content-title')) {
      anchor.appendChild(wrap);
    } else {
      anchor.insertAdjacentElement('afterend', wrap);
    }
  }

  function mountAttack() {
    if (document.getElementById(APP_ID + '-attack-root')) return;
    const anchor = findAttackAnchor();
    if (!anchor) return;

    const root = document.createElement('div');
    root.id = APP_ID + '-attack-root';
    root.style.position = 'fixed';
    root.style.zIndex = '999999';

    const ui = buildInnerUI('attack');
    root.appendChild(ui);
    document.body.appendChild(root);

    if (!getStorage(StorageKey.AttackPos)) {
      const rect = anchor.getBoundingClientRect();
      setAttackPos(rect.left, rect.bottom + 4);
    }
    applyAttackPosition();

    const dragbar = root.querySelector('.zqb-dragbar');
    if (dragbar) enableAttackDrag(dragbar);
  }

  function unmountAll() {
    document.getElementById(APP_ID + '-profile-wrap')?.remove();
    document.getElementById(APP_ID + '-attack-root')?.remove();
    closeGlobalSettings();
  }

  function mount() {
    injectCSS();
    const page = getPageType();
    if (!page) {
      unmountAll();
      return;
    }

    if (page === 'profile') {
      document.getElementById(APP_ID + '-attack-root')?.remove();
      mountProfile();
    }

    if (page === 'attack') {
      document.getElementById(APP_ID + '-profile-wrap')?.remove();
      mountAttack();
    }

    renderArmedState();
    scanAndAttachChatHold();
  }

  function wireGlobalClose() {
    document.addEventListener('click', () => closeGlobalSettings());
    document.addEventListener('touchstart', () => closeGlobalSettings(), { passive: true });
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') closeGlobalSettings();
    });
    window.addEventListener('resize', () => closeGlobalSettings());
  }

  function loop() {
    setInterval(() => {
      if (location.href !== state.lastHref) {
        state.lastHref = location.href;
        unmountAll();
      }

      mount();
      applyAttackPosition();
      scanAndAttachChatHold();
    }, 300);
  }

  wireGlobalClose();
  loop();
})();