torn-crack

Simple Cracking Helper

// ==UserScript==
// @name         torn-crack
// @namespace    torn-crack
// @version      0.9.5
// @description  Simple Cracking Helper
// @author       SirAua [3785905]
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @match        *://www.torn.com/page.php?sid=crimes*
// @grant        GM_xmlhttpRequest
// @connect      gitlab.com
// @connect      supabase.co
// @connect      *.supabase.co
// @license      mit
// ==/UserScript==

(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;
  }

  function formatShortDuration(ms) {
    if (ms <= 0) return 'now';
    const s = Math.floor(ms / 1000);
    const d = Math.floor(s / 86400);
    const h = Math.floor((s % 86400) / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = s % 60;
    if (d > 0) return `${d}d ${h}h ${m}m`;
    if (h > 0) return `${h}h ${m}m ${sec}s`;
    if (m > 0) return `${m}m ${sec}s`;
    return `${sec}s`;
  }

  async function setLastDownloadedId(id) {
    if (id == null) return;
    await idbSet('sb_last_id', Number(id));
    crackLog('Stored sb_last_id =', id);
  }

  /* --------------------------
     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');
  }

  /* --------------------------
     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 lastId = Number(await idbGet('sb_last_id')) || 0;
      const wantCompressed = LZ_READY;
      const afterParam = lastId ? `&after_id=${encodeURIComponent(String(lastId))}` : '';
      const url = wantCompressed
        ? `${SUPABASE_WORDS_URL}?compressed=1${afterParam}`
        : `${SUPABASE_WORDS_URL}${afterParam}`;

      const headers = Object.assign(
        { Accept: 'application/json' },
        ifNoneMatchEtag ? { 'If-None-Match': ifNoneMatchEtag } : {}
      );

      crackLog('Downloading community wordlist ->', url);
      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);
      const headerLastId = getHeader(res.responseHeaders, 'X-Last-Id') || '';

      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 = [];
      let gotLastId = null;

      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);
            const inner = JSON.parse(json || '[]');
            if (Array.isArray(inner)) {
              arr = inner;
            } else if (inner && Array.isArray(inner.words)) {
              arr = inner.words;
              if (inner.lastId != null) gotLastId = Number(inner.lastId);
            } else {
              crackLog('Compressed payload had unexpected inner shape');
              arr = [];
            }
          }
        }
        else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.words)) {
          arr = parsed.words;
          if (parsed.lastId != null) gotLastId = Number(parsed.lastId);
        }
        else if (Array.isArray(parsed)) {
          arr = parsed;
        } else {
          crackLog('Unexpected response shape from /get-words', parsed);
          arr = [];
        }
      } catch (e) {
        crackLog('Failed to parse community wordlist response', e);
        arr = [];
      }

      if (headerLastId) {
        gotLastId = Number(headerLastId);
      }

      const up = arr.map(w => (typeof w === 'string' ? w.toUpperCase() : '')).filter(Boolean);
      supabaseWords = new Set([...supabaseWords, ...up]);
      const added = await mergeSupabaseIntoCache(up);

      await idbSet('sb_last_downloaded_count', count || (await idbGet('sb_last_downloaded_count')) || up.length);
      await idbSet('sb_last_sync_ts', Date.now());
      if (gotLastId) {
        await setLastDownloadedId(gotLastId);
      } else {
        if (!lastId && up.length > 0) {
          try {
            if (count && Number.isFinite(count)) {
              await setLastDownloadedId(count);
            }
          } catch (e) { /* ignore */ }
        }
      }

      crackLog(`downloadCommunityWordlist: merged ${up.length} words, added ${added}${gotLastId ? ', lastId=' + gotLastId : ''}`);
      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;
  }

  let autoSyncTimer = null;
  let autoSyncInFlight = false;

  async function msUntilEligibleSync() {
    const last = Number(await idbGet('sb_last_sync_ts')) || 0;
    const remain = last + SYNC_MIN_INTERVAL_MS - Date.now();
    return Math.max(0, remain);
  }

  function startAutoSyncHeartbeat() {
    if (autoSyncTimer) return;
    autoSyncTimer = setInterval(async () => {
      if (autoSyncInFlight) return;
      try {
        const remain = await msUntilEligibleSync();
        if (remain > 0) return;

        autoSyncInFlight = true;
        setStatus('Auto-syncing community words…');

        const added = await checkRemoteAndMaybeDownload(false);

        const remoteCount = await idbGet('sb_remote_count');
        const delta = await idbGet('sb_pending_delta');
        if (added && added > 0) {
          setStatus(`Ready (+${added}, remote: ${remoteCount})`);
        } else {
          setStatus(`Ready (remote ${remoteCount}${delta ? `, +${delta} pending` : ''})`);
        }
      } catch (e) {
        crackLog('Auto-sync failed', e);
        setStatus('Ready');
      } finally {
        autoSyncInFlight = false;
      }
    }, 1000);
  }

  /* --------------------------
     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) {
    if (!crimeOption.dataset.crackKey) {
      crimeOption.dataset.crackKey = String(Date.now()) + '-' + Math.floor(Math.random() * 100000);
    }
    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);
      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 nextSyncDiv = document.createElement('div');
    nextSyncDiv.style.cssText = 'color:#0f0; font-size:12px; margin-bottom:10px;';
    nextSyncDiv.textContent = 'Calculating next sync time…';
    box.appendChild(nextSyncDiv);

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

    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 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 (ticker) clearInterval(ticker);
      if (statsTimer) clearInterval(statsTimer);
      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);

    let stats = [];
    (async () => {
      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 )` : ''}`;
    })();

    let ticker = null;
    let statsTimer = null;

    const updateNextSync = async () => {
      const lastSyncTs = Number(await idbGet('sb_last_sync_ts')) || 0;
      const nextAllowed = lastSyncTs + SYNC_MIN_INTERVAL_MS;
      const remaining = nextAllowed - Date.now();
      const eligible = remaining <= 0;
      const delta = Number(await idbGet('sb_pending_delta')) || 0;
      nextSyncDiv.textContent = eligible
        ? `Next sync: now${delta ? ` ( +${delta} pending )` : ''}`
        : `Next sync in ${formatShortDuration(remaining)}${delta ? ` ( +${delta} pending )` : ''}`;
    };

    const refreshRemoteStats = async () => {
      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 )` : ''}`;
    };

    await updateNextSync();
    ticker = setInterval(updateNextSync, 1000);
    statsTimer = setInterval(refreshRemoteStats, 15000);
  }

  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);

    startAutoSyncHeartbeat();
  })();
})();