Black Diamond Chain Queue

Black Diamond faction chain queue — bid-only numbered board, auto-tracks real hits from the faction attacks API. Works in Tampermonkey (desktop) and Torn PDA (mobile). Clobber-free append-only sync via Firebase (free).

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==UserScript==
// @name         Black Diamond Chain Queue
// @namespace    black-diamond-chain-queue
// @version      3.7.0
// @description  Black Diamond faction chain queue — bid-only numbered board, auto-tracks real hits from the faction attacks API. Works in Tampermonkey (desktop) and Torn PDA (mobile). Clobber-free append-only sync via Firebase (free).
// @author       Black Diamond Corp
// @license      MIT
// @match        https://www.torn.com/*
// @match        https://torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      firebaseio.com
// @connect      firebasedatabase.app
// @connect      api.torn.com
// @run-at       document-idle
// @noframes
// ==/UserScript==

/*
 * ---------------------------------------------------------------------------
 * HOW IT STAYS IN SYNC (the important part)
 * ---------------------------------------------------------------------------
 * The queue is NOT stored as one big array that everyone overwrites. That is
 * what caused "two people at the same second clobber each other."
 *
 * Instead this is an APPEND-ONLY EVENT LOG in a free Firebase Realtime DB.
 * Every action (join, hit, skip, bid, remove, reset) is POSTed, so Firebase
 * assigns each one its OWN unique push key -> no two writers ever collide ->
 * no lost writes. Each browser GETs the whole log once and replays it in time
 * order to compute the current queue. Old events are pruned after a few hours.
 *
 * Bidding is resolved with a DETERMINISTIC seeded RNG (seed = bid id + the
 * sorted list of bidders). Every browser feeds the same seed into the same
 * PRNG and therefore picks the SAME random winner -- no server, no "who gets
 * to decide the winner" race.
 * ---------------------------------------------------------------------------
 */

(function () {
  'use strict';

  // ======================= CONFIG / CONSTANTS ==============================
  const DB_EVENTS = 'queue/events'; // Realtime DB path holding the append-only log
  const EVENT_TTL_SEC = 6 * 3600;   // prune events older than 6h (Firebase has no per-key TTL)
  const POLL_IDLE_MS = 8000;        // relaxed polling when nothing is happening
  const POLL_ACTIVE_MS = 3000;      // tight polling when a bid is live / you're near front
  const BID_WINDOW_MS = 15000;      // how long a bid stays open for others to join
  const BID_SETTLE_MS = 3000;       // grace after close so late joins propagate before we lock the winner
  const AFK_BUMP_MS = 246000;       // 4.1 min: no hit by now -> anyone can bump (chain drops at 5m)
  const ATTACK_WARN_MS = 50000;     // when it's your turn and chain drop timer <= this, flash ATTACK NOW
  const CHAIN_TIMEOUT_MS = 300000;  // Torn's chain timer resets to 5:00 on each hit
  const CHAIN_REFRESH_MS = 25000;   // how often a capable key re-reads the live chain
  const CHAINSTATE_TTL = 150;       // chainstate keys expire fast (they go stale quickly)
  const CHAINSTATE_RELAY_MS = 60000;// re-relay at least this often so the cap never expires mid-chain

  // ---- Torn PDA (mobile app) compatibility ----
  const IS_PDA = typeof window !== 'undefined' && !!(window.flutter_inappwebview && window.flutter_inappwebview.callHandler);
  const PDA_APIKEY = '###PDA-APIKEY###';                 // PDA replaces this at runtime
  function pdaKey() { return PDA_APIKEY.indexOf('PDA-APIKEY') === -1 ? PDA_APIKEY : ''; }

  // ============================ LOCAL STATE ================================
  let cfg = loadCfg();              // { name, bucket, email }
  let events = [];                  // raw event objects fetched from the bucket
  let lastRenderKey = '';           // avoid redundant DOM churn
  let pollTimer = null;
  let syncing = false;
  let lastError = '';
  let lastChainFetch = 0;           // throttle live-chain reads
  let chainRetryAt = 0;             // back off (not give up) if a chain read fails
  let lastChainCurrent = 0;         // to detect the chain dropping (auto-reset the board)
  let lastBeepTurn = 0;             // so the attack alert beeps once per turn, not every second
  let audioCtx = null;
  let clockOffset = 0;              // Date.now()+this ≈ shared DB clock (fixes cross-PC skew)
  let lastClockSync = 0;
  let pending = [];                 // optimistic events kept until the server confirms them
  let attacksRetryAt = 0;           // back off if the attacks feed isn't readable by this key
  let lastAttackFetch = 0;
  let lastAttackTs = 0;             // newest attack timestamp seen (seconds)
  const processedAtk = new Set();   // attack ids already relayed this session
  let settingsOpen = false;         // freeze the 1s auto-refresh while editing settings
  let collapsed = stLoad('tcq_collapsed', false); // remembered collapse state
  let muted = stLoad('tcq_muted', false);         // remembered mute state (alert beeps)
  let lastUpBeep = 0;                             // beep once when it becomes your turn

  // ========================= CONFIG PERSISTENCE ============================
  // GM storage works in Tampermonkey; on Torn PDA it's unreliable, so fall back
  // to localStorage. Try GM first, then localStorage.
  function stStore(k, v) {
    try { GM_setValue(k, v); } catch (e) { /* no GM (e.g. PDA) */ }
    try { localStorage.setItem('tcq:' + k, JSON.stringify(v)); } catch (e) {}
  }
  function stLoad(k, d) {
    try { const g = GM_getValue(k, undefined); if (typeof g !== 'undefined') return g; } catch (e) {}
    try { const s = localStorage.getItem('tcq:' + k); if (s !== null) return JSON.parse(s); } catch (e) {}
    return d;
  }
  function loadCfg() {
    return {
      name: stLoad('tcq_name', ''),
      dbUrl: stLoad('tcq_dburl', ''),
      apiKey: stLoad('tcq_apikey', ''),
      faction: stLoad('tcq_faction', ''),
      ffa: Number(stLoad('tcq_ffa', 10)) || 10, // free-for-all until this chain number
      target: Number(stLoad('tcq_target', 0)) || 0, // hard chain cap (0 = follow Torn)
      isLeader: !!stLoad('tcq_leader', false), // can remove anyone + reset
    };
  }
  function saveCfg(next) {
    cfg = Object.assign({}, cfg, next);
    stStore('tcq_name', cfg.name || '');
    stStore('tcq_dburl', cfg.dbUrl || '');
    stStore('tcq_apikey', cfg.apiKey || '');
    stStore('tcq_faction', cfg.faction || '');
    stStore('tcq_ffa', cfg.ffa || 10);
    stStore('tcq_target', cfg.target || 0);
    stStore('tcq_leader', !!cfg.isLeader);
  }
  // effective API key: your saved key, or the one Torn PDA injects
  function apiKey() { return cfg.apiKey || pdaKey(); }
  const configured = () => cfg.name && cfg.dbUrl;
  function dbBase() { return String(cfg.dbUrl || '').replace(/\/+$/, ''); }
  function normalizeDbUrl(u) {
    u = String(u || '').trim();
    if (u && !/^https?:\/\//.test(u)) u = 'https://' + u;
    return u.replace(/\/+$/, '');
  }

  // ============================== HTTP =====================================
  // One request helper for both platforms: Torn PDA's CSP-free bridge on mobile,
  // GM_xmlhttpRequest on desktop. Both return the response text.
  function gmReq(method, url, body, headers) {
    if (IS_PDA) {
      const h = headers || {};
      const map = { GET: 'PDA_httpGet', DELETE: 'PDA_httpDelete', POST: 'PDA_httpPost', PUT: 'PDA_httpPut', PATCH: 'PDA_httpPatch' };
      const ready = Promise.resolve(typeof window.__PDA_platformReadyPromise !== 'undefined' ? window.__PDA_platformReadyPromise : null);
      return ready.then(() => (method === 'GET' || method === 'DELETE')
        ? window.flutter_inappwebview.callHandler(map[method], url, h)
        : window.flutter_inappwebview.callHandler(map[method], url, h, body))
        .then((r) => {
          const status = r && r.status;
          if (status >= 200 && status < 300) return r.responseText;
          throw new Error('HTTP ' + status + ': ' + ((r && r.responseText) || '').slice(0, 200));
        });
    }
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method,
        url,
        data: body,
        headers: headers || {},
        onload: (r) => {
          if (r.status >= 200 && r.status < 300) resolve(r.responseText);
          else reject(new Error('HTTP ' + r.status + ': ' + (r.responseText || '').slice(0, 200)));
        },
        onerror: () => reject(new Error('network error')),
        ontimeout: () => reject(new Error('timeout')),
        timeout: 15000,
      });
    });
  }

  // Align this browser to the shared DB clock so timers + event ordering match
  // across everyone's PCs, even if someone's system clock is wrong.
  async function syncClock() {
    if (!configured()) return;
    try {
      const send = Date.now();
      let txt = await gmReq('PUT', dbBase() + '/queue/_t.json', JSON.stringify({ '.sv': 'timestamp' }), { 'Content-Type': 'application/json' });
      const recv = Date.now();
      let server = Number(JSON.parse(txt));
      if (!server) { const g = await gmReq('GET', dbBase() + '/queue/_t.json', null, {}); server = Number(JSON.parse(g)); }
      if (server > 0) clockOffset = server - (send + (recv - send) / 2);
    } catch (e) { /* keep previous offset */ }
  }

  // Verify a Firebase database URL is reachable and read/writable by this script.
  function testConnection(url) {
    const base = normalizeDbUrl(url);
    if (!/^https:\/\/.+\.(firebaseio\.com|firebasedatabase\.app)$/.test(base)) {
      return Promise.reject(new Error('that doesn\'t look like a Firebase database URL'));
    }
    return gmReq('GET', base + '/' + DB_EVENTS + '.json?shallow=true', null, {}).then((txt) => {
      if (/permission_denied|"error"/i.test(txt || '')) throw new Error('database rules are blocking access (set them public)');
      return true;
    });
  }

  // Look up the player's own identity from their Public API key.
  // SECURITY: the key is sent ONLY to api.torn.com (never to the shared bucket),
  // and stored locally on this browser via GM storage.
  function tornUser(key) {
    const url = 'https://api.torn.com/user/?selections=profile&key=' +
      encodeURIComponent(key) + '&comment=ChainQueue';
    return gmReq('GET', url, null, {}).then((txt) => {
      let j;
      try { j = JSON.parse(txt); } catch (e) { throw new Error('unexpected API response'); }
      if (j.error) throw new Error(j.error.error || ('API error ' + j.error.code));
      if (!j.name) throw new Error('no name returned — is this a Public key?');
      return {
        name: j.name,
        id: j.player_id,
        level: j.level,
        faction: (j.faction && j.faction.faction_name) || '',
        position: (j.faction && j.faction.position) || '',
      };
    });
  }

  // Read the faction's live chain (current count + unlocked max) from the API.
  // May require a key with faction API access; callers handle failure gracefully.
  function factionChain(key) {
    const url = 'https://api.torn.com/faction/?selections=chain&key=' +
      encodeURIComponent(key) + '&comment=ChainQueue';
    return gmReq('GET', url, null, {}).then((txt) => {
      let j;
      try { j = JSON.parse(txt); } catch (e) { throw new Error('unexpected chain response'); }
      if (j.error) throw new Error(j.error.error || ('API error ' + j.error.code));
      const c = j.chain || {};
      return {
        current: Number(c.current) || 0,
        max: Number(c.max) || 0,
        timeout: Number(c.timeout) || 0, // seconds until the chain drops
      };
    });
  }

  // Newest chain state seen in the shared log (relayed by whoever can read it).
  function latestChain(evList) {
    let best = null;
    for (const e of evList) {
      if (e.k === 'chainstate' && (!best || e.t > best.t)) {
        best = {
          t: e.t, current: Number(e.p.cur) || 0, max: Number(e.p.max) || 0,
          to: Number(e.p.to) || 0, ffa: Number(e.p.ffa) || 0, tgt: Number(e.p.tgt) || 0,
        };
      }
    }
    return best;
  }

  // Relay chain state into the shared log so every member (even those whose key
  // can't read the chain) gets the live cap. Just another append-only event.
  function putChainState(ch) {
    const evt = mkEvent('chainstate', { cur: ch.current, max: ch.max, to: ch.timeout, ffa: cfg.ffa || 10, tgt: cfg.target || 0 });
    evt._at = Date.now(); pending.push(evt); events.push(evt);
    return putEvent(evt);
  }

  async function maybeRefreshChain() {
    if (!apiKey() || now() < chainRetryAt) return;
    // when I'm up next, read/relay more often so the board stays fresh
    let myTurn = false;
    try { const s = computeState(events, now()); myTurn = s.claims[s.current + 1] === cfg.name; } catch (e) {}
    const fetchEvery = myTurn ? 8000 : CHAIN_REFRESH_MS;
    const relayEvery = myTurn ? 10000 : CHAINSTATE_RELAY_MS;
    if (now() - lastChainFetch < fetchEvery) return;
    lastChainFetch = now();
    try {
      const ch = await factionChain(apiKey());
      if (ch.max > 0) {
        // chain dropped / a new chain started -> clear the board once, automatically
        if (lastChainCurrent > 1 && ch.current < lastChainCurrent - 1) {
          const rst = mkEvent('reset'); rst._at = Date.now(); pending.push(rst); events.push(rst);
          await putEvent(rst);
        }
        lastChainCurrent = ch.current;
        const prev = latestChain(events);
        const changed = !prev || prev.current !== ch.current || prev.max !== ch.max;
        const stale = prev && (now() - prev.t > relayEvery);
        if (changed || stale) await putChainState(ch);
      }
    } catch (e) {
      chainRetryAt = now() + 60000; // back off a minute, then try again (don't give up)
    }
  }

  // Read the faction's recent attacks (needs a Limited key + faction API access).
  function factionAttacks(key, fromSec) {
    const url = 'https://api.torn.com/faction/?selections=attacks&key=' + encodeURIComponent(key) +
      (fromSec ? ('&from=' + fromSec) : '') + '&comment=ChainQueue';
    return gmReq('GET', url, null, {}).then((txt) => {
      let j;
      try { j = JSON.parse(txt); } catch (e) { throw new Error('unexpected attacks response'); }
      if (j.error) throw new Error(j.error.error || ('API error ' + j.error.code));
      return j.attacks || {};
    });
  }

  // Whoever can read the attacks feed relays each faction chain-hit into the log
  // as an 'autohit' (removes the attacker from the queue). Everyone else rides it.
  async function maybeRelayAttacks() {
    if (!apiKey() || now() < attacksRetryAt) return;
    if (now() - lastAttackFetch < 8000) return;
    lastAttackFetch = now();
    const fromSec = lastAttackTs ? (lastAttackTs - 5) : (Math.floor(now() / 1000) - 300);
    try {
      const atks = await factionAttacks(apiKey(), fromSec);
      const known = new Set(events.filter((e) => e.k === 'autohit').map((e) => e.p.atk));
      const ids = Object.keys(atks).sort((a, b) => (Number(atks[a].timestamp_ended) || 0) - (Number(atks[b].timestamp_ended) || 0));
      for (const atkid of ids) {
        const at = atks[atkid];
        const ended = Number(at.timestamp_ended) || 0;
        if (ended > lastAttackTs) lastAttackTs = ended;
        if (processedAtk.has(atkid)) continue;
        processedAtk.add(atkid);
        const chain = Number(at.chain) || 0;
        const who = at.attacker_name;
        const mine = at.attacker_factionname && cfg.faction && at.attacker_factionname === cfg.faction;
        if (chain > 0 && who && mine && !known.has(atkid)) {
          const evt = mkEvent('autohit', { who: who, atk: atkid, chain: chain, ts: ended });
          evt._at = Date.now(); pending.push(evt); events.push(evt);
          await putEvent(evt);
        }
      }
      if (processedAtk.size > 800) processedAtk.clear();
    } catch (e) {
      attacksRetryAt = now() + 60000; // key can't read attacks (not Limited / no faction access)
    }
  }

  // Append one event. Firebase POST generates a unique server key => no clobbering.
  function putEvent(evt) {
    return gmReq('POST', dbBase() + '/' + DB_EVENTS + '.json', JSON.stringify(evt),
      { 'Content-Type': 'application/json' });
  }

  // Read the entire log in one request. Firebase returns { pushKey: event, ... }.
  function listEvents() {
    return gmReq('GET', dbBase() + '/' + DB_EVENTS + '.json', null, {}).then((txt) => {
      let obj;
      try { obj = JSON.parse(txt); } catch (e) { return []; }
      if (!obj || typeof obj !== 'object') return [];
      const out = [];
      for (const k in obj) {
        const v = obj[k];
        if (v && v.k && v.t) { v._k = k; out.push(v); } // remember the push key for pruning
      }
      return out;
    });
  }

  // Firebase has no per-key TTL, so we delete stale events ourselves.
  function pruneOld(list) {
    const t = now();
    let n = 0;
    for (const e of list) {
      if (!e._k) continue;
      const ttl = (e.k === 'chainstate' ? CHAINSTATE_TTL : EVENT_TTL_SEC) * 1000;
      if (t - e.t > ttl) {
        gmReq('DELETE', dbBase() + '/' + DB_EVENTS + '/' + encodeURIComponent(e._k) + '.json', null, {}).catch(() => {});
        if (++n >= 25) break; // cap deletes per cycle
      }
    }
  }

  // ============================ HELPERS ===================================
  function now() { return Date.now() + clockOffset; } // shared DB time
  function rand6() { return Math.random().toString(36).slice(2, 8); }
  function mkEvent(kind, payload) {
    const t = now();
    return { id: t + '-' + rand6(), t, a: cfg.name, k: kind, p: payload || {} };
  }
  function esc(s) {
    return String(s).replace(/[&<>"']/g, (c) => (
      { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
    ));
  }
  // short attention beep for alerts (best-effort; ignored if muted or blocked)
  function beep() {
    if (muted) return;
    try {
      audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
      const o = audioCtx.createOscillator();
      const g = audioCtx.createGain();
      o.type = 'square';
      o.frequency.value = 880;
      g.gain.value = 0.06;
      o.connect(g); g.connect(audioCtx.destination);
      o.start();
      o.stop(audioCtx.currentTime + 0.18);
    } catch (e) { /* audio not available */ }
  }

  // Deterministic string hash (xmur3) -> seeded PRNG (mulberry32).
  // Identical inputs give identical output in every browser => agreed winner.
  function xmur3(str) {
    let h = 1779033703 ^ str.length;
    for (let i = 0; i < str.length; i++) {
      h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
      h = (h << 13) | (h >>> 19);
    }
    return function () {
      h = Math.imul(h ^ (h >>> 16), 2246822507);
      h = Math.imul(h ^ (h >>> 13), 3266489909);
      h ^= h >>> 16;
      return h >>> 0;
    };
  }
  function seededPick(seed, items) {
    if (items.length === 0) return null;
    const rng = xmur3(seed)();
    return items[rng % items.length];
  }

  // ===================== STATE FROM EVENT LOG =============================
  // Bid-only numbered board: each chain hit number is claimed by a timed bid.
  // Pure + deterministic — every browser computes the same board from the log.
  function computeState(evList, nowMs) {
    let resetT = 0;
    for (const e of evList) if (e.k === 'reset' && e.t > resetT) resetT = e.t;
    const live = evList.filter((e) => e.t > resetT).sort((a, b) =>
      (a.t - b.t) || (a.id < b.id ? -1 : 1));

    const chain = latestChain(evList);
    const current = chain ? chain.current : 0; // last completed chain hit
    const max = chain ? chain.max : 0;         // unlocked cap (0 = unknown)

    // numbers a ✕/bump has released (per number, latest release time)
    const cleared = {};
    for (const e of live) if (e.k === 'clearclaim') {
      const n = e.p.num;
      if (!cleared[n] || e.t > cleared[n]) cleared[n] = e.t;
    }

    // resolve bids (one open at a time) into claims: number -> winner name
    const claims = {};
    let activeBid = null;
    let openBidId = null;
    for (const e of live) {
      if (e.k !== 'bidopen') continue;
      if (openBidId) continue;                 // one bid at a time
      const num = Number(e.p.num);
      if (num <= current || claims[num]) continue; // already hit or claimed
      openBidId = e.p.bid;
      const bidders = collectBidders(live, e);
      const resolveAt = e.p.closes + BID_SETTLE_MS;
      const sorted = bidders.slice().sort();
      const winner = seededPick(e.p.bid + '::' + sorted.join(','), sorted);
      if (nowMs >= resolveAt) {
        if (!(cleared[num] && cleared[num] > resolveAt)) claims[num] = winner; // loser gets nothing
        openBidId = null;                      // ready for the next number
      } else {
        activeBid = { bid: e.p.bid, num: num, closes: e.p.closes, resolveAt: resolveAt, bidders: bidders, winner: winner };
      }
    }

    // next number free to bid on (skip claimed / actively-bidding numbers)
    let nextNum = current + 1;
    while (claims[nextNum] || (activeBid && activeBid.num === nextNum)) nextNum++;

    return { current: current, max: max, claims: claims, activeBid: activeBid, nextNum: nextNum, chain: chain, hasChain: !!chain };
  }

  // free-for-all threshold + hard chain target: relayed value wins so everyone agrees
  function ffaThreshold(st) { return (st.chain && st.chain.ffa) || cfg.ffa || 10; }
  function targetMax(st) { return (st.chain && st.chain.tgt) || cfg.target || 0; }

  function collectBidders(live, openEvt) {
    const bidId = openEvt.p.bid;
    const closes = openEvt.p.closes;
    const set = [];
    const seen = new Set();
    const add = (n) => { if (!seen.has(n)) { seen.add(n); set.push(n); } };
    add(openEvt.a); // opener auto-bids
    for (const e of live) {
      if (e.k === 'bidjoin' && e.p.bid === bidId && e.t > openEvt.t && e.t <= closes) {
        add(e.a);
      }
    }
    return set;
  }

  // ============================ ACTIONS ===================================
  async function act(evt, optimistic) {
    if (optimistic) { evt._at = Date.now(); pending.push(evt); events.push(evt); render(); }
    try {
      await putEvent(evt);
      await sync(true);
    } catch (e) {
      pending = pending.filter((p) => p !== evt);
      events = events.filter((x) => x !== evt);
      lastError = e.message || String(e);
      render();
    }
  }
  // open a 15s bid for the next free chain number
  function openBid() {
    const st = computeState(events, now());
    if (!st.hasChain) { lastError = 'Waiting for live chain data…'; render(); return; }
    if (st.current < ffaThreshold(st)) { lastError = 'Free-for-all until #' + ffaThreshold(st) + ' — just hit!'; render(); return; }
    const num = st.nextNum;
    const emax = targetMax(st) > 0 ? targetMax(st) : st.max;
    if (emax && num > emax) { lastError = 'Chain target ' + emax + ' reached.'; render(); return; }
    act(mkEvent('bidopen', { bid: now() + '-' + rand6(), num: num, closes: now() + BID_WINDOW_MS }), true);
  }
  function joinBid(bidId) { act(mkEvent('bidjoin', { bid: bidId }), true); }
  function clearClaim(num) {
    const st = computeState(events, now());
    if (!cfg.isLeader && st.claims[Number(num)] !== cfg.name) { lastError = 'Only leaders can remove someone else.'; render(); return; }
    act(mkEvent('clearclaim', { num: Number(num) }), true);
  }

  async function resetQueue() {
    if (!cfg.isLeader) { lastError = 'Only leaders can reset the board.'; render(); return; }
    if (!confirm('Reset the ENTIRE board for everyone? This clears all claims.')) return;
    await act(mkEvent('reset'), false);
  }

  // ============================== SYNC ====================================
  async function sync(force) {
    if (!configured() || syncing) return;
    syncing = true;
    try {
      if (Date.now() - lastClockSync > 300000) { lastClockSync = Date.now(); await syncClock(); }
      const fetched = await listEvents();
      const ids = new Set(fetched.map((e) => e.id));
      // keep our just-sent actions until the server echoes them back (no snap-back)
      pending = pending.filter((p) => Date.now() - p._at < 15000 && !ids.has(p.id));
      events = fetched.concat(pending);
      await maybeRefreshChain();
      await maybeRelayAttacks();
      pruneOld(fetched);
      lastError = '';
    } catch (e) {
      lastError = e.message || String(e);
    } finally {
      syncing = false;
      render();
      schedulePoll();
    }
  }

  function schedulePoll() {
    if (pollTimer) clearTimeout(pollTimer);
    let interval = POLL_IDLE_MS;
    if (configured()) {
      const st = computeState(events, now());
      if (st.activeBid || st.claims[st.current + 1] === cfg.name) interval = POLL_ACTIVE_MS;
    }
    pollTimer = setTimeout(() => sync(false), interval);
  }

  // ============================== UI ======================================
  function ensurePanel() {
    let el = document.getElementById('tcq-panel');
    if (el) return el;
    el = document.createElement('div');
    el.id = 'tcq-panel';
    document.body.appendChild(el);
    injectStyles();
    restorePos(el);
    makeDraggable(el);
    return el;
  }

  // -------- drag + remembered position --------
  function setPos(el, x, y) {
    el.style.left = x + 'px';
    el.style.top = y + 'px';
    el.style.right = 'auto';
    el.style.bottom = 'auto';
  }
  function clamp(x, y, el) {
    const w = el.offsetWidth || 250, h = el.offsetHeight || 120;
    return [
      Math.max(0, Math.min(window.innerWidth - w, x)),
      Math.max(0, Math.min(window.innerHeight - h, y)),
    ];
  }
  function restorePos(el) {
    let x = stLoad('tcq_x', null);
    let y = stLoad('tcq_y', null);
    if (x === null || y === null) return; // keep default bottom-right
    setTimeout(() => { const c = clamp(Number(x), Number(y), el); setPos(el, c[0], c[1]); }, 0);
  }
  function makeDraggable(el) {
    let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
    const begin = (cx, cy, target) => {
      const hd = target.closest && target.closest('.tcq-hd');
      if (!hd || (target.closest && target.closest('button'))) return false; // not from the icons
      dragging = true;
      const r = el.getBoundingClientRect();
      ox = r.left; oy = r.top; sx = cx; sy = cy;
      el.classList.add('tcq-dragging');
      return true;
    };
    const move = (cx, cy) => {
      if (!dragging) return;
      const c = clamp(ox + (cx - sx), oy + (cy - sy), el);
      setPos(el, c[0], c[1]);
    };
    const end = () => {
      if (!dragging) return;
      dragging = false;
      el.classList.remove('tcq-dragging');
      const r = el.getBoundingClientRect();
      stStore('tcq_x', Math.round(r.left));
      stStore('tcq_y', Math.round(r.top));
    };
    // mouse (desktop)
    el.addEventListener('mousedown', (e) => { if (begin(e.clientX, e.clientY, e.target)) e.preventDefault(); });
    window.addEventListener('mousemove', (e) => move(e.clientX, e.clientY));
    window.addEventListener('mouseup', end);
    // touch (Torn PDA / mobile)
    el.addEventListener('touchstart', (e) => { const p = e.touches[0]; begin(p.clientX, p.clientY, e.target); }, { passive: true });
    window.addEventListener('touchmove', (e) => { if (!dragging) return; const p = e.touches[0]; move(p.clientX, p.clientY); e.preventDefault(); }, { passive: false });
    window.addEventListener('touchend', end);
    window.addEventListener('blur', end);
  }

  function render() {
    const el = ensurePanel();
    if (settingsOpen) return; // user is editing settings — don't overwrite the form
    if (!configured()) { el.innerHTML = setupHTML(); wireSetup(el); return; }

    const st = computeState(events, now());
    const current = st.current, max = st.max;
    const bid = st.activeBid;
    const bidLive = !!bid;
    const iAmBidder = bid && bid.bidders.indexOf(cfg.name) !== -1;

    // whoever claimed the next hit is "up"
    const upNum = current + 1;
    const upName = st.claims[upNum] || null;
    const iAmUp = upName === cfg.name;
    const ffa = ffaThreshold(st);
    const inFFA = st.hasChain && current < ffa; // early chain = free-for-all, no bidding
    const effMax = targetMax(st) > 0 ? targetMax(st) : max; // hard target overrides Torn's auto-bump
    const targetReached = st.hasChain && effMax > 0 && current >= effMax;

    // your own claimed number (lowest one you're holding beyond the current hit)
    let myHold = 0;
    for (const nn in st.claims) {
      const num = Number(nn);
      if (st.claims[nn] === cfg.name && num > current && (myHold === 0 || num < myHold)) myHold = num;
    }
    if (iAmUp && lastUpBeep !== upNum) { lastUpBeep = upNum; beep(); } // ping when it's your turn

    // attack alert / AFK — anchored to the REAL last chain hit (Torn's timestamp)
    let lastHitTs = 0;
    for (const e of events) if (e.k === 'autohit' && e.p.ts && e.p.ts > lastHitTs) lastHitTs = e.p.ts;
    const sinceHit = lastHitTs ? (now() - lastHitTs * 1000) : null;
    const attackWarn = iAmUp && sinceHit !== null &&
      sinceHit >= (CHAIN_TIMEOUT_MS - ATTACK_WARN_MS) && sinceHit < (CHAIN_TIMEOUT_MS + 8000);
    if (attackWarn && lastBeepTurn !== upNum) { lastBeepTurn = upNum; beep(); }
    const upAfk = upName && !iAmUp && sinceHit !== null && sinceHit >= AFK_BUMP_MS;

    const autoLive = (!!apiKey() && now() >= attacksRetryAt) ||
      events.some((e) => (e.k === 'autohit' || e.k === 'chainstate') && (now() - e.t < 120000));

    // next 25 board rows (not during the free-for-all phase)
    const rowsData = [];
    if (st.hasChain && !inFFA) {
      const end = effMax ? Math.min(current + 25, effMax) : current + 25;
      for (let n = upNum; n <= end; n++) rowsData.push([n, st.claims[n] || '', !!(bid && bid.num === n)]);
    }

    const key = JSON.stringify({
      rows: rowsData, bid: bid ? [bid.bid, bid.num, bid.bidders] : 0, err: lastError,
      name: cfg.name, up: upName, afk: upAfk, atk: attackWarn, auto: autoLive, cur: current, next: st.nextNum, col: collapsed, ffa: inFFA, tr: targetReached, mute: muted, hold: myHold,
    });
    const timerActive = !collapsed && (bidLive || (upName && sinceHit !== null)); // no per-second redraw while minimized
    if (key === lastRenderKey && !timerActive) return;
    lastRenderKey = key;
    el.classList.toggle('tcq-collapsed', collapsed);
    el.classList.toggle('tcq-myturn', iAmUp); // green pill when it's your turn (esp. minimized)

    let rows = rowsData.map((r) => {
      const n = r[0], who = r[1], isBidding = r[2];
      const isTarget = effMax && n === effMax;
      const me = who && who === cfg.name ? ' tcq-me' : '';
      const upCls = (n === upNum) ? ' tcq-up' : '';
      let right;
      if (isBidding) right = '<span class="tcq-bidtag">🎲 bidding…</span>';
      else if (who) right = '<span class="tcq-name">' + esc(who) + '</span>' +
        ((cfg.isLeader || who === cfg.name) ? '<button class="tcq-x" data-clear="' + n + '" title="clear claim">✕</button>' : '');
      else right = '<span class="tcq-open">open</span>';
      return '<li class="tcq-row' + me + upCls + (isTarget ? ' tcq-milerow' : '') + '">' +
        '<span class="tcq-num">#' + n + (isTarget ? ' 🎯' : '') + '</span>' + right + '</li>';
    }).join('');
    if (!rows) rows = '<li class="tcq-empty">' + (inFFA ? 'Bidding starts at #' + (ffa + 1) + '.' : (st.hasChain ? 'Chain cap reached.' : 'Waiting for live chain data…')) + '</li>';

    const chainLine = st.hasChain
      ? '<div class="tcq-chain">⛓ chain <b>' + current + '/' + max + '</b>' + (upName ? ' · up: <b>' + esc(upName) + '</b>' : '') + '</div>'
      : '';
    const attackHTML = attackWarn ? '<div class="tcq-attack">⚔️ ATTACK NOW — chain\'s about to drop!</div>' : '';
    const ffaBanner = inFFA ? '<div class="tcq-ffa">🔥 Free-for-all — everyone hit! (to #' + ffa + ')</div>' : '';
    const targetBanner = (targetReached && !inFFA) ? '<div class="tcq-ffa">🎯 Chain target ' + effMax + ' reached — hold or let it reset.</div>' : '';
    const holdLine = (myHold && !iAmUp) ? '<div class="tcq-hold">📍 You hold #' + myHold + '</div>' : '';
    const afkBanner = upAfk
      ? '<div class="tcq-afk">⏰ <b>' + esc(upName) + '</b> (#' + upNum + ') looks AFK' + (cfg.isLeader ? ' — <button class="tcq-btn tcq-warn" data-clear="' + upNum + '">Bump</button>' : '.') + '</div>'
      : '';

    let bidHTML = '';
    if (bidLive) {
      const secs = Math.max(0, Math.ceil((bid.closes - now()) / 1000));
      bidHTML = '<div class="tcq-bid live">' +
        '<div class="tcq-bidhead">🎲 Bidding for <b>#' + bid.num + '</b> — <b>' + secs + 's</b></div>' +
        '<div class="tcq-bidders">' + bid.bidders.map(esc).join(', ') + '</div>' +
        (iAmBidder ? '<div class="tcq-biddin">You\'re in the draw.</div>'
          : '<button class="tcq-btn tcq-join" data-join="' + esc(bid.bid) + '">Join bid for #' + bid.num + '</button>') +
        '</div>';
    }

    const meLine = inFFA
      ? '<span class="tcq-status">🔥 Get the chain going!</span>'
      : (targetReached
        ? '<span class="tcq-status you-up">🎯 Target ' + effMax + ' reached!</span>'
        : (iAmUp
          ? '<span class="tcq-status you-up">You\'re up — hit #' + upNum + '!</span>'
          : (upName ? '<span class="tcq-status">Up: ' + esc(upName) + ' (#' + upNum + ')</span>'
            : '<span class="tcq-status">#' + upNum + ' is open.</span>')));

    const canBid = st.hasChain && !inFFA && !targetReached && !bidLive && (!effMax || st.nextNum <= effMax);

    // preserve the list's scroll position across the innerHTML rebuild
    const prevList = el.querySelector('.tcq-list');
    const savedScroll = prevList ? prevList.scrollTop : 0;
    el.innerHTML =
      '<div class="tcq-hd">' +
        '<span class="tcq-title">' + (collapsed
          ? '💎 ' + (inFFA ? '🔥 FFA' : (upName ? esc(upName) : (st.hasChain ? current + '/' + (effMax || max || '?') : 'Queue')))
          : '💎 Chain Queue' + (cfg.faction ? ' <span class="tcq-fac">' + esc(cfg.faction) + '</span>' : '')) + '</span>' +
        '<span class="tcq-tools">' +
          '<button class="tcq-icon" id="tcq-mute" title="' + (muted ? 'unmute' : 'mute') + '">' + (muted ? '🔇' : '🔔') + '</button>' +
          '<button class="tcq-icon" id="tcq-refresh" title="refresh now">⟳</button>' +
          '<button class="tcq-icon" id="tcq-gear" title="settings">⚙</button>' +
          '<button class="tcq-icon" id="tcq-min" title="' + (collapsed ? 'expand' : 'collapse') + '">' + (collapsed ? '+' : '–') + '</button>' +
        '</span>' +
      '</div>' +
      '<div class="tcq-body">' +
        attackHTML + meLine + chainLine + ffaBanner + targetBanner + holdLine +
        '<ol class="tcq-list">' + rows + '</ol>' +
        afkBanner + bidHTML +
        (autoLive ? '<div class="tcq-auto">🛰 auto-tracking hits</div>' : '') +
        '<div class="tcq-actions">' +
          (canBid
            ? '<button class="tcq-btn tcq-primary" id="tcq-bid">🎲 Bid #' + st.nextNum + '</button>'
            : (bidLive ? '' : '<button class="tcq-btn tcq-dim" disabled>' + (inFFA ? 'Free-for-all' : (targetReached ? 'Target reached' : (st.hasChain ? 'Chain full' : 'No chain data'))) + '</button>')) +
        '</div>' +
        '<div class="tcq-foot">' +
          (cfg.isLeader ? '<button class="tcq-link" id="tcq-reset">reset</button>' : '<span></span>') +
          '<span class="tcq-err">' + (lastError ? '⚠ ' + esc(lastError) : (syncing ? 'syncing…' : '')) + '</span>' +
        '</div>' +
      '</div>';

    const newList = el.querySelector('.tcq-list');
    if (newList && savedScroll) newList.scrollTop = savedScroll;
    wireMain(el);
  }

  function wireMain(el) {
    const on = (id, fn) => { const b = el.querySelector('#' + id); if (b) b.onclick = fn; };
    on('tcq-bid', openBid);
    on('tcq-reset', resetQueue);
    on('tcq-mute', () => { muted = !muted; stStore('tcq_muted', muted); lastRenderKey = ''; render(); });
    on('tcq-refresh', () => sync(true));
    on('tcq-gear', () => { settingsOpen = true; el.innerHTML = setupHTML(); wireSetup(el); });
    on('tcq-min', () => { collapsed = !collapsed; stStore('tcq_collapsed', collapsed); lastRenderKey = ''; render(); });
    el.querySelectorAll('[data-join]').forEach((b) => { b.onclick = () => joinBid(b.getAttribute('data-join')); });
    el.querySelectorAll('[data-clear]').forEach((b) => { b.onclick = () => clearClaim(b.getAttribute('data-clear')); });
  }

  function setupHTML() {
    return (
      '<div class="tcq-hd"><span class="tcq-title">💎 Black Diamond Queue — setup</span>' +
        (configured() ? '<button class="tcq-icon" id="tcq-close" title="back to queue">✕</button>' : '') +
      '</div>' +
      '<div class="tcq-body tcq-setup">' +
        '<label>Torn API key ' +
          (pdaKey() ? '<span class="tcq-hint">(Torn PDA key detected — just tap Verify)</span>'
                    : '<span class="tcq-hint">(Public level — auto-fills your name)</span>') +
          '<input id="tcq-in-key" type="password" autocomplete="off" value="' + esc(cfg.apiKey || '') + '" placeholder="' + (pdaKey() ? 'using Torn PDA key' : 'paste your key (hidden)') + '" />' +
        '</label>' +
        '<button class="tcq-btn" id="tcq-verify">Verify key &amp; get my name</button>' +
        '<div class="tcq-ident" id="tcq-ident">' +
          (cfg.name ? '✓ ' + esc(cfg.name) + (cfg.faction ? ' <span class="tcq-fac">' + esc(cfg.faction) + '</span>' : '') : '') +
        '</div>' +
        '<div class="tcq-sep2"><a href="#" id="tcq-manual">no key? enter my name manually</a></div>' +
        '<label id="tcq-namewrap" style="display:' + (apiKey() ? 'none' : 'block') + '">Your Torn name' +
          '<input id="tcq-in-name" type="text" value="' + esc(cfg.name || '') + '" placeholder="e.g. BigHitter" />' +
        '</label>' +
        '<label>Firebase database URL <span class="tcq-hint">(shared — one per faction)</span>' +
          '<input id="tcq-in-db" type="text" value="' + esc(cfg.dbUrl || '') + '" placeholder="https://xxxx-default-rtdb.firebaseio.com" />' +
        '</label>' +
        '<button class="tcq-btn" id="tcq-test">Test connection</button>' +
        '<label>Free-for-all until # <span class="tcq-hint">(no bidding below this)</span>' +
          '<input id="tcq-in-ffa" type="number" min="1" value="' + (cfg.ffa || 10) + '" placeholder="10" />' +
        '</label>' +
        '<label>Chain target # <span class="tcq-hint">(0 = follow Torn; caps the board &amp; auto-resets)</span>' +
          '<input id="tcq-in-target" type="number" min="0" value="' + (cfg.target || 0) + '" placeholder="0" />' +
        '</label>' +
        '<label class="tcq-check"><input id="tcq-in-leader" type="checkbox"' + (cfg.isLeader ? ' checked' : '') + ' /> ' +
          'I\'m a leader/officer <span class="tcq-hint">(remove anyone &amp; reset)</span></label>' +
        '<button class="tcq-btn tcq-primary" id="tcq-save">Save &amp; Connect</button>' +
        '<div class="tcq-sep">first-time faction setup? create a free Firebase Realtime Database, set its rules to public, and paste that URL above (steps in the README).</div>' +
        '<div class="tcq-err" id="tcq-setup-msg">' + (lastError ? '⚠ ' + esc(lastError) : '') + '</div>' +
      '</div>'
    );
  }

  function wireSetup(el) {
    const nameI = el.querySelector('#tcq-in-name');
    const dbI = el.querySelector('#tcq-in-db');
    const keyI = el.querySelector('#tcq-in-key');
    const ffaI = el.querySelector('#tcq-in-ffa');
    const targetI = el.querySelector('#tcq-in-target');
    const leaderI = el.querySelector('#tcq-in-leader');
    const identEl = el.querySelector('#tcq-ident');
    const msg = el.querySelector('#tcq-setup-msg');

    // seed verified identity from saved config so Save works without re-verifying
    let verified = cfg.name ? { name: cfg.name, faction: cfg.faction || '' } : null;

    const closeBtn = el.querySelector('#tcq-close');
    if (closeBtn) closeBtn.onclick = () => { settingsOpen = false; lastRenderKey = ''; render(); };

    el.querySelector('#tcq-manual').onclick = (e) => {
      e.preventDefault();
      el.querySelector('#tcq-namewrap').style.display = 'block';
    };

    el.querySelector('#tcq-verify').onclick = async () => {
      const key = (keyI.value || '').trim() || pdaKey();
      if (!key) { msg.textContent = '⚠ Paste your Public API key first.'; return; }
      msg.textContent = 'checking key…';
      try {
        const u = await tornUser(key);
        const lead = /^(leader|co-leader)$/i.test(u.position || '');
        verified = { name: u.name, faction: u.faction };
        if (lead && leaderI) leaderI.checked = true; // auto-flag actual leaders/co-leaders
        identEl.innerHTML = '✓ ' + esc(u.name) +
          (u.faction ? ' <span class="tcq-fac">' + esc(u.faction) + '</span>' : '') +
          (lead ? ' <span class="tcq-fac">' + esc(u.position) + '</span>' : '');
        msg.textContent = '';
      } catch (e) {
        verified = null;
        identEl.textContent = '';
        msg.textContent = '⚠ ' + (e.message || 'key check failed');
      }
    };

    el.querySelector('#tcq-save').onclick = () => {
      const key = (keyI.value || '').trim();
      const dbUrl = normalizeDbUrl(dbI.value);
      let name = verified ? verified.name : '';
      let faction = verified ? verified.faction : (cfg.faction || '');
      if (!name) name = (nameI.value || '').trim(); // manual fallback
      if (!name) { msg.textContent = '⚠ Verify your API key, or enter your name manually.'; return; }
      if (!dbUrl) { msg.textContent = '⚠ Paste the shared Firebase database URL.'; return; }
      const ffa = Math.max(1, parseInt((ffaI && ffaI.value) || '10', 10) || 10);
      const target = Math.max(0, parseInt((targetI && targetI.value) || '0', 10) || 0);
      const isLeader = !!(leaderI && leaderI.checked);
      saveCfg({ name, dbUrl, apiKey: key, faction, ffa: ffa, target: target, isLeader: isLeader });
      settingsOpen = false;
      lastError = ''; lastRenderKey = '';
      render();
      sync(true);
    };

    el.querySelector('#tcq-test').onclick = async () => {
      const url = (dbI.value || '').trim();
      if (!url) { msg.textContent = '⚠ Paste the Firebase database URL first.'; return; }
      msg.textContent = 'testing…';
      try {
        await testConnection(url);
        msg.textContent = '✓ Connected — the database is reachable and writable.';
      } catch (e) {
        msg.textContent = '⚠ ' + (e.message || 'could not reach the database');
      }
    };
  }

  // ============================= STYLES ===================================
  function injectStyles() {
    if (document.getElementById('tcq-styles')) return;
    const s = document.createElement('style');
    s.id = 'tcq-styles';
    s.textContent = `
#tcq-panel{position:fixed;right:14px;bottom:14px;width:250px;max-width:calc(100vw - 20px);z-index:2147483000;
  font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#e7e7ea;
  background:#1c1f26;border:1px solid #33373f;border-radius:10px;
  box-shadow:0 8px 26px rgba(0,0,0,.45);overflow:hidden}
#tcq-panel.tcq-collapsed .tcq-body{display:none}
#tcq-panel.tcq-collapsed{width:auto}
#tcq-panel.tcq-collapsed .tcq-hd{border-bottom:0}
#tcq-panel.tcq-collapsed #tcq-refresh,#tcq-panel.tcq-collapsed #tcq-gear,#tcq-panel.tcq-collapsed #tcq-mute{display:none}
#tcq-panel.tcq-myturn:not(.tcq-collapsed){border-color:#2f9e5a}
#tcq-panel.tcq-myturn.tcq-collapsed{border-color:#3ad17a;animation:tcqglow 1s ease-in-out infinite}
#tcq-panel.tcq-myturn.tcq-collapsed .tcq-hd{background:#1f7a3a}
#tcq-panel.tcq-myturn.tcq-collapsed .tcq-title,#tcq-panel.tcq-myturn.tcq-collapsed .tcq-icon{color:#fff}
@keyframes tcqglow{0%,100%{box-shadow:0 0 0 2px rgba(58,209,122,.55),0 8px 26px rgba(0,0,0,.45)}
  50%{box-shadow:0 0 0 7px rgba(58,209,122,0),0 8px 26px rgba(0,0,0,.45)}}
#tcq-panel.tcq-dragging{opacity:.94;box-shadow:0 14px 38px rgba(0,0,0,.6);
  transition:none;user-select:none}
#tcq-panel .tcq-hd{display:flex;align-items:center;justify-content:space-between;
  padding:8px 10px;background:#232732;border-bottom:1px solid #33373f;
  cursor:move;user-select:none}
#tcq-panel .tcq-hd:active{cursor:grabbing}
#tcq-panel .tcq-title{font-weight:700;letter-spacing:.2px}
#tcq-panel .tcq-title::before{content:"⠿";margin-right:6px;color:#6d7482;font-weight:400}
#tcq-panel .tcq-tools{display:flex;gap:4px}
#tcq-panel .tcq-icon{background:transparent;border:0;color:#9aa0ad;cursor:pointer;
  font-size:14px;line-height:1;padding:2px 4px;border-radius:4px}
#tcq-panel .tcq-icon:hover{background:#2f3644;color:#fff}
#tcq-panel .tcq-body{padding:9px 10px}
#tcq-panel .tcq-status{display:block;margin-bottom:7px;color:#aeb4c0}
#tcq-panel .tcq-status.you-up{color:#7ee081;font-weight:700}
#tcq-panel .tcq-list{list-style:none;margin:0 0 8px;padding:0;max-height:210px;overflow:auto}
#tcq-panel .tcq-row{display:flex;align-items:center;gap:7px;padding:4px 5px;
  border-radius:6px;margin-bottom:2px;background:#232732}
#tcq-panel .tcq-row.tcq-me{background:#2b3a55;outline:1px solid #3a5fa0}
#tcq-panel .tcq-num{min-width:16px;text-align:center;color:#8b93a3;font-weight:700}
#tcq-panel .tcq-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#tcq-panel .tcq-x{background:transparent;border:0;color:#6d7482;cursor:pointer;font-size:12px}
#tcq-panel .tcq-x:hover{color:#ff6b6b}
#tcq-panel .tcq-empty{color:#7d8494;padding:6px 4px;font-style:italic}
#tcq-panel .tcq-actions{display:flex;flex-wrap:wrap;gap:5px}
#tcq-panel .tcq-btn{flex:1 1 auto;min-width:66px;border:0;border-radius:6px;padding:7px 8px;
  cursor:pointer;font-weight:700;font-size:12px;background:#333a47;color:#e7e7ea}
#tcq-panel .tcq-btn:hover{filter:brightness(1.12)}
#tcq-panel .tcq-primary{background:#2f9e44;color:#fff}
#tcq-panel .tcq-warn{background:#b5460f;color:#fff}
#tcq-panel .tcq-join{background:#6741d9;color:#fff;width:100%;margin-top:5px}
#tcq-panel .tcq-dim{opacity:.5;cursor:not-allowed}
#tcq-panel .tcq-bid{margin:2px 0 8px;padding:7px 8px;border-radius:7px;background:#2a2450;
  border:1px solid #4b3fa0}
#tcq-panel .tcq-bidhead{margin-bottom:3px}
#tcq-panel .tcq-bidders{color:#c9c2ff;font-size:11px;word-break:break-word}
#tcq-panel .tcq-biddin{color:#8ee08e;font-size:11px;margin-top:4px}
#tcq-panel .tcq-foot{display:flex;align-items:center;justify-content:space-between;margin-top:8px}
#tcq-panel .tcq-link{background:none;border:0;color:#6d7482;cursor:pointer;text-decoration:underline;font-size:11px}
#tcq-panel .tcq-link:hover{color:#ff9c6b}
#tcq-panel .tcq-err{color:#ff8f8f;font-size:11px;text-align:right;flex:1;margin-left:6px}
#tcq-panel .tcq-setup label{display:block;margin-bottom:7px;color:#aeb4c0}
#tcq-panel .tcq-setup input{width:100%;box-sizing:border-box;margin-top:3px;padding:6px 7px;
  border-radius:6px;border:1px solid #3a3f4b;background:#12151b;color:#fff;font-size:12px}
#tcq-panel .tcq-setup .tcq-btn{width:100%;margin-bottom:8px}
#tcq-panel .tcq-check{display:flex;align-items:center;gap:6px;color:#aeb4c0}
#tcq-panel .tcq-check input{width:auto;margin:0}
#tcq-panel .tcq-sep{color:#6d7482;text-align:center;margin:4px 0 8px;font-size:11px}
#tcq-panel .tcq-hint{font-weight:400;color:#7d8494;font-size:10px}
#tcq-panel .tcq-ident{min-height:14px;color:#8ee08e;font-size:12px;margin:-2px 0 6px}
#tcq-panel .tcq-fac{color:#c9c2ff;font-weight:400;font-size:10px}
#tcq-panel .tcq-sep2{text-align:center;margin:2px 0 8px}
#tcq-panel .tcq-sep2 a{color:#6d7482;font-size:11px}
#tcq-panel .tcq-chain{margin:-2px 0 7px;color:#9fb4d6;font-size:11px}
#tcq-panel .tcq-chain b{color:#e7e7ea}
#tcq-panel .tcq-hold{margin:0 0 7px;color:#8ee0a0;font-size:11px;font-weight:700}
#tcq-panel .tcq-auto{margin:0 0 7px;padding:4px 7px;border-radius:6px;background:#16351f;
  border:1px solid #2f6b3a;color:#8ee0a0;font-size:10px;text-align:center}
#tcq-panel .tcq-milerow{outline:1px solid #6b5a1f}
#tcq-panel .tcq-up{background:#173a2a;outline:1px solid #2f9e5a}
#tcq-panel .tcq-open{flex:1;color:#7d8494;font-size:11px;text-align:right}
#tcq-panel .tcq-bidtag{flex:1;color:#c9c2ff;font-size:11px;text-align:right}
#tcq-panel .tcq-afk{margin:0 0 8px;padding:6px 8px;border-radius:6px;background:#3a2420;
  border:1px solid #7d3a2a;color:#ffbfa8;font-size:11px}
#tcq-panel .tcq-attack{margin:0 0 8px;padding:9px 8px;border-radius:7px;text-align:center;
  font-size:14px;font-weight:800;letter-spacing:.4px;color:#fff;background:#b5140f;
  border:1px solid #ff5a4a;animation:tcqpulse .7s ease-in-out infinite}
#tcq-panel .tcq-attack b{color:#ffe08a}
#tcq-panel .tcq-ffa{margin:0 0 8px;padding:7px 8px;border-radius:7px;text-align:center;
  font-weight:800;color:#fff;background:#b5600f;border:1px solid #ff9a3a}
@keyframes tcqpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,60,40,.7);opacity:1}
  50%{box-shadow:0 0 0 7px rgba(255,60,40,0);opacity:.82}}
@media (max-width:600px){
  #tcq-panel{font-size:13px}
  #tcq-panel .tcq-btn{padding:10px 8px}
  #tcq-panel .tcq-icon{font-size:18px;padding:4px 8px}
  #tcq-panel .tcq-x{font-size:16px;padding:2px 6px}
  #tcq-panel .tcq-list{max-height:44vh}
}
`;
    document.head.appendChild(s);
  }

  // ============================= BOOT =====================================
  function boot() {
    if (!document.body) { setTimeout(boot, 300); return; }
    if (document.getElementById('tcq-panel')) return; // another copy is already running — don't double up
    ensurePanel();
    render();
    if (configured()) sync(true);
    // tick once a second; render() no-ops itself unless a countdown is active
    setInterval(() => { if (configured()) render(); }, 1000);
  }
  if (typeof document !== 'undefined') boot();

  // expose internals for the test harness (no-op in the browser)
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = { computeState, latestChain, xmur3, seededPick };
  }
})();
// v2.1.0 — draggable panel with remembered position