torn-crack

Simple Cracking Helper

Від 21.09.2025. Дивіться остання версія.

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

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

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

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

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

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

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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

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

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

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

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

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

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

// ==UserScript==
// @name         torn-crack
// @namespace    torn-crack
// @version      0.0.9.4
// @description  Simple Cracking Helper
// @author       SirAua [3785905]
// @match        *://www.torn.com/page.php?sid=crimes*
// @grant        GM_xmlhttpRequest
// @connect      gitlab.com
// @connect      supabase.co
// @connect      *.supabase.co
// @license      mit
// ==/UserScript==

// 🔴Clear the Wordlist Cache🔴

(function () {
  'use strict';
  if (window.CRACK_INJECTED) return;
  window.CRACK_INJECTED = true;

  /* --------------------------
     Config
     -------------------------- */
  const debug = false;
  const UPDATE_INTERVAL = 800;
  const MAX_SUG = 8;
  const MIN_LENGTH = 4;
  const MAX_LENGTH = 10;

  const WORDLIST_URL =
    'https://gitlab.com/kalilinux/packages/seclists/-/raw/kali/master/Passwords/Common-Credentials/Pwdb_top-1000000.txt?ref_type=heads';

  const DOWNLOAD_MIN_DELTA = 20;

  const SUPABASE_COUNT_URL =
    'https://mthndavliqfbtaplgfau.supabase.co/functions/v1/get-words/count';
  const SUPABASE_WORDS_URL =
    'https://mthndavliqfbtaplgfau.supabase.co/functions/v1/get-words/words';
  const SUPABASE_ADD_WORD_URL =
    'https://mthndavliqfbtaplgfau.supabase.co/functions/v1/add-word';

  /* --------------------------
      Rate-limiting / batching
      -------------------------- */
  const SYNC_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000;
  const OUTBOX_FLUSH_INTERVAL_MS = 30 * 1000;
  const OUTBOX_POST_INTERVAL_MS = 2000;
  const OUTBOX_BATCH_SIZE = 5;

  const DB_NAME = 'crack';
  const STORE_NAME = 'dictionary';
  const STATUS_PREF_KEY = 'crack_show_badge';
  const EXCL_STORAGE_PREFIX = 'crack_excl_';

  /* --------------------------
     State
     -------------------------- */
  let dict = [];
  let dictLoaded = false;
  let dictLoading = false;
  let supabaseWords = new Set();
  let statusEl = null;
  const prevRowStates = new Map();
  const panelUpdateTimers = new Map();
  const LAST_INPUT = { key: null, time: 0 };

  let outboxFlushTimer = null;
  let lastOutboxPost = 0;

  /* --------------------------
     Utils
     -------------------------- */
  function crackLog(...args) { if (debug) console.log('[Crack]', ...args); }
  function getBoolPref(key, def = true) {
    const v = localStorage.getItem(key); return v === null ? def : v === '1';
  }
  function setBoolPref(key, val) { localStorage.setItem(key, val ? '1' : '0'); }

  function ensureStatusBadge() {
    if (statusEl) return statusEl;
    statusEl = document.createElement('div');
    statusEl.id = '__crack_status';
    statusEl.style.cssText = `
      position: fixed; right: 10px; bottom: 40px; z-index: 10000;
      background:#000; color:#0f0; border:1px solid #0f0; border-radius:6px;
      padding:6px 8px; font-size:11px; font-family:monospace; opacity:0.9;
    `;
    statusEl.textContent = 'Dictionary: Idle';
    document.body.appendChild(statusEl);
    const show = getBoolPref(STATUS_PREF_KEY, true);
    statusEl.style.display = show ? 'block' : 'none';
    return statusEl;
  }
  const __statusSinks = new Set();
  function registerStatusSink(el) { if (el) __statusSinks.add(el); }
  function unregisterStatusSink(el) { if (el) __statusSinks.delete(el); }
  function setStatus(msg) {
    const text = `Dictionary: ${msg}`;
    const badge = ensureStatusBadge();
    if (badge.textContent !== text) badge.textContent = text;
    __statusSinks.forEach(el => { if (el && el.textContent !== text) el.textContent = text; });
    crackLog('STATUS →', msg);
  }

  function gmRequest(opts) {
    return new Promise((resolve, reject) => {
      try {
        const safeOpts = Object.assign({}, opts);
        if (!('responseType' in safeOpts) || !safeOpts.responseType) safeOpts.responseType = 'text';
        safeOpts.headers = Object.assign({ Accept: 'application/json, text/plain, */*; q=0.1' }, safeOpts.headers || {});
        GM_xmlhttpRequest({ ...safeOpts, onload: resolve, onerror: reject, ontimeout: reject });
      } catch (err) { reject(err); }
    });
  }
  function getHeader(headers, name) {
    const re = new RegExp('^' + name + ':\\s*(.*)$', 'mi');
    const m = headers && headers.match ? headers.match(re) : null;
    return m ? m[1].trim() : null;
  }

  /* --------------------------
     Dynamic LZString loader
     -------------------------- */
  let LZ_READY = false;
  function loadLZString(url = 'https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js') {
    return new Promise((resolve, reject) => {
      if (typeof LZString !== 'undefined') { LZ_READY = true; resolve(LZString); return; }
      const script = document.createElement('script');
      script.src = url; script.async = true;
      script.onload = () => {
        if (typeof LZString !== 'undefined') { LZ_READY = true; resolve(LZString); }
        else reject(new Error('LZString failed to load'));
      };
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }

  function compressPayload(obj) {
    try {
      if (!LZ_READY) return { compressed: false, payload: JSON.stringify(obj) };
      const json = JSON.stringify(obj);
      const b64 = LZString.compressToBase64(json);
      return { compressed: true, payload: b64 };
    } catch (e) {
      crackLog('Compression failed', e);
      return { compressed: false, payload: JSON.stringify(obj) };
    }
  }

  /* --------------------------
     IndexedDB
     -------------------------- */
  function openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, 1);
      request.onupgradeneeded = () => {
        const db = request.result;
        if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  async function idbSet(key, value) {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).put(value, key);
      tx.oncomplete = resolve; tx.onerror = () => reject(tx.error);
    });
  }
  async function idbGet(key) {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readonly');
      const req = tx.objectStore(STORE_NAME).get(key);
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }
  async function idbClear() {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).clear();
      tx.oncomplete = resolve; tx.onerror = () => reject(tx.error);
    });
  }
  async function clearLocalDictCache() {
    await idbClear();
    crackLog('Cleared cached dictionary from IndexedDB');
    setStatus('Cleared cache — reload');
  }

  /* --------------------------
     Key capture
     -------------------------- */
  function captureKey(k) {
    if (!k) return;
    const m = String(k).match(/^[A-Za-z0-9._]$/);
    if (!m) return;
    LAST_INPUT.key = k.toUpperCase();
    LAST_INPUT.time = performance.now();
  }
  window.addEventListener('keydown', (e) => {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    captureKey(e.key);
  }, true);

  /* --------------------------
     Dictionary load
     -------------------------- */
  async function commitBucketsToIDB(buckets) {
    for (const lenStr of Object.keys(buckets)) {
      const L = Number(lenStr);
      const newArr = Array.from(buckets[lenStr]);
      let existing = await idbGet(`len_${L}`);
      if (!existing) existing = [];
      const merged = Array.from(new Set([...existing, ...newArr]));
      await idbSet(`len_${L}`, merged);
      dict[L] = merged;
    }
  }

  async function fetchAndIndex(url, onProgress) {
    setStatus('Downloading base wordlist …');
    const res = await gmRequest({ method: 'GET', url, timeout: 45000, responseType: 'text' });
    setStatus('Indexing…');

    const lines = (res.responseText || '').split(/\r?\n/);
    const buckets = {};
    let processed = 0;

    for (const raw of lines) {
      processed++;
      const word = (raw || '').trim().toUpperCase();
      if (!word) continue;
      if (!/^[A-Z0-9_.]+$/.test(word)) continue;
      const L = word.length;
      if (L < MIN_LENGTH || L > MAX_LENGTH) continue;
      if (!buckets[L]) buckets[L] = new Set();
      buckets[L].add(word);

      if (processed % 5000 === 0 && typeof onProgress === 'function') {
        onProgress({ phase: '1M-index', processed, pct: null });
        await new Promise(r => setTimeout(r, 0));
      }
    }

    await commitBucketsToIDB(buckets);

    const perLengthCounts = {};
    for (let L = MIN_LENGTH; L <= MAX_LENGTH; L++) {
      perLengthCounts[L] = (await idbGet(`len_${L}`))?.length || 0;
    }
    setStatus('1M cached');
    return { totalProcessed: processed, perLengthCounts };
  }

  function needReloadAfterBaseLoad() {
    try {
      if (sessionStorage.getItem('__crack_base_reload_done') === '1') return false;
      sessionStorage.setItem('__crack_base_reload_done', '1');
      return true;
    } catch { return true; }
  }

  async function loadDict() {
    if (dictLoaded || dictLoading) return;
    dictLoading = true;
    setStatus('Loading from cache…');

    let hasData = false;
    dict = [];
    for (let len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
      const chunk = await idbGet(`len_${len}`);
      if (chunk && chunk.length) { dict[len] = chunk; hasData = true; }
    }

    if (!hasData) {
      crackLog('No cache found. Downloading dictionary…');
      try {
        await fetchAndIndex(WORDLIST_URL, ({ phase, processed }) => {
          if (phase === '1M-index') setStatus(`Indexing 1M… processed ${processed}`);
        });
        if (needReloadAfterBaseLoad()) {
          setStatus('Dictionary cached — reloading…');
          setTimeout(() => location.reload(), 120);
          return;
        }
      } catch (e) {
        crackLog('Failed to download base wordlist:', e);
      }
    } else {
      crackLog('Dictionary loaded from IndexedDB');
    }

    dictLoaded = true;
    dictLoading = false;
    setStatus('Ready');

    scheduleRemoteSync(5 * 1000);
  }

  /* --------------------------
     Supabase sync
     -------------------------- */
  async function fetchRemoteMeta(force = false) {
    try {
      const lastSync = Number(await idbGet('sb_last_sync_ts')) || 0;
      const now = Date.now();
      if (!force && (now - lastSync) < SYNC_MIN_INTERVAL_MS) {
        crackLog('Skipping fetchRemoteMeta (recent sync)');
        return { count: Number(await idbGet('sb_remote_count')) || 0, etag: await idbGet('sb_remote_etag') || '' };
      }

      crackLog('Performing HEAD to get remote meta');
      const res = await gmRequest({ method: 'HEAD', url: SUPABASE_COUNT_URL, timeout: 10000, headers: { Accept: 'application/json, */*; q=0.1' } });
      const count = Number(getHeader(res.responseHeaders, 'X-Total-Count') || 0);
      const etag = getHeader(res.responseHeaders, 'ETag') || '';
      await idbSet('sb_remote_count', count);
      await idbSet('sb_remote_etag', etag);
      await idbSet('sb_last_sync_ts', Date.now());
      return { count, etag };
    } catch (e) {
      crackLog('fetchRemoteMeta failed:', e);
      return { count: Number(await idbGet('sb_remote_count')) || 0, etag: await idbGet('sb_remote_etag') || '' };
    }
  }

  async function mergeSupabaseIntoCache(words) {
    const byLen = {};
    for (const w of words) {
      if (!/^[A-Z0-9_.]+$/.test(w)) continue;
      const L = w.length;
      if (L < MIN_LENGTH || L > MAX_LENGTH) continue;
      if (!byLen[L]) byLen[L] = new Set();
      byLen[L].add(w);
    }
    let added = 0;
    for (let L = MIN_LENGTH; L <= MAX_LENGTH; L++) {
      const set = byLen[L]; if (!set || set.size === 0) continue;
      let chunk = await idbGet(`len_${L}`); if (!chunk) chunk = [];
      const existing = new Set(chunk);
      let changed = false;
      for (const w of set) {
        if (!existing.has(w)) { existing.add(w); added++; changed = true; }
      }
      if (changed) {
        const merged = Array.from(existing);
        await idbSet(`len_${L}`, merged);
        dict[L] = merged;
      }
    }
    return added;
  }

  async function downloadCommunityWordlist(ifNoneMatchEtag) {
    try {
      const wantCompressed = LZ_READY;
      const url = wantCompressed ? `${SUPABASE_WORDS_URL}?compressed=1` : SUPABASE_WORDS_URL;
      const headers = Object.assign(
        { Accept: 'application/json' },
        ifNoneMatchEtag ? { 'If-None-Match': ifNoneMatchEtag } : {}
      );
      const res = await gmRequest({ method: 'GET', url, headers, timeout: 30000, responseType: 'text' });

      const etag = getHeader(res.responseHeaders, 'ETag') || '';
      const count = Number(getHeader(res.responseHeaders, 'X-Total-Count') || 0);
      if (etag) await idbSet('sb_remote_etag', etag);
      if (count) await idbSet('sb_remote_count', count);

      if (res.status === 304) { crackLog('Community word-list unchanged (304).'); return 0; }
      if (res.status !== 200) { crackLog('Download failed, status:', res.status); return 0; }

      let arr = [];
      try {
        const parsed = JSON.parse(res.responseText || '[]');
        if (parsed && parsed.compressed && typeof parsed.data === 'string') {
          if (!LZ_READY) {
            crackLog('Received compressed payload but LZ not ready; skipping');
            arr = [];
          } else {
            const json = LZString.decompressFromBase64(parsed.data);
            arr = JSON.parse(json || '[]');
          }
        } else if (Array.isArray(parsed)) {
          arr = parsed;
        } else {
          crackLog('Unexpected response shape from /get-words');
          arr = [];
        }
      } catch {
        arr = [];
      }

      const up = arr.map(w => (typeof w === 'string' ? w.toUpperCase() : '')).filter(Boolean);
      supabaseWords = new Set(up);
      const added = await mergeSupabaseIntoCache(up);
      await idbSet('sb_last_downloaded_count', count || up.length);
      await idbSet('sb_last_sync_ts', Date.now());
      return added;
    } catch (e) { crackLog('downloadCommunityWordlist failed:', e); return 0; }
  }

  async function checkRemoteAndMaybeDownload(force = false) {
    const { count: remoteCount, etag } = await fetchRemoteMeta(force);
    const lastDownloaded = (await idbGet('sb_last_downloaded_count')) || 0;
    const delta = Math.max(0, remoteCount - lastDownloaded);

    if (!force && delta < DOWNLOAD_MIN_DELTA) {
      crackLog(`Skip download: delta=${delta} < ${DOWNLOAD_MIN_DELTA}`);
      await idbSet('sb_pending_delta', delta);
      return 0;
    }

    setStatus(force ? 'Manual sync…' : `Syncing (+${delta})…`);
    const added = await downloadCommunityWordlist(etag);
    await idbSet('sb_pending_delta', 0);
    return added;
  }

  function scheduleRemoteSync(delay = 0, force = false) {
    setTimeout(async () => {
      try {
        const added = await checkRemoteAndMaybeDownload(force);
        if (added && added > 0) setStatus(`Ready (+${added})`);
        else setStatus('Ready');
      } catch (e) { crackLog('scheduled sync failed', e); setStatus('Ready'); }
    }, delay);
  }

  /* --------------------------
     Outbox
     -------------------------- */
  async function enqueueOutbox(word) {
    if (!word) return;
    const w = word.toUpperCase();
    let out = await idbGet('sb_outbox') || [];
    if (!out.includes(w)) {
      out.push(w);
      await idbSet('sb_outbox', out);
      crackLog('Enqueued word to outbox:', w);
      ensureOutboxFlushScheduled();
    }
  }

  function ensureOutboxFlushScheduled() {
    if (outboxFlushTimer) return;
    outboxFlushTimer = setTimeout(flushOutbox, OUTBOX_FLUSH_INTERVAL_MS);
  }

  async function flushOutbox() {
    outboxFlushTimer = null;
    let out = await idbGet('sb_outbox') || [];
    if (!out || out.length === 0) return;

    while (out.length > 0) {
      const batch = out.splice(0, OUTBOX_BATCH_SIZE);
      const now = Date.now();
      const sinceLast = now - lastOutboxPost;
      if (sinceLast < OUTBOX_POST_INTERVAL_MS) await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS - sinceLast));

      const compressed = compressPayload({ words: batch });
      const body = compressed.compressed ? { compressed: true, data: compressed.payload } : { words: batch };

      try {
        await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'POST',
            url: SUPABASE_ADD_WORD_URL,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(body),
            onload: (res) => {
              if (res.status >= 200 && res.status < 300) resolve(res);
              else reject(res);
            }, onerror: reject, ontimeout: reject, timeout: 15000
          });
        });
        crackLog('Flushed outbox batch:', batch.length, compressed.compressed ? '(compressed)' : '(raw)');
        for (const w of batch) { supabaseWords.add(w); await addWordToLocalCache(w); }
      } catch (e) {
        crackLog('Batch POST failed, falling back to single POSTs', e);
        for (const w of batch) {
          const b = compressPayload({ word: w });
          const singleBody = b.compressed ? { compressed: true, data: b.payload } : { word: w };
          try {
            await new Promise((resolve, reject) => {
              GM_xmlhttpRequest({
                method: 'POST', url: SUPABASE_ADD_WORD_URL,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(singleBody),
                onload: (r) => (r.status >= 200 && r.status < 300) ? resolve(r) : reject(r),
                onerror: reject, ontimeout: reject, timeout: 10000
              });
            });
            crackLog('Flushed outbox (single):', w, b.compressed ? '(compressed)' : '(raw)');
            supabaseWords.add(w);
            await addWordToLocalCache(w);
            await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS));
          } catch (ee) {
            crackLog('Single POST failed for', w, ee);
            out.unshift(w);
            break;
          }
        }
      }

      lastOutboxPost = Date.now();
      await idbSet('sb_outbox', out);
    }
  }

  /* --------------------------
     Exclusions + suggestions
     -------------------------- */
  function loadExclusions(rowKey, len) {
    const raw = sessionStorage.getItem(EXCL_STORAGE_PREFIX + rowKey + '_' + len);
    let arr = [];
    if (raw) { try { arr = JSON.parse(raw); } catch { } }
    const out = new Array(len);
    for (let i = 0; i < len; i++) {
      const s = Array.isArray(arr[i]) ? arr[i] : (typeof arr[i] === 'string' ? arr[i].split('') : []);
      out[i] = new Set(s.map(c => String(c || '').toUpperCase()).filter(Boolean));
    }
    return out;
  }
  function saveExclusions(rowKey, len, sets) {
    const arr = new Array(len);
    for (let i = 0; i < len; i++) arr[i] = Array.from(sets[i] || new Set());
    sessionStorage.setItem(EXCL_STORAGE_PREFIX + rowKey + '_' + len, JSON.stringify(arr));
  }
  function schedulePanelUpdate(panel) {
    if (!panel) return;
    const key = panel.dataset.rowkey;
    if (panelUpdateTimers.has(key)) clearTimeout(panelUpdateTimers.get(key));
    panelUpdateTimers.set(key, setTimeout(() => {
      panel.updateSuggestions();
      panelUpdateTimers.delete(key);
    }, 50));
  }
  function addExclusion(rowKey, pos, letter, len) {
    letter = String(letter || '').toUpperCase();
    if (!letter) return;
    const sets = loadExclusions(rowKey, len);
    if (!sets[pos]) sets[pos] = new Set();
    const before = sets[pos].size;
    sets[pos].add(letter);
    if (sets[pos].size !== before) {
      saveExclusions(rowKey, len, sets);
      const panel = document.querySelector(`.__crackhelp_panel[data-rowkey="${rowKey}"]`);
      schedulePanelUpdate(panel);
    }
  }

  async function suggest(pattern, rowKey) {
    const len = pattern.length;
    if (len < MIN_LENGTH || len > MAX_LENGTH) return [];
    if (!dict[len]) {
      const chunk = await idbGet(`len_${len}`); if (!chunk) return [];
      dict[len] = chunk;
    }
    const maxCandidates = MAX_SUG * 50;
    const worker = new Worker(URL.createObjectURL(new Blob([`
      self.onmessage = function(e) {
        const { dictChunk, pattern, max } = e.data;
        const regex = new RegExp('^' + pattern.replace(/[*]/g, '.') + '$');
        const out = [];
        for (const word of dictChunk) {
          if (regex.test(word)) out.push(word);
          if (out.length >= max) break;
        }
        self.postMessage(out);
      };
    `], { type: 'application/javascript' })));
    const candidates = await new Promise((resolve) => {
      worker.onmessage = (e) => { worker.terminate(); resolve([...new Set(e.data)]); };
      worker.postMessage({ dictChunk: dict[len], pattern: pattern.toUpperCase(), max: maxCandidates });
    });

    const exSets = loadExclusions(rowKey, len);
    const filtered = candidates.filter(w => {
      for (let i = 0; i < len; i++) {
        const s = exSets[i];
        if (s && s.has(w[i])) return false;
      }
      return true;
    });
    return filtered.slice(0, MAX_SUG);
  }

  function prependPanelToRow(row, pat, rowKey) {
    let panel = row.querySelector('.__crackhelp_panel');

    if (!panel) {
      panel = document.createElement('div');
      panel.className = '__crackhelp_panel';
      panel.dataset.rowkey = rowKey;
      panel.dataset.pattern = pat;
      panel.style.cssText = 'background:#000; font-size:10px; text-align:center; position:absolute; z-index:9999;';

      const listDiv = document.createElement('div');
      listDiv.style.cssText = 'margin-top:2px;';
      panel.appendChild(listDiv);

      panel.updateSuggestions = async function () {
        const curPat = panel.dataset.pattern || '';
        const curRowKey = panel.dataset.rowkey;

        if (!dictLoaded && dictLoading) {
          if (!listDiv.firstChild || listDiv.firstChild.textContent !== '(loading dictionary…)') {
            listDiv.innerHTML = '<span style="padding:2px;color:#ff0;">(loading dictionary…)</span>';
          }
          return;
        }

        const sugs = await suggest(curPat, curRowKey);
        let i = 0;
        for (; i < sugs.length; i++) {
          let sp = listDiv.children[i];
          if (!sp) { sp = document.createElement('span'); sp.style.cssText = 'padding:2px;color:#0f0;'; listDiv.appendChild(sp); }
          if (sp.textContent !== sugs[i]) sp.textContent = sugs[i];
          if (sp.style.color !== 'rgb(0, 255, 0)' && sp.style.color !== '#0f0') sp.style.color = '#0f0';
        }
        while (listDiv.children.length > sugs.length) listDiv.removeChild(listDiv.lastChild);

        if (sugs.length === 0) {
          if (!listDiv.firstChild) {
            const sp = document.createElement('span');
            sp.textContent = dictLoaded ? '(no matches)' : '(loading dictionary…)';
            sp.style.color = dictLoaded ? '#a00' : '#ff0';
            listDiv.appendChild(sp);
          } else {
            const sp = listDiv.firstChild;
            const txt = dictLoaded ? '(no matches)' : '(loading dictionary…)';
            if (sp.textContent !== txt) sp.textContent = txt;
            sp.style.color = dictLoaded ? '#a00' : '#ff0';
          }
        }
      };

      row.prepend(panel);
    } else {
      panel.dataset.pattern = pat;
    }
    schedulePanelUpdate(panel);
    return panel;
  }

  async function isWordInLocalDict(word) {
    const len = word.length;
    if (!dict[len]) {
      const chunk = await idbGet(`len_${len}`); if (!chunk) return false;
      dict[len] = chunk;
    }
    return dict[len].includes(word);
  }
  async function addWordToLocalCache(word) {
    const len = word.length;
    if (len < MIN_LENGTH || len > MAX_LENGTH) return;
    let chunk = await idbGet(`len_${len}`); if (!chunk) chunk = [];
    if (!chunk.includes(word)) {
      chunk.push(word); await idbSet(`len_${len}`, chunk);
      if (!dict[len]) dict[len] = [];
      if (!dict[len].includes(word)) dict[len].push(word);
      crackLog('Added to local cache:', word);
    }
  }

  function getRowKey(crimeOption, idx) {
    if (!crimeOption.dataset.crackKey) crimeOption.dataset.crackKey = String(idx ?? 0);
    return crimeOption.dataset.crackKey;
  }

  function attachSlotSensors(crimeOption, rowKey) {
    if (crimeOption.dataset.crackDelegated === '1') return;
    crimeOption.dataset.crackDelegated = '1';

    const slotSelector = '[class^="charSlot"]:not([class*="charSlotDummy"])';
    const badLineSelector = '[class*="incorrectGuessLine"]';

    const onVisualCue = (ev) => {
      const t = ev.target;
      const slot = t.closest && t.closest(slotSelector);
      if (!slot || !crimeOption.contains(slot)) return;

      const slots = crimeOption.querySelectorAll(slotSelector);
      const i = Array.prototype.indexOf.call(slots, slot);
      if (i < 0) return;
      if (getComputedStyle(slot).borderColor === 'rgb(130, 201, 30)') return;

      const now = performance.now();
      const shown = (slot.textContent || '').trim();
      if (shown && /^[A-Za-z0-9._]$/.test(shown)) return;

      const prev = prevRowStates.get(rowKey) || null;
      const hasRowLastInput = !!(prev && prev.lastInput && (now - prev.lastInput.time) <= 1800 && prev.lastInput.i === i);
      const isIncorrectLineEvent = t.matches && t.matches(badLineSelector);
      const freshGlobal = (now - (LAST_INPUT.time || 0)) <= 1800;

      let letter = null;
      if (hasRowLastInput) letter = prev.lastInput.letter;
      else if (isIncorrectLineEvent && freshGlobal && LAST_INPUT.key) letter = LAST_INPUT.key.toUpperCase();
      else return;

      if (!/^[A-Za-z0-9._]$/.test(letter)) return;

      const len = slots.length;
      addExclusion(rowKey, i, letter, len);

      const panel = document.querySelector(`.__crackhelp_panel[data-rowkey="${rowKey}"]`);
      if (panel && panel.updateSuggestions) schedulePanelUpdate(panel);
    };

    crimeOption.addEventListener('animationstart', onVisualCue, true);
    crimeOption.addEventListener('transitionend', onVisualCue, true);
  }

  function scanCrimePage() {
    if (!location.href.endsWith('cracking')) return;

    const currentCrime = document.querySelector('[class^="currentCrime"]');
    if (!currentCrime) return;

    const container = currentCrime.querySelector('[class^="virtualList"]');
    if (!container) return;

    const crimeOptions = container.querySelectorAll('[class^="crimeOptionWrapper"]');

    for (const crimeOption of crimeOptions) {
      let patText = '';
      const rowKey = getRowKey(crimeOption, [...crimeOptions].indexOf(crimeOption));
      attachSlotSensors(crimeOption, rowKey);

      const charSlots = crimeOption.querySelectorAll('[class^="charSlot"]:not([class*="charSlotDummy"])');
      const curChars = [];
      for (const charSlot of charSlots) {
        let ch = (charSlot.textContent || '').trim().toUpperCase();
        curChars.push(ch ? ch : '*');
      }
      patText = curChars.join('');

      const now = performance.now();
      const len = curChars.length;

      const prev = prevRowStates.get(rowKey) || { chars: Array(len).fill('*') };

      for (let i = 0; i < len; i++) {
        const was = prev.chars[i];
        const is = curChars[i];
        if (was === '*' && is !== '*') prev.lastInput = { i, letter: is, time: now };
        if (was !== '*' && is === '*') {
          if (prev.lastInput && prev.lastInput.i === i && prev.lastInput.letter === was && (now - prev.lastInput.time) <= 1800) {
            addExclusion(rowKey, i, was, len);
          }
        }
      }
      prevRowStates.set(rowKey, { chars: curChars, lastInput: prev.lastInput, time: now });

      if (!/[*]/.test(patText)) {
        const newWord = patText.toUpperCase();
        if (!/^[A-Z0-9_.]+$/.test(newWord)) {
          crackLog('Revealed word contains invalid chars. skippin:', newWord);
        } else {
          (async () => {
            const localHas = await isWordInLocalDict(newWord);
            const supHas = supabaseWords.has(newWord);
            if (!localHas && !supHas) {
              await addWordToLocalCache(newWord);
              await enqueueOutbox(newWord);
            } else if (supHas && !localHas) {
              await addWordToLocalCache(newWord);
            }
          })();
        }
      }

      if (!/^[*]+$/.test(patText)) prependPanelToRow(crimeOption, patText, rowKey);
    }
  }

  /* --------------------------
     Settings UI
     -------------------------- */
  async function showMenuOverlay() {
    const overlay = document.createElement('div');
    overlay.style.cssText = `
      position: fixed; top: 0; left: 0; width: 100%; height: 100%;
      background: rgba(0,0,0,0.7); color: #fff;
      display: flex; align-items: center; justify-content: center;
      z-index: 10000; font-size: 14px;
    `;
    const box = document.createElement('div');
    box.style.cssText = `
      background: #111; padding: 20px; border: 1px solid #0f0;
      border-radius: 6px; text-align: center; min-width: 360px;
    `;
    box.innerHTML = `<div style="margin-bottom:12px; font-size:20px; color:#0f0;">Settings</div>`;

    const statusLine = document.createElement('div');
    statusLine.style.cssText = 'color:#0f0; font-size:12px; margin-bottom:8px;';
    statusLine.textContent = ensureStatusBadge().textContent;
    registerStatusSink(statusLine);
    box.appendChild(statusLine);

    const wordCountDiv = document.createElement('div');
    wordCountDiv.style.cssText = 'color:#0f0; font-size:12px; margin-bottom:10px;';
    wordCountDiv.textContent = 'Loading dictionary stats...';
    box.appendChild(wordCountDiv);

    (async () => {
      let stats = [];
      for (let len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
        const chunk = await idbGet(`len_${len}`); stats.push(`${len}: ${chunk ? chunk.length : 0}`);
      }
      const remoteCount = await idbGet('sb_remote_count');
      const delta = await idbGet('sb_pending_delta');
      wordCountDiv.textContent =
        `Stored per length → ${stats.join(' | ')}  |  Remote cracked: ${remoteCount ?? 'n/a'}${delta ? ` ( +${delta} pending )` : ''}`;
    })();

    const badgeRow = document.createElement('div');
    badgeRow.style.cssText = 'margin:8px 0; font-size:12px; color:#0f0; display:flex; align-items:center; justify-content:center; gap:8px;';
    const badgeLabel = document.createElement('label');
    badgeLabel.style.cssText = 'cursor:pointer; display:flex; align-items:center; gap:6px;';
    const badgeChk = document.createElement('input');
    badgeChk.type = 'checkbox';
    badgeChk.checked = getBoolPref(STATUS_PREF_KEY, true);
    badgeChk.onchange = () => {
      const show = badgeChk.checked;
      setBoolPref(STATUS_PREF_KEY, show);
      ensureStatusBadge().style.display = show ? 'block' : 'none';
    };
    const badgeText = document.createElement('span'); badgeText.textContent = 'Show status badge';
    badgeLabel.appendChild(badgeChk); badgeLabel.appendChild(badgeText);
    badgeRow.appendChild(badgeLabel);
    box.appendChild(badgeRow);

    const syncBtn = document.createElement('button');
    syncBtn.textContent = 'Sync community words now';
    syncBtn.style.cssText = 'margin:4px; padding:6px 10px; background:#1f6feb; color:#fff; cursor:pointer; border-radius:4px;';
    syncBtn.onclick = async () => {
      syncBtn.disabled = true;
      const added = await checkRemoteAndMaybeDownload(true);
      const remoteCount = await idbGet('sb_remote_count');
      const delta = await idbGet('sb_pending_delta');
      setStatus(added ? `Ready (+${added}, remote: ${remoteCount})` : `Ready (remote ${remoteCount}${delta ? `, +${delta} pending` : ''})`);
      let stats = [];
      for (let len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
        const chunk = await idbGet(`len_${len}`); stats.push(`${len}: ${chunk ? chunk.length : 0}`);
      }
      wordCountDiv.textContent = `Stored per length → ${stats.join(' | ')}  |  Remote cracked: ${remoteCount ?? 'n/a'}`;
      syncBtn.disabled = false;
    };
    box.appendChild(syncBtn);

    const btnCache = document.createElement('button');
    btnCache.textContent = 'Clear Wordlist Cache';
    btnCache.style.cssText = 'margin:4px; padding:6px 10px; background:#a00; color:#fff; cursor:pointer; border-radius:4px;';
    btnCache.onclick = async () => { await clearLocalDictCache(); location.reload(); };
    box.appendChild(btnCache);

    const cancelBtn = document.createElement('button');
    cancelBtn.textContent = 'Close';
    cancelBtn.style.cssText = 'margin:4px; padding:6px 10px; background:#222; color:#fff; cursor:pointer; border-radius:4px;';
    cancelBtn.onclick = () => {
      unregisterStatusSink(statusLine);
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
    };
    box.appendChild(cancelBtn);

    const line = document.createElement('hr');
    line.style.cssText = 'border:none; border-top:1px solid #0f0; margin:10px 0;';
    box.appendChild(line);

    const pwrdByMsg = document.createElement('div');
    pwrdByMsg.style.cssText = 'color:#0f0; font-size:12px; margin-bottom:10px;';
    pwrdByMsg.textContent = 'Powered by Supabase / IndexedDB - Made with Love ❤ by SirAua [3785905] (and friends)';
    box.appendChild(pwrdByMsg);

    const psMsg = document.createElement('div');
    psMsg.style.cssText = 'color:#0f0; font-size:10px; margin-bottom:10px;';
    psMsg.textContent = 'PS: Clear cache sometimes.';
    box.appendChild(psMsg);

    overlay.appendChild(box);
    document.body.appendChild(overlay);
  }

  function injectMenuButton() {
    if (!location.href.endsWith('cracking')) return;
    if (document.getElementById('__crack_menu_btn')) return;
    const appHeader = document.querySelector('[class^="appHeaderDelimiter"]');
    if (!appHeader) return;
    const btn = document.createElement('button');
    btn.id = '__crack_menu_btn';
    btn.textContent = 'Bruteforce characters to show suggestions! (Click for settings)';
    btn.style.cssText = 'background:#000; color:#0f0; font-size:10px; text-align:left; z-index:9999; cursor:pointer;';
    btn.onclick = showMenuOverlay;
    appHeader.appendChild(btn);
    ensureStatusBadge();
  }

  /* --------------------------
     Init
     -------------------------- */
  (async function init() {
    ensureStatusBadge();
    try { if (sessionStorage.getItem('__crack_base_reload_done') === '1') sessionStorage.removeItem('__crack_base_reload_done'); } catch { }
    setStatus('Initializing…');

    try {
      await loadLZString();
      crackLog('LZString ready:', typeof LZString !== 'undefined');
    } catch (e) {
      crackLog('Failed to load LZString, compression disabled', e);
    }

    loadDict();
    scanCrimePage();
    setInterval(scanCrimePage, UPDATE_INTERVAL);
    setInterval(injectMenuButton, UPDATE_INTERVAL);
  })();
})();