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).
// ==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) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 & 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 & reset)</span></label>' +
'<button class="tcq-btn tcq-primary" id="tcq-save">Save & 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