Torn / Torn PDA chain watch coordinator with mobile-safe chain countdown, IndexedDB storage, synced target cache, draggable settings, and target finder
// ==UserScript==
// @name Chain cordinator
// @namespace https://torn.com/
// @version 26.5.6.0
// @description Torn / Torn PDA chain watch coordinator with mobile-safe chain countdown, IndexedDB storage, synced target cache, draggable settings, and target finder
// @author Vreebn [4149405]
// @match https://www.torn.com/*
// @match https://torn.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @run-at document-end
// @connect torn-chain-coordinator.ebnoreza.workers.dev
// @connect ffscouter.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) return;
if (window.__CHAIN_COORDINATOR_V265140_ACTIVE__) return;
window.__CHAIN_COORDINATOR_V265140_ACTIVE__ = true;
const WORKER_BASE = 'https://torn-chain-coordinator.ebnoreza.workers.dev';
const PRESENCE_SYNC_EVERY_MS = 30 * 1000;
const IDENTITY_SCAN_MS = 2500;
const DISCOVERY_SCAN_MS = 20000;
const UI_REFRESH_MS = 250;
const CHAIN_FAST_SCAN_MS = 250;
const ROUTE_CHECK_MS = 1000;
const TARGET_CACHE_POLL_MS = 2000;
const BOOT_DOM_DELAY_MS = 1800;
const BOOT_SYNC_DELAY_MS = 2600;
const SLOT_MINUTES = 30;
const TIMELINE_CELL_MINUTES = 30;
const GRAPH_BAR_COUNT = 48;
const CHAIN_COOLDOWN_MS = 5 * 60 * 1000;
const MOBILE_WIDTH_PX = 768;
const FF_BATCH_SIZE = 200;
const TARGET_MAX_BATCHES = 10;
const DEFAULT_TARGET_COUNT = 5;
const DEFAULT_FF_MIN = '2.9';
const DEFAULT_FF_MAX = '3.1';
const IDB_NAME = 'chain_coordinator_v265120';
const IDB_STORE = 'kv';
const STORE_KEY = 'state';
const TARGET_STORE_KEY = 'targets';
const BC_NAME = 'chain_coordinator_target_sync_v265120';
const LOG_PREFIX = '[CC]';
let panel = null;
let miniBtn = null;
let settingsBtn = null;
let settingsModal = null;
let targetChannel = null;
const mem = {
player_id: '',
player_name: '',
player_url: '',
faction_id: '',
faction_name: '',
faction_url: '',
last_identity_source: '',
share_energy: '1',
ff_api_key: '',
ff_min: DEFAULT_FF_MIN,
ff_max: DEFAULT_FF_MAX,
target_count: String(DEFAULT_TARGET_COUNT),
watch_start_mode: 'now',
watch_start_hhmm: '',
watch_duration_hours: '1',
chain_value: '',
chain_max: '',
chain_time_left: '',
energy_current: '',
energy_max: '',
last_status: '',
next_sync_label: '',
ui_mode: 'summary',
board: {
online_count: 0,
energy_total_current: 0,
energy_total_max: 0,
current_watch: null,
next_watch: null,
watch_slots: []
},
me: {
active_watch: null,
next_watch: null
},
target_rows: [],
target_revision: 0,
targets_loading: '',
targets_error: ''
};
const state = {
paused: false,
hidden: false,
collapsed: false,
panelX: null,
panelY: null,
miniAnchorX: 'right',
miniAnchorY: 'bottom',
miniRight: 18,
miniBottom: 52,
miniLeft: 18,
miniTop: 80,
settingsAnchorX: 'right',
settingsAnchorY: 'bottom',
settingsRight: 14,
settingsBottom: 104,
settingsLeft: 14,
settingsTop: 120,
chainDeadlineMs: null,
chainLastDomText: '',
chainLastDomReadAt: 0,
chainTimerChainKey: '',
chainTimerFromTooltip: false,
lastRoute: location.href,
identityTimerId: null,
chainFastTimerId: null,
discoveryTimerId: null,
uiTimerId: null,
routeTimerId: null,
syncIntervalId: null,
syncTimeoutId: null,
targetPollTimerId: null,
saveTimer: null,
storageBroken: false,
syncingPresence: false,
syncingBoard: false,
syncingWatch: false,
syncingTargets: false,
sendingDiscovery: false,
lastSavedJson: '',
lastDiscoverySignature: ''
};
function log(...args) { console.log(LOG_PREFIX, ...args); }
function warn(...args) { console.warn(LOG_PREFIX, ...args); }
function errlog(...args) { console.error(LOG_PREFIX, ...args); }
function qs(sel, root = document) {
try { return root.querySelector(sel); } catch { return null; }
}
function qsa(sel, root = document) {
try { return Array.from(root.querySelectorAll(sel)); } catch { return []; }
}
function safeText(el) {
return (el?.textContent || '').replace(/\s+/g, ' ').trim();
}
function cleanString(v) {
if (v == null) return '';
return String(v).trim();
}
function esc(s) {
return String(s ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function safeJsonParse(v, fallback = null) {
try { return JSON.parse(v); } catch { return fallback; }
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function parseIntOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
function parseFloatOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function nowIso() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isMobileWidth() {
return window.innerWidth <= MOBILE_WIDTH_PX;
}
function makeChainKey(value, max) {
const v = cleanString(value);
const m = cleanString(max);
if (!v && !m) return '';
return `${v}/${m || ''}`;
}
function invalidateMobileChainTimer() {
state.chainDeadlineMs = null;
state.chainLastDomText = '';
state.chainLastDomReadAt = 0;
state.chainTimerChainKey = '';
state.chainTimerFromTooltip = false;
mem.chain_time_left = '';
}
function isValidPlayerId(v) {
const s = cleanString(v);
return /^\d{1,12}$/.test(s) && Number(s) > 0;
}
function isValidFactionId(v) {
const s = cleanString(v);
return /^\d{1,12}$/.test(s) && Number(s) > 0;
}
function playerProfileUrl(playerId) {
return `https://www.torn.com/profiles.php?XID=${encodeURIComponent(playerId)}`;
}
function factionProfileUrl(factionId) {
return `https://www.torn.com/factions.php?step=profile&ID=${encodeURIComponent(factionId)}`;
}
function extractParamFromUrl(rawUrl, paramName) {
const raw = cleanString(rawUrl);
if (!raw) return '';
const m = raw.match(new RegExp(`[?&]${paramName}=([0-9]+)`, 'i'));
return m ? m[1] : '';
}
function normalizeTime(str) {
const m = String(str || '').trim().match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isInteger(hh) || !Number.isInteger(mm)) return null;
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
function hhmmToMinutes(hhmm) {
const t = normalizeTime(hhmm);
if (!t) return null;
const [hh, mm] = t.split(':').map(Number);
return hh * 60 + mm;
}
function minutesToHHMM(totalMinutes) {
const mins = ((Number(totalMinutes) % 1440) + 1440) % 1440;
const hh = Math.floor(mins / 60);
const mm = mins % 60;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
function floorNowToSlotHHMM() {
const now = new Date();
const total = now.getUTCHours() * 60 + now.getUTCMinutes();
const floored = Math.floor(total / SLOT_MINUTES) * SLOT_MINUTES;
return minutesToHHMM(floored);
}
function floorToUtcHalfHour(ms) {
const d = new Date(ms);
d.setUTCSeconds(0, 0);
const m = d.getUTCMinutes();
d.setUTCMinutes(m < 30 ? 0 : 30);
return d.getTime();
}
function getWeekdayLabel(weekday) {
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][Number(weekday)] || '-';
}
function isoToParts(iso) {
if (!iso) return null;
const d = new Date(iso);
if (!Number.isFinite(d.getTime())) return null;
return {
weekday: d.getUTCDay(),
hhmm: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
};
}
function formatIsoAsTct(iso) {
const p = isoToParts(iso);
return p ? `${getWeekdayLabel(p.weekday)} ${p.hhmm}` : '-';
}
function formatIsoRangeAsTct(startIso, endIso) {
const s = isoToParts(startIso);
const e = isoToParts(endIso);
if (!s || !e) return '-';
if (s.weekday === e.weekday) return `${getWeekdayLabel(s.weekday)} ${s.hhmm} → ${e.hhmm}`;
return `${getWeekdayLabel(s.weekday)} ${s.hhmm} → ${getWeekdayLabel(e.weekday)} ${e.hhmm}`;
}
function formatWatchUntil(iso) {
const p = isoToParts(iso);
return p ? `until ${p.hhmm}` : '';
}
function parseChainTimeLeftToMs(value) {
const raw = String(value || '').trim();
if (!raw) return null;
let m = raw.match(/^(\d{1,2}):(\d{2})$/);
if (m) return (Number(m[1]) * 60 + Number(m[2])) * 1000;
m = raw.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
if (m) return (Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3])) * 1000;
return null;
}
function formatDuration(ms) {
if (!(ms > 0)) return '00:00:00';
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function formatMiniCountdown(ms) {
if (!(ms > 0)) return '⏱';
if (ms > CHAIN_COOLDOWN_MS) return '⏱';
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function countdownToneClass(ms) {
if (!(ms > 0)) return 'cc-good';
if (ms > CHAIN_COOLDOWN_MS) return 'cc-good';
if (ms > 4 * 60 * 1000) return 'cc-good';
if (ms > 3 * 60 * 1000) return 'cc-info';
if (ms > 2 * 60 * 1000) return 'cc-warn';
if (ms > 1 * 60 * 1000) return 'cc-orange';
return 'cc-bad';
}
function miniToneClass(ms) {
if (!(ms > 0)) return 'cc-mini-blue';
if (ms > CHAIN_COOLDOWN_MS) return 'cc-mini-blue';
if (ms > 4 * 60 * 1000) return 'cc-mini-green';
if (ms > 3 * 60 * 1000) return 'cc-mini-blue';
if (ms > 2 * 60 * 1000) return 'cc-mini-yellow';
if (ms > 1 * 60 * 1000) return 'cc-mini-orange';
return 'cc-mini-red';
}
function openDb() {
return new Promise((resolve, reject) => {
try {
const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('IDB open failed'));
} catch (e) {
reject(e);
}
});
}
async function idbGet(key) {
if (state.storageBroken) return null;
let db;
try {
db = await openDb();
return await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly');
const store = tx.objectStore(IDB_STORE);
const req = store.get(key);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error || new Error('IDB get failed'));
});
} catch (e) {
state.storageBroken = true;
warn('IDB get failed; storage disabled', e?.message || e);
return null;
} finally {
try { if (db) db.close(); } catch {}
}
}
async function idbSet(key, value) {
if (state.storageBroken) return false;
let db;
try {
db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
const store = tx.objectStore(IDB_STORE);
const req = store.put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error || new Error('IDB set failed'));
});
return true;
} catch (e) {
state.storageBroken = true;
warn('IDB set failed; storage disabled', e?.message || e);
return false;
} finally {
try { if (db) db.close(); } catch {}
}
}
function getCompactSaveObject() {
return {
version: '26.5.14.0',
saved_at: nowIso(),
player_id: mem.player_id,
player_name: mem.player_name,
player_url: mem.player_url,
faction_id: mem.faction_id,
faction_name: mem.faction_name,
faction_url: mem.faction_url,
last_identity_source: mem.last_identity_source,
share_energy: mem.share_energy,
ff_api_key: mem.ff_api_key,
ff_min: mem.ff_min,
ff_max: mem.ff_max,
target_count: mem.target_count,
watch_start_mode: mem.watch_start_mode,
watch_start_hhmm: mem.watch_start_hhmm,
watch_duration_hours: mem.watch_duration_hours,
ui_mode: mem.ui_mode,
paused: state.paused,
hidden: state.hidden,
collapsed: state.collapsed,
panelX: state.panelX,
panelY: state.panelY,
miniAnchorX: state.miniAnchorX,
miniAnchorY: state.miniAnchorY,
miniRight: state.miniRight,
miniBottom: state.miniBottom,
miniLeft: state.miniLeft,
miniTop: state.miniTop,
settingsAnchorX: state.settingsAnchorX,
settingsAnchorY: state.settingsAnchorY,
settingsRight: state.settingsRight,
settingsBottom: state.settingsBottom,
settingsLeft: state.settingsLeft,
settingsTop: state.settingsTop
};
}
async function persistNow() {
const raw = JSON.stringify(getCompactSaveObject());
if (raw === state.lastSavedJson) return;
const ok = await idbSet(STORE_KEY, raw);
if (ok) state.lastSavedJson = raw;
}
function queuePersist() {
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(() => persistNow().catch(() => {}), 900);
}
async function loadPersistedState() {
const raw = await idbGet(STORE_KEY);
const saved = safeJsonParse(raw, null);
if (!saved || typeof saved !== 'object') return;
mem.player_id = cleanString(saved.player_id);
mem.player_name = cleanString(saved.player_name);
mem.player_url = cleanString(saved.player_url);
mem.faction_id = cleanString(saved.faction_id);
mem.faction_name = cleanString(saved.faction_name);
mem.faction_url = cleanString(saved.faction_url);
mem.last_identity_source = cleanString(saved.last_identity_source) || 'cache';
mem.share_energy = cleanString(saved.share_energy) || '1';
mem.ff_api_key = cleanString(saved.ff_api_key);
mem.ff_min = cleanString(saved.ff_min) || DEFAULT_FF_MIN;
mem.ff_max = cleanString(saved.ff_max) || DEFAULT_FF_MAX;
mem.target_count = cleanString(saved.target_count) || String(DEFAULT_TARGET_COUNT);
mem.watch_start_mode = cleanString(saved.watch_start_mode) || 'now';
mem.watch_start_hhmm = cleanString(saved.watch_start_hhmm);
mem.watch_duration_hours = cleanString(saved.watch_duration_hours) || '1';
mem.ui_mode = cleanString(saved.ui_mode) || 'summary';
state.paused = !!saved.paused;
state.hidden = !!saved.hidden;
state.collapsed = !!saved.collapsed;
state.panelX = typeof saved.panelX === 'number' ? saved.panelX : null;
state.panelY = typeof saved.panelY === 'number' ? saved.panelY : null;
state.miniAnchorX = saved.miniAnchorX === 'left' ? 'left' : 'right';
state.miniAnchorY = saved.miniAnchorY === 'top' ? 'top' : 'bottom';
state.miniRight = typeof saved.miniRight === 'number' ? saved.miniRight : 18;
state.miniBottom = typeof saved.miniBottom === 'number' ? saved.miniBottom : 52;
state.miniLeft = typeof saved.miniLeft === 'number' ? saved.miniLeft : 18;
state.miniTop = typeof saved.miniTop === 'number' ? saved.miniTop : 80;
state.settingsAnchorX = saved.settingsAnchorX === 'left' ? 'left' : 'right';
state.settingsAnchorY = saved.settingsAnchorY === 'top' ? 'top' : 'bottom';
state.settingsRight = typeof saved.settingsRight === 'number' ? saved.settingsRight : 14;
state.settingsBottom = typeof saved.settingsBottom === 'number' ? saved.settingsBottom : 104;
state.settingsLeft = typeof saved.settingsLeft === 'number' ? saved.settingsLeft : 14;
state.settingsTop = typeof saved.settingsTop === 'number' ? saved.settingsTop : 120;
state.lastSavedJson = raw || '';
}
function getTargetCachePayload() {
return {
revision: Number(mem.target_revision || 0),
updated_at: nowIso(),
rows: Array.isArray(mem.target_rows) ? mem.target_rows.slice(0, 25).map(r => ({
player_id: r.player_id,
player_name: r.player_name,
player_level: r.player_level,
player_faction_id: r.player_faction_id,
player_faction_name: r.player_faction_name,
ff_score: r.ff_score,
ff_label: r.ff_label,
profile_url: r.profile_url,
attack_url: r.attack_url
})) : []
};
}
async function saveTargetCache(broadcast = true) {
mem.target_revision = Date.now();
const payload = getTargetCachePayload();
await idbSet(TARGET_STORE_KEY, JSON.stringify(payload));
if (broadcast && targetChannel) {
try {
targetChannel.postMessage({
type: 'targets_updated',
revision: payload.revision,
rows: payload.rows
});
} catch {}
}
}
async function loadTargetCache(applyEvenIfEmpty = true) {
const raw = await idbGet(TARGET_STORE_KEY);
const saved = safeJsonParse(raw, null);
if (!saved || typeof saved !== 'object') return false;
const revision = Number(saved.revision || 0);
if (revision <= Number(mem.target_revision || 0)) return false;
const rows = Array.isArray(saved.rows) ? saved.rows : [];
if (!applyEvenIfEmpty && !rows.length) return false;
mem.target_revision = revision;
mem.target_rows = rows.slice(0, 25);
if (mem.ui_mode === 'targets') renderActiveView();
return true;
}
function setupTargetBroadcast() {
try {
targetChannel = new BroadcastChannel(BC_NAME);
targetChannel.onmessage = (ev) => {
const msg = ev?.data || {};
if (msg.type !== 'targets_updated') return;
const revision = Number(msg.revision || 0);
if (revision <= Number(mem.target_revision || 0)) return;
mem.target_revision = revision;
mem.target_rows = Array.isArray(msg.rows) ? msg.rows.slice(0, 25) : [];
if (mem.ui_mode === 'targets') renderActiveView();
};
} catch {
targetChannel = null;
}
}
function xhrJson({ method = 'GET', url, data = null, headers = {}, timeout = 30000 }) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest unavailable'));
return;
}
GM_xmlhttpRequest({
method,
url,
data: data ? JSON.stringify(data) : undefined,
headers: data ? { 'Content-Type': 'application/json', ...headers } : headers,
timeout,
onload: (res) => {
let json = {};
try { json = JSON.parse(res.responseText || '{}'); } catch {}
if (res.status >= 200 && res.status < 300) resolve(json);
else reject(new Error(json?.error || `HTTP ${res.status}`));
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Request timeout'))
});
});
}
async function workerJson({ method = 'GET', path, data = null, timeout = 30000 }) {
const url = path.startsWith('http') ? path : `${WORKER_BASE}${path}`;
return await xhrJson({ method, url, data, timeout });
}
async function setStatus(text) {
mem.last_status = String(text || '');
const el = qs('#cc-status');
if (el) el.textContent = mem.last_status;
}
function getPlayerFromHiddenInput() {
const input = qs('#torn-user');
if (!input) return null;
try {
const data = JSON.parse(input.value || '{}');
const id = cleanString(data?.id);
const name = cleanString(data?.playername);
if (!isValidPlayerId(id) || !name) return null;
return {
player_id: id,
player_name: name,
player_url: playerProfileUrl(id)
};
} catch {
return null;
}
}
function isHomePageForSelf() {
const path = location.pathname || '';
const search = location.search || '';
const hash = location.hash || '';
if (path === '/' || path === '/index.php') return true;
if (/sid=home/i.test(search)) return true;
if (/\/home/i.test(path)) return true;
if (/home/i.test(hash) && !/profiles/i.test(hash)) return true;
return false;
}
function isFactionYourPage() {
try {
const url = new URL(location.href);
return /factions\.php$/i.test(url.pathname || '') &&
String(url.searchParams.get('step') || '').toLowerCase() === 'your';
} catch {
return /factions\.php\?step=your/i.test(location.href);
}
}
function buildFactionObject(id, name, href, source) {
const factionId = cleanString(id);
const factionName = cleanString(name);
if (!isValidFactionId(factionId) || !factionName) return null;
return {
faction_id: factionId,
faction_name: factionName,
faction_url: href ? new URL(href, location.origin).href : factionProfileUrl(factionId),
source: source || 'Home'
};
}
function readSelfFactionFromHomeOnly() {
if (!isHomePageForSelf()) return null;
const links = qsa('a[href*="factions.php?step=profile"][href*="ID="]');
for (const a of links) {
const href = a.getAttribute('href') || '';
const id = extractParamFromUrl(href, 'ID');
const name = safeText(a);
if (!isValidFactionId(id) || !name) continue;
const block = a.closest('li, div, section, article, table, tbody, tr') || a.parentElement || a;
const blockText = safeText(block);
if (
/faction/i.test(blockText) ||
/general information/i.test(blockText) ||
/your faction/i.test(blockText)
) {
const obj = buildFactionObject(id, name, href, 'Home faction info');
if (obj) return obj;
}
}
return null;
}
function detectEnergyFromDom() {
const selectors = [
'div[class*="bar"][class*="energy"]',
'[class*="energy"]',
'[aria-label*="Energy"]',
'[class*="energy__"]',
'[class*="bar-mobile__"][class*="energy__"]'
];
for (const sel of selectors) {
for (const node of qsa(sel)) {
const txt = safeText(node);
const m = txt.match(/(\d+)\s*\/\s*(\d+)/);
if (m) return { current: Number(m[1]), max: Number(m[2]) };
}
}
return null;
}
function parseChainRatioFromText(txt) {
const raw = String(txt || '').replace(/\s+/g, ' ').trim();
if (!raw) return null;
const ratio =
raw.match(/([0-9][0-9,]*)\s*\/\s*([0-9][0-9,]*)(k)?/i) ||
raw.match(/([0-9][0-9,]*)\s+\/\s+([0-9][0-9,]*)(k)?/i);
if (!ratio) return null;
let maxNum = Number(String(ratio[2]).replace(/,/g, ''));
if (ratio[3]) maxNum *= 1000;
return {
value: String(Number(String(ratio[1]).replace(/,/g, ''))),
max: String(Math.trunc(maxNum || 0))
};
}
function readMobileChainBar() {
const tooltipCandidates = [
...qsa('[role="tooltip"]'),
...qsa('[data-floating-ui-portal] [role="tooltip"]'),
...qsa('[id^="__r_"][role="tooltip"]'),
...qsa('div[class*="tooltip"]')
].filter(Boolean);
for (const tip of tooltipCandidates) {
const txt = safeText(tip);
if (!txt || !/\bchain\b/i.test(txt)) continue;
const ratio = parseChainRatioFromText(txt);
const timeNode =
qs('[class*="bar-timeleft"]', tip) ||
qsa('span,p,div', tip).find(n => /\d{1,2}:\d{2}(?::\d{2})?/.test(safeText(n)));
const timeTxt = safeText(timeNode) || txt;
const time = timeTxt.match(/(\d{1,2}:\d{2}(?::\d{2})?)/);
if (ratio || time) {
return {
chain_value: ratio ? ratio.value : '',
chain_max: ratio ? ratio.max : '',
chain_time_left: time ? time[1] : '',
timer_source: time ? 'tooltip' : 'tooltip_no_timer'
};
}
}
const bars = [
...qsa('a[class*="chain-bar"]'),
...qsa('a[class*="bar-mobile"][class*="chain"]'),
...qsa('a[href="#"][class*="chain"]'),
...qsa('[class*="bar-mobile"][class*="chain"]')
].filter(Boolean);
for (const bar of bars) {
const allText = safeText(bar);
if (!/\bchain\b/i.test(allText)) continue;
const nameNode = qsa('p,span,div', bar).find(n => /^chain$/i.test(safeText(n)));
if (!nameNode && !/\bchain\b/i.test(allText)) continue;
const valueNode =
qs('p[class*="bar-value"], [class*="bar-value"]', bar) ||
qsa('p,span,div', bar).find(n => parseChainRatioFromText(safeText(n)));
const timeNode =
qs('p[class*="bar-timeleft"], [class*="bar-timeleft"]', bar) ||
qsa('p,span,div', bar).find(n => /\d{1,2}:\d{2}(?::\d{2})?/.test(safeText(n)));
const valueTxt = safeText(valueNode) || allText;
const timeTxt = safeText(timeNode) || allText;
const ratio = parseChainRatioFromText(valueTxt) || parseChainRatioFromText(allText);
const time = timeTxt.match(/(\d{1,2}:\d{2}(?::\d{2})?)/);
if (ratio || time) {
return {
chain_value: ratio ? ratio.value : '',
chain_max: ratio ? ratio.max : '',
chain_time_left: time ? time[1] : '',
timer_source: time ? 'bar' : 'bar_no_timer'
};
}
}
return null;
}
function detectChainFromDom() {
const mobile = readMobileChainBar();
if (mobile) return mobile;
const chainBar = qs('a[class*="chain-bar"], a[href*="chainreport"], a[href*="war.php?step=chain"]');
if (chainBar) {
const valueNode = qs('p[class*="bar-value"], [class*="bar-value"]', chainBar);
const timeNode = qs('p[class*="bar-timeleft"], [class*="bar-timeleft"]', chainBar);
const valueTxt = safeText(valueNode);
const timeTxt = safeText(timeNode);
const ratioObj = parseChainRatioFromText(valueTxt || safeText(chainBar));
const time = (timeTxt || safeText(chainBar)).match(/(\d{1,2}:\d{2}(?::\d{2})?)/);
if (ratioObj || time) {
return {
chain_value: ratioObj ? ratioObj.value : '',
chain_max: ratioObj ? ratioObj.max : '',
chain_time_left: time ? time[1] : '',
timer_source: time ? 'desktop_bar' : 'desktop_bar_no_timer'
};
}
}
const candidates = qsa('[class*="chain"], a[href*="chainreport"], a[href*="war.php?step=chain"]');
for (const node of candidates) {
const txt = safeText(node);
if (!txt || !/chain/i.test(txt)) continue;
if (txt.length > 180) continue;
const ratioObj = parseChainRatioFromText(txt);
const time = txt.match(/(\d{1,2}:\d{2}(?::\d{2})?)/);
if (ratioObj || time) {
return {
chain_value: ratioObj ? ratioObj.value : '',
chain_max: ratioObj ? ratioObj.max : '',
chain_time_left: time ? time[1] : '',
timer_source: time ? 'fallback' : 'fallback_no_timer'
};
}
}
return null;
}
function applyChainDom(chain) {
if (!chain) return false;
let changed = false;
const mobile = isMobileWidth();
const oldChainKey = makeChainKey(mem.chain_value, mem.chain_max);
const incomingValue = chain.chain_value !== '' ? cleanString(chain.chain_value) : mem.chain_value;
const incomingMax = chain.chain_max !== '' ? cleanString(chain.chain_max) : mem.chain_max;
const incomingChainKey = makeChainKey(incomingValue, incomingMax);
const chainNumberChanged =
mobile &&
oldChainKey &&
incomingChainKey &&
incomingChainKey !== oldChainKey;
if (chainNumberChanged && !chain.chain_time_left) {
invalidateMobileChainTimer();
changed = true;
}
if (chain.chain_value !== '' && mem.chain_value !== chain.chain_value) {
mem.chain_value = chain.chain_value;
changed = true;
}
if (chain.chain_max !== '' && mem.chain_max !== chain.chain_max) {
mem.chain_max = chain.chain_max;
changed = true;
}
if (chain.chain_time_left !== '') {
const ms = parseChainTimeLeftToMs(chain.chain_time_left);
if (ms != null) {
state.chainDeadlineMs = Date.now() + ms;
state.chainLastDomText = chain.chain_time_left;
state.chainLastDomReadAt = Date.now();
state.chainTimerChainKey = makeChainKey(
chain.chain_value || mem.chain_value,
chain.chain_max || mem.chain_max
);
state.chainTimerFromTooltip = String(chain.timer_source || '').includes('tooltip');
}
if (mem.chain_time_left !== chain.chain_time_left) {
mem.chain_time_left = chain.chain_time_left;
changed = true;
}
} else if (mobile) {
const currentKey = makeChainKey(mem.chain_value, mem.chain_max);
if (state.chainTimerChainKey && currentKey && state.chainTimerChainKey !== currentKey) {
invalidateMobileChainTimer();
changed = true;
}
}
return changed;
}
function fastChainScan() {
if (state.paused) return;
const chain = detectChainFromDom();
const changed = applyChainDom(chain);
if (changed) updateChainSummaryOnly();
}
async function refreshIdentityFromActivePage() {
if (state.paused) return;
try {
const player = getPlayerFromHiddenInput();
if (player) {
mem.player_id = player.player_id;
mem.player_name = player.player_name;
mem.player_url = player.player_url;
}
const faction = readSelfFactionFromHomeOnly();
if (faction) {
mem.faction_id = faction.faction_id;
mem.faction_name = faction.faction_name;
mem.faction_url = faction.faction_url;
mem.last_identity_source = faction.source || 'Home';
}
const energy = detectEnergyFromDom();
if (energy) {
mem.energy_current = String(energy.current);
mem.energy_max = String(energy.max);
}
fastChainScan();
queuePersist();
} catch (e) {
warn('refreshIdentityFromActivePage failed', e?.message || e);
}
}
function getIdentityForSync() {
return {
player_id: cleanString(mem.player_id),
player_name: cleanString(mem.player_name),
player_url: cleanString(mem.player_url),
faction_id: cleanString(mem.faction_id),
faction_name: cleanString(mem.faction_name),
faction_url: cleanString(mem.faction_url)
};
}
function getPresencePayload() {
const identity = getIdentityForSync();
return {
...identity,
share_energy: String(mem.share_energy || '1') === '1',
energy_current: parseIntOrNull(mem.energy_current),
energy_max: parseIntOrNull(mem.energy_max),
chain_value: parseIntOrNull(mem.chain_value),
chain_max: parseIntOrNull(mem.chain_max),
chain_time_left: cleanString(mem.chain_time_left) || null
};
}
function extractDiscoveredPlayersFromDom() {
const out = new Map();
const selfId = cleanString(mem.player_id);
for (const a of qsa('a[href*="profiles.php?XID="]')) {
const href = a.getAttribute('href') || '';
const id = extractParamFromUrl(href, 'XID');
if (!isValidPlayerId(id) || id === selfId) continue;
const name = safeText(a).replace(/\[[0-9]+\]/g, '').trim();
if (!out.has(id)) out.set(id, { player_id: id, player_name: name || '', source: 'profile_link' });
}
for (const a of qsa('a[href*="user2ID="]')) {
const href = a.getAttribute('href') || '';
const id = extractParamFromUrl(href, 'user2ID');
if (!isValidPlayerId(id) || id === selfId) continue;
if (!out.has(id)) out.set(id, { player_id: id, player_name: safeText(a) || '', source: 'attack_link' });
}
return [...out.values()].slice(0, 80);
}
async function sendDiscoveredPlayers() {
if (state.paused || state.sendingDiscovery) return;
const identity = getIdentityForSync();
if (!identity.player_id) return;
const players = extractDiscoveredPlayersFromDom();
if (!players.length) return;
const signature = players.map(x => x.player_id).sort().join(',');
if (signature && signature === state.lastDiscoverySignature) return;
state.sendingDiscovery = true;
try {
await workerJson({
method: 'POST',
path: '/api/players/discovered',
data: {
source_player_id: identity.player_id,
source_faction_id: identity.faction_id || '',
players
},
timeout: 20000
});
state.lastDiscoverySignature = signature;
} catch (e) {
warn('sendDiscoveredPlayers failed', e?.message || e);
} finally {
state.sendingDiscovery = false;
}
}
function applyBoardSummary(rawBoard) {
const board = rawBoard && typeof rawBoard === 'object' ? rawBoard : {};
const onlineUsers = Array.isArray(board.online_users) ? board.online_users : [];
const watchSlots = Array.isArray(board.watch_slots)
? board.watch_slots
: Array.isArray(board.timeline) ? board.timeline : [];
const summary = board.summary && typeof board.summary === 'object' ? board.summary : {};
const sortedSlots = watchSlots
.filter(x => x && x.start_time_utc && x.end_time_utc)
.sort((a, b) => Date.parse(a.start_time_utc) - Date.parse(b.start_time_utc));
const now = Date.now();
const currentWatch = sortedSlots.find(slot => {
const s = Date.parse(slot.start_time_utc || '');
const e = Date.parse(slot.end_time_utc || '');
return Number.isFinite(s) && Number.isFinite(e) && now >= s && now < e;
}) || null;
const nextWatch = sortedSlots.find(slot => {
const s = Date.parse(slot.start_time_utc || '');
return Number.isFinite(s) && s > now;
}) || null;
mem.board = {
online_count: Number(summary.online_count || onlineUsers.length || 0),
energy_total_current: Number(summary.energy_total_current || 0),
energy_total_max: Number(summary.energy_total_max || 0),
current_watch: currentWatch,
next_watch: nextWatch,
watch_slots: sortedSlots.slice(0, 120)
};
const serverChainValue = parseIntOrNull(summary.chain_value);
const serverChainMax = parseIntOrNull(summary.chain_max);
const serverChainTime = cleanString(summary.chain_time_left);
if (!mem.chain_value && serverChainValue != null) mem.chain_value = String(serverChainValue);
if (!mem.chain_max && serverChainMax != null && serverChainMax > 0 && serverChainMax !== 10000) {
mem.chain_max = String(serverChainMax);
}
if (!mem.chain_time_left && serverChainTime) {
mem.chain_time_left = serverChainTime;
const ms = parseChainTimeLeftToMs(serverChainTime);
if (ms != null) {
state.chainDeadlineMs = Date.now() + ms;
state.chainTimerChainKey = makeChainKey(mem.chain_value, mem.chain_max);
state.chainTimerFromTooltip = false;
}
}
}
async function applyServerState(boardRes, meRes) {
if (boardRes && typeof boardRes === 'object') applyBoardSummary(boardRes.board || boardRes);
if (meRes && typeof meRes === 'object') {
const me = meRes.me || {};
mem.me = {
active_watch: me.active_watch && typeof me.active_watch === 'object' ? me.active_watch : null,
next_watch: me.next_watch && typeof me.next_watch === 'object' ? me.next_watch : null
};
if (me.share_energy != null) mem.share_energy = String(me.share_energy ? 1 : 0);
}
}
async function syncPresence() {
if (state.paused || state.syncingPresence) return;
state.syncingPresence = true;
try {
const identity = getIdentityForSync();
if (!identity.player_id || !identity.player_name || !identity.faction_id || !identity.faction_name) return;
await workerJson({
method: 'POST',
path: '/api/presence',
data: getPresencePayload(),
timeout: 20000
});
} catch (e) {
warn('syncPresence failed', e?.message || e);
} finally {
state.syncingPresence = false;
}
}
async function fetchFullState(reason = 'sync') {
if (state.paused || state.syncingBoard) return;
state.syncingBoard = true;
try {
const identity = getIdentityForSync();
if (!identity.player_id || !identity.player_name) {
await setStatus('Could not detect player.');
return;
}
if (!identity.faction_id || !identity.faction_name) {
await setStatus('Open Torn Home once so I can read your faction.');
return;
}
await setStatus('Syncing...');
const boardPath = `/api/board?faction_id=${encodeURIComponent(identity.faction_id)}`;
const mePath = `/api/me?player_id=${encodeURIComponent(identity.player_id)}&faction_id=${encodeURIComponent(identity.faction_id)}`;
const [boardRes, meRes] = await Promise.all([
workerJson({ method: 'GET', path: boardPath, timeout: 25000 }),
workerJson({ method: 'GET', path: mePath, timeout: 25000 })
]);
await applyServerState(boardRes, meRes);
fastChainScan();
await setStatus('Synced.');
renderActiveView();
updateUI();
} catch (e) {
warn('fetchFullState failed', e?.message || e);
await setStatus('Sync failed.');
} finally {
state.syncingBoard = false;
}
}
function computeNextAlignedSyncInfo() {
const now = Date.now();
const next = Math.ceil(now / PRESENCE_SYNC_EVERY_MS) * PRESENCE_SYNC_EVERY_MS;
const d = new Date(next);
return {
nextMs: next,
label: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')} UTC`
};
}
function scheduleAlignedSync() {
if (state.syncTimeoutId) clearTimeout(state.syncTimeoutId);
if (state.syncIntervalId) clearInterval(state.syncIntervalId);
const info = computeNextAlignedSyncInfo();
mem.next_sync_label = info.label;
const delay = Math.max(50, info.nextMs - Date.now());
state.syncTimeoutId = setTimeout(async () => {
if (!state.paused) {
await refreshIdentityFromActivePage();
await syncPresence();
await fetchFullState('board');
}
state.syncIntervalId = setInterval(async () => {
const i = computeNextAlignedSyncInfo();
mem.next_sync_label = i.label;
if (!state.paused) {
await refreshIdentityFromActivePage();
await syncPresence();
await fetchFullState('board');
}
}, PRESENCE_SYNC_EVERY_MS);
}, delay);
}
function getCurrentWatch() {
return mem.board?.current_watch || null;
}
function getNextWatch() {
return mem.board?.next_watch || null;
}
function getFactionEnergyTotal() {
return Number(mem.board?.energy_total_current || 0);
}
function getMyWatchPlan() {
const startMode = String(mem.watch_start_mode || 'now');
const startHHMM = startMode === 'custom'
? (normalizeTime(mem.watch_start_hhmm || '') || floorNowToSlotHHMM())
: floorNowToSlotHHMM();
const durationHours = Math.max(0.5, Math.min(12, Number(mem.watch_duration_hours || 1) || 1));
const startMinutes = hhmmToMinutes(startHHMM);
if (startMinutes == null) return null;
const now = new Date();
const start = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0, 0, 0, 0
) + startMinutes * 60000);
if (startMode === 'custom' && start.getTime() < Date.now() - 60 * 1000) start.setUTCDate(start.getUTCDate() + 1);
const end = new Date(start.getTime() + durationHours * 60 * 60 * 1000);
return {
start_iso: start.toISOString(),
end_iso: end.toISOString(),
start_hhmm: `${String(start.getUTCHours()).padStart(2, '0')}:${String(start.getUTCMinutes()).padStart(2, '0')}`,
end_hhmm: `${String(end.getUTCHours()).padStart(2, '0')}:${String(end.getUTCMinutes()).padStart(2, '0')}`
};
}
async function createWatch() {
if (state.paused || state.syncingWatch) return;
state.syncingWatch = true;
try {
const identity = getIdentityForSync();
if (!identity.player_id || !identity.faction_id) {
await setStatus('Could not detect player/faction.');
return;
}
const plan = getMyWatchPlan();
if (!plan) {
await setStatus('Invalid watch time.');
return;
}
await setStatus('Saving watch...');
await workerJson({
method: 'POST',
path: '/api/watch',
data: {
...getPresencePayload(),
start_time_utc: plan.start_iso,
end_time_utc: plan.end_iso
},
timeout: 25000
});
await fetchFullState('watch');
} catch (e) {
await setStatus('Save failed.');
} finally {
state.syncingWatch = false;
}
}
async function clearMyWatch() {
if (state.paused || state.syncingWatch) return;
state.syncingWatch = true;
try {
const identity = getIdentityForSync();
if (!identity.player_id || !identity.faction_id) {
await setStatus('Could not detect player/faction.');
return;
}
await setStatus('Clearing watch...');
await workerJson({
method: 'POST',
path: '/api/watch/clear',
data: {
player_id: identity.player_id,
player_name: identity.player_name,
player_url: identity.player_url,
faction_id: identity.faction_id,
faction_name: identity.faction_name,
faction_url: identity.faction_url,
clear_all_mine: true
},
timeout: 25000
});
await fetchFullState('clear-watch');
} catch (e) {
await setStatus('Clear failed.');
} finally {
state.syncingWatch = false;
}
}
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function removeTargetFromList(targetId) {
const id = String(targetId || '');
if (!id) return;
mem.target_rows = (Array.isArray(mem.target_rows) ? mem.target_rows : [])
.filter(row => String(row.player_id || '') !== id);
await saveTargetCache(true);
if (mem.ui_mode === 'targets') renderActiveView();
}
async function markTargetOpened(targetId) {
try {
const identity = getIdentityForSync();
if (!identity.player_id || !targetId) return;
await workerJson({
method: 'POST',
path: '/api/targets/opened',
data: {
player_id: identity.player_id,
target_ids: [String(targetId)]
},
timeout: 15000
});
} catch {}
}
async function reportFfFailed(targetIds, reason = 'not_returned_by_ffscouter') {
try {
const ids = Array.from(new Set((targetIds || []).map(String).filter(isValidPlayerId))).slice(0, 100);
if (!ids.length) return;
await workerJson({
method: 'POST',
path: '/api/targets/ff-failed',
data: {
failed_target_ids: ids,
reason
},
timeout: 15000
});
} catch {}
}
function normalizeFfRows(raw) {
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw?.data)) return raw.data;
if (Array.isArray(raw?.results)) return raw.results;
if (Array.isArray(raw?.targets)) return raw.targets;
if (raw && typeof raw === 'object') return Object.values(raw).filter(x => x && typeof x === 'object');
return [];
}
function readFfRowPlayerId(row) {
return Number(row?.player_id || row?.id || row?.user_id || row?.target_id || row?.XID || 0);
}
function readFfScore(row) {
return parseFloatOrNull(row?.fair_fight ?? row?.fairFight ?? row?.ff ?? row?.score);
}
function ffLabelFromScore(ffScore) {
if (ffScore <= 1) return 'Extremely easy';
if (ffScore <= 2) return 'Easy';
if (ffScore <= 3.5) return 'Moderately difficult';
if (ffScore <= 4.5) return 'Difficult';
return 'May be impossible';
}
async function loadTargets() {
if (state.paused || state.syncingTargets) return;
state.syncingTargets = true;
try {
const identity = getIdentityForSync();
const ffKey = cleanString(mem.ff_api_key);
if (!identity.player_id) {
mem.targets_error = 'Could not detect player.';
mem.targets_loading = '';
renderActiveView();
return;
}
if (!identity.faction_id) {
mem.targets_error = 'Could not detect faction. Open Torn Home once.';
mem.targets_loading = '';
renderActiveView();
return;
}
if (!ffKey) {
mem.targets_error = 'Enter your FFScouter API key from Settings on faction page.';
mem.targets_loading = '';
renderActiveView();
return;
}
const minFF = parseFloatOrNull(mem.ff_min);
const maxFF = parseFloatOrNull(mem.ff_max);
const targetCount = clamp(parseIntOrNull(mem.target_count) || DEFAULT_TARGET_COUNT, 1, 25);
mem.targets_loading = '1';
mem.targets_error = '';
mem.target_rows = [];
renderActiveView();
const rows = [];
const matchedIds = new Set();
let offset = 0;
for (let batchIndex = 0; batchIndex < TARGET_MAX_BATCHES && rows.length < targetCount; batchIndex++) {
const workerPath =
`/api/targets/candidates?player_id=${encodeURIComponent(identity.player_id)}` +
`&faction_id=${encodeURIComponent(identity.faction_id)}` +
`&limit=${FF_BATCH_SIZE}&offset=${offset}`;
const candidateRes = await workerJson({
method: 'GET',
path: workerPath,
timeout: 30000
});
const candidates = Array.isArray(candidateRes?.candidates) ? candidateRes.candidates : [];
if (!candidates.length) break;
offset = Number(candidateRes?.next_offset || (offset + candidates.length));
const ids = candidates.map(x => Number(x.player_id)).filter(Boolean);
if (!ids.length) continue;
for (const idsBatch of chunk(ids, FF_BATCH_SIZE)) {
if (rows.length >= targetCount) break;
const ffUrl = `https://ffscouter.com/api/v1/get-stats?key=${encodeURIComponent(ffKey)}&targets=${idsBatch.join(',')}`;
const ffRes = await xhrJson({
method: 'GET',
url: ffUrl,
headers: {
'Accept': 'application/json,text/plain,*/*',
'Cache-Control': 'no-cache'
},
timeout: 45000
});
const ffRows = normalizeFfRows(ffRes);
const returnedIds = new Set();
for (const row of ffRows) {
const playerId = readFfRowPlayerId(row);
const ffScore = readFfScore(row);
if (!playerId || ffScore == null) continue;
returnedIds.add(String(playerId));
if (matchedIds.has(String(playerId))) continue;
if (minFF != null && ffScore < minFF) continue;
if (maxFF != null && ffScore > maxFF) continue;
const source = candidates.find(c => Number(c.player_id) === playerId) || {};
rows.push({
player_id: playerId,
player_name: cleanString(source.player_name || row.name || row.player_name || `ID ${playerId}`),
player_level: parseIntOrNull(source.player_level || row.level),
player_faction_id: cleanString(source.player_faction_id || ''),
player_faction_name: cleanString(source.player_faction_name || ''),
ff_score: ffScore,
ff_label: ffLabelFromScore(ffScore),
profile_url: playerProfileUrl(playerId),
attack_url: `https://www.torn.com/loader.php?sid=attack&user2ID=${encodeURIComponent(playerId)}`
});
matchedIds.add(String(playerId));
if (rows.length >= targetCount) break;
}
const missing = idsBatch.map(String).filter(id => !returnedIds.has(id));
if (missing.length) reportFfFailed(missing, 'not_returned_by_ffscouter').catch(() => {});
}
}
rows.sort((a, b) => (a.ff_score ?? 999) - (b.ff_score ?? 999));
mem.target_rows = rows.slice(0, targetCount);
mem.targets_loading = '';
mem.targets_error = mem.target_rows.length ? '' : 'No targets matched your FF range.';
await saveTargetCache(true);
renderActiveView();
} catch (e) {
mem.targets_loading = '';
mem.targets_error = 'Target load failed.';
renderActiveView();
} finally {
state.syncingTargets = false;
}
}
async function clearTargets() {
mem.target_rows = [];
mem.targets_error = '';
await saveTargetCache(true);
renderActiveView();
}
function getCountdownTarget() {
if (state.chainDeadlineMs && Number.isFinite(state.chainDeadlineMs)) {
const currentKey = makeChainKey(mem.chain_value, mem.chain_max);
if (isMobileWidth() && state.chainTimerChainKey && currentKey && state.chainTimerChainKey !== currentKey) {
invalidateMobileChainTimer();
return null;
}
if (state.chainDeadlineMs <= Date.now()) {
if (isMobileWidth()) invalidateMobileChainTimer();
return null;
}
return { target_ms: state.chainDeadlineMs, type: 'chain' };
}
const chainLeft = parseChainTimeLeftToMs(mem.chain_time_left);
if (chainLeft != null) {
state.chainDeadlineMs = Date.now() + chainLeft;
state.chainTimerChainKey = makeChainKey(mem.chain_value, mem.chain_max);
return { target_ms: state.chainDeadlineMs, type: 'chain' };
}
return null;
}
function refreshCountdown() {
const target = getCountdownTarget();
const countEl = qs('#cc-countdown');
const headEl = qs('#cc-header-countdown');
if (!target?.target_ms) {
if (countEl) {
countEl.textContent = '--:--:--';
countEl.className = 'cc-main-value';
}
if (headEl) headEl.textContent = '--:--:--';
if (miniBtn) {
miniBtn.textContent = '⏱';
setMiniBtnStyleByRemaining(null);
positionMiniButton();
}
return;
}
const ms = Math.max(0, target.target_ms - Date.now());
const txt = formatDuration(ms);
if (countEl) {
countEl.textContent = txt;
countEl.className = `cc-main-value ${countdownToneClass(ms)}`;
}
if (headEl) headEl.textContent = txt;
if (miniBtn) {
miniBtn.textContent = formatMiniCountdown(ms);
setMiniBtnStyleByRemaining(ms);
positionMiniButton();
}
}
function updateChainSummaryOnly() {
const summaryEl = qs('#cc-chain-summary-line');
if (summaryEl) {
summaryEl.textContent = `Chain: ${mem.chain_value || '-'} / ${mem.chain_max || '-'} • Timer: ${mem.chain_time_left || '-'}`;
}
refreshCountdown();
}
function setUiMode(mode) {
mem.ui_mode = mode;
queuePersist();
renderActiveView();
}
function makeDurationOptionsHtml(selectedValue = '1') {
const vals = [0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 12];
const selected = String(selectedValue || '1');
return vals.map(v => {
const label = Number.isInteger(v) ? `${v} hour${v === 1 ? '' : 's'}` : `${v} hours`;
return `<option value="${v}"${selected === String(v) ? ' selected' : ''}>${label}</option>`;
}).join('');
}
function makeTctOptionsHtml(selectedValue = '') {
const selected = normalizeTime(selectedValue || '') || '';
const out = [];
for (let m = 0; m < 24 * 60; m += SLOT_MINUTES) {
const hhmm = minutesToHHMM(m);
out.push(`<option value="${hhmm}"${selected === hhmm ? ' selected' : ''}>${hhmm} TCT</option>`);
}
return out.join('');
}
function renderSummaryView() {
const host = qs('#cc-view-host');
if (!host) return;
const onlineCount = Number(mem.board?.online_count || 0);
const current = getCurrentWatch();
const next = getNextWatch();
const myActive = mem.me?.active_watch || null;
const factionEnergy = getFactionEnergyTotal();
const currentWatchHtml = current
? `
<div class="cc-main-value cc-wrap-value">
${esc(current.player_name || '-')}
<span class="cc-small-muted">${esc(formatWatchUntil(current.end_time_utc))}</span>
</div>
`
: `<div class="cc-main-value cc-wrap-value">-</div>`;
host.innerHTML = `
<div class="cc-view cc-view-summary">
<div class="cc-section">
<div class="cc-main-row">
<div class="cc-main-block">
<div class="cc-main-label">Online now</div>
<div class="cc-main-value">${onlineCount}</div>
</div>
<div class="cc-main-block">
<div class="cc-main-label">Current watch</div>
${currentWatchHtml}
</div>
</div>
<div class="cc-summary-subline" style="margin-top:8px;">Faction energy: ${factionEnergy}</div>
</div>
<div class="cc-section">
<div class="cc-main-row">
<div class="cc-main-block">
<div class="cc-main-label">Next watch</div>
<div class="cc-main-value cc-wrap-value">${next ? esc(formatIsoAsTct(next.start_time_utc)) : '-'}</div>
</div>
<div class="cc-main-block">
<div class="cc-main-label">Chain timer</div>
<div class="cc-main-value" id="cc-countdown">--:--:--</div>
</div>
</div>
</div>
<div class="cc-section">
<div class="cc-main-label" style="margin-bottom:6px;">Chain summary</div>
<div class="cc-main-value cc-wrap-value" id="cc-chain-summary-line">Chain: ${esc(mem.chain_value || '-')} / ${esc(mem.chain_max || '-')} • Timer: ${esc(mem.chain_time_left || '-')}</div>
<div class="cc-summary-subline">My watch: ${myActive ? esc(formatIsoRangeAsTct(myActive.start_time_utc, myActive.end_time_utc)) : '-'}</div>
<div class="cc-toolbar-row" style="margin-top:8px;">
<button type="button" id="cc-open-watch-btn" class="cc-small-btn cc-grow-btn">Watch</button>
<button type="button" id="cc-open-targets-btn" class="cc-small-btn cc-grow-btn">Find target</button>
</div>
</div>
<div id="cc-status">-</div>
</div>
`;
qs('#cc-open-watch-btn', host)?.addEventListener('click', () => setUiMode('watch'));
qs('#cc-open-targets-btn', host)?.addEventListener('click', () => setUiMode('targets'));
refreshCountdown();
}
function renderGraph() {
const startMs = floorToUtcHalfHour(Date.now());
const slots = Array.isArray(mem.board?.watch_slots) ? mem.board.watch_slots : [];
const cells = [];
for (let i = 0; i < GRAPH_BAR_COUNT; i++) {
const cellStart = startMs + i * TIMELINE_CELL_MINUTES * 60000;
const cellEnd = cellStart + TIMELINE_CELL_MINUTES * 60000;
const cellMid = cellStart + (cellEnd - cellStart) / 2;
const names = [];
for (const slot of slots) {
const s = Date.parse(slot.start_time_utc || '');
const e = Date.parse(slot.end_time_utc || '');
if (!Number.isFinite(s) || !Number.isFinite(e)) continue;
if (s <= cellMid && e > cellMid) names.push(slot.player_name || '-');
}
const count = names.length;
const d = new Date(cellStart);
const hhmm = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
const title = `${hhmm} UTC • ${count ? names.join(', ') : 'gap'}`;
let cls = 'gap';
if (count >= 3) cls = 'high';
else if (count === 2) cls = 'mid';
else if (count === 1) cls = 'low';
cells.push(`<div class="cc-graph-cell ${cls}" title="${esc(title)}"></div>`);
}
return `
<div class="cc-graph-head"><span>Now</span><span>+24h</span></div>
<div class="cc-graph-wrap">${cells.join('')}</div>
<div class="cc-graph-legend">
<span><i class="cc-dot gap"></i>Gap</span>
<span><i class="cc-dot low"></i>1 watcher</span>
<span><i class="cc-dot mid"></i>+2</span>
<span><i class="cc-dot high"></i>+3</span>
</div>
`;
}
function renderTimelineRows() {
const slots = Array.isArray(mem.board?.watch_slots) ? mem.board.watch_slots : [];
const current = getCurrentWatch();
if (!slots.length) return `<div class="cc-empty-day">No watch slots yet.</div>`;
return slots.slice(0, 20).map(slot => {
const active = current && current.player_id === slot.player_id && current.start_time_utc === slot.start_time_utc;
return `
<div class="cc-timeline-row${active ? ' active' : ''}">
<div class="cc-timeline-main">
<div class="cc-timeline-title">${esc(slot.player_name || '-')}</div>
<div class="cc-timeline-sub">${esc(formatIsoRangeAsTct(slot.start_time_utc, slot.end_time_utc))}</div>
</div>
</div>
`;
}).join('');
}
function renderWatchView() {
const host = qs('#cc-view-host');
if (!host) return;
const plan = getMyWatchPlan();
const myActive = mem.me?.active_watch || null;
const onlineCount = Number(mem.board?.online_count || 0);
const factionEnergy = getFactionEnergyTotal();
host.innerHTML = `
<div class="cc-view cc-view-schedule">
<div class="cc-section cc-schedule-section">
<div class="cc-editor-meta">
<div class="cc-editor-meta-item">
<div class="cc-main-label">Online now</div>
<div class="cc-main-value">${onlineCount}</div>
</div>
<div class="cc-editor-meta-item">
<div class="cc-main-label">Faction energy</div>
<div class="cc-main-value">${factionEnergy}</div>
</div>
</div>
<div class="cc-ranges-title">My watch</div>
<div class="cc-watch-mode-row">
<button type="button" class="cc-week-tab ${String(mem.watch_start_mode || 'now') === 'now' ? 'active' : ''}" id="cc-start-now-btn">Now</button>
<button type="button" class="cc-week-tab ${String(mem.watch_start_mode || 'now') === 'custom' ? 'active' : ''}" id="cc-start-custom-btn">Custom</button>
</div>
<div class="cc-range-row watch-time-row">
<select class="cc-range-select" id="cc-watch-start-select" ${String(mem.watch_start_mode || 'now') === 'custom' ? '' : 'disabled'}>
${makeTctOptionsHtml(String(mem.watch_start_hhmm || floorNowToSlotHHMM()))}
</select>
<span class="cc-range-sep">→</span>
<select class="cc-range-select" id="cc-watch-duration-select">
${makeDurationOptionsHtml(mem.watch_duration_hours || '1')}
</select>
</div>
<div class="cc-empty-day">Planned: ${plan ? `${plan.start_hhmm} → ${plan.end_hhmm}` : '-'}</div>
<div class="cc-toolbar-row" style="margin-top:8px;">
<button type="button" id="cc-create-watch-btn" class="cc-small-btn cc-grow-btn cc-save-watch-main">Save watch</button>
</div>
${myActive ? `
<div class="cc-toolbar-row" style="margin-top:8px;">
<button type="button" id="cc-clear-watch-btn" class="cc-small-btn cc-grow-btn cc-clear-watch-main">Clear my watch</button>
</div>
` : ''}
<div class="cc-ranges-title" style="margin-top:14px;">Coverage graph</div>
${renderGraph()}
<div class="cc-ranges-title" style="margin-top:12px;">Timeline</div>
<div class="cc-range-list">${renderTimelineRows()}</div>
<div class="cc-toolbar-row" style="margin-top:10px;">
<button type="button" id="cc-open-home-btn" class="cc-small-btn cc-grow-btn">Home</button>
<button type="button" id="cc-open-targets-btn" class="cc-small-btn cc-grow-btn">Find target</button>
</div>
</div>
<div id="cc-status">-</div>
</div>
`;
qs('#cc-start-now-btn', host)?.addEventListener('click', () => {
mem.watch_start_mode = 'now';
queuePersist();
renderWatchView();
});
qs('#cc-start-custom-btn', host)?.addEventListener('click', () => {
mem.watch_start_mode = 'custom';
if (!normalizeTime(mem.watch_start_hhmm || '')) mem.watch_start_hhmm = floorNowToSlotHHMM();
queuePersist();
renderWatchView();
});
qs('#cc-watch-start-select', host)?.addEventListener('change', (e) => {
mem.watch_start_hhmm = normalizeTime(e.target.value || '') || floorNowToSlotHHMM();
queuePersist();
renderWatchView();
});
qs('#cc-watch-duration-select', host)?.addEventListener('change', (e) => {
mem.watch_duration_hours = String(e.target.value || '1');
queuePersist();
renderWatchView();
});
qs('#cc-create-watch-btn', host)?.addEventListener('click', () => createWatch().catch(() => {}));
qs('#cc-clear-watch-btn', host)?.addEventListener('click', () => clearMyWatch().catch(() => {}));
qs('#cc-open-home-btn', host)?.addEventListener('click', () => setUiMode('summary'));
qs('#cc-open-targets-btn', host)?.addEventListener('click', () => setUiMode('targets'));
}
function renderTargetRows() {
if (mem.targets_loading === '1') return `<div class="cc-empty-day">Scanning targets...</div>`;
if (mem.targets_error) return `<div class="cc-empty-day">${esc(mem.targets_error)}</div>`;
const rows = Array.isArray(mem.target_rows) ? mem.target_rows : [];
if (!rows.length) return `<div class="cc-empty-day">No targets loaded yet.</div>`;
return rows.map(row => `
<div class="cc-target-row">
<div class="cc-target-main">
<div class="cc-target-title">${esc(row.player_name || `ID ${row.player_id}`)}</div>
<div class="cc-target-sub">
${row.player_level != null ? `Lvl ${esc(row.player_level)} • ` : ''}
${row.player_faction_name ? `${esc(row.player_faction_name)} • ` : ''}
${esc(row.ff_label || '-')}
${row.ff_score != null ? ` • FF ${Number(row.ff_score).toFixed(2)}` : ''}
</div>
</div>
<div class="cc-target-actions">
<a class="cc-target-btn attack" href="${esc(row.attack_url)}" target="_blank" rel="noopener noreferrer" data-use-target-id="${esc(row.player_id)}">Attack</a>
<a class="cc-target-btn profile" href="${esc(row.profile_url)}" target="_blank" rel="noopener noreferrer" data-use-target-id="${esc(row.player_id)}">Profile</a>
</div>
</div>
`).join('');
}
function renderTargetsView() {
const host = qs('#cc-view-host');
if (!host) return;
const hasList = Array.isArray(mem.target_rows) && mem.target_rows.length > 0;
const ffMissingHtml = mem.ff_api_key
? ``
: `<div class="cc-section cc-alert-section"><div class="cc-empty-day">Add your FFScouter API key from Settings on your faction page.</div></div>`;
host.innerHTML = `
<div class="cc-view cc-view-schedule">
<div class="cc-section cc-schedule-section">
${ffMissingHtml}
<div class="cc-ranges-title">Target finder</div>
<div class="cc-main-label cc-ff-label">Fair Fight:</div>
<div class="cc-range-row targets">
<input class="cc-range-input" id="cc-ff-min" type="text" inputmode="decimal" placeholder="Min FF" value="${esc(mem.ff_min || DEFAULT_FF_MIN)}">
<span class="cc-range-sep">→</span>
<input class="cc-range-input" id="cc-ff-max" type="text" inputmode="decimal" placeholder="Max FF" value="${esc(mem.ff_max || DEFAULT_FF_MAX)}">
</div>
<div class="cc-toolbar-row" style="margin-top:10px;">
<button type="button" id="cc-load-targets-btn" class="cc-small-btn cc-grow-btn">${hasList ? 'Refresh' : 'Scan'}</button>
${hasList ? `<button type="button" id="cc-clear-targets-btn" class="cc-small-btn cc-grow-btn danger">Clear list</button>` : ''}
</div>
<div class="cc-range-list" style="margin-top:12px;">
${renderTargetRows()}
</div>
<div class="cc-toolbar-row" style="margin-top:10px;">
<button type="button" id="cc-targets-home-btn" class="cc-small-btn cc-grow-btn">Home</button>
<button type="button" id="cc-targets-watch-btn" class="cc-small-btn cc-grow-btn">Watch</button>
</div>
</div>
<div id="cc-status">-</div>
</div>
`;
qs('#cc-ff-min', host)?.addEventListener('input', (e) => {
mem.ff_min = String(e.target.value || '').replace(/[^0-9.]/g, '') || DEFAULT_FF_MIN;
queuePersist();
});
qs('#cc-ff-max', host)?.addEventListener('input', (e) => {
mem.ff_max = String(e.target.value || '').replace(/[^0-9.]/g, '') || DEFAULT_FF_MAX;
queuePersist();
});
qs('#cc-load-targets-btn', host)?.addEventListener('click', () => loadTargets().catch(() => {}));
qs('#cc-clear-targets-btn', host)?.addEventListener('click', () => clearTargets().catch(() => {}));
qsa('[data-use-target-id]', host).forEach(a => {
a.addEventListener('click', () => {
const id = a.getAttribute('data-use-target-id');
removeTargetFromList(id).catch(() => {});
markTargetOpened(id).catch(() => {});
});
});
qs('#cc-targets-home-btn', host)?.addEventListener('click', () => setUiMode('summary'));
qs('#cc-targets-watch-btn', host)?.addEventListener('click', () => setUiMode('watch'));
}
function renderActiveView() {
try {
if (!qs('#cc-view-host')) return;
if (mem.ui_mode === 'watch') renderWatchView();
else if (mem.ui_mode === 'targets') renderTargetsView();
else renderSummaryView();
updateUI();
} catch (e) {
errlog('renderActiveView failed', e);
}
}
function updateUI() {
const subtitle = qs('#cc-subtitle');
if (subtitle) {
if (mem.faction_name) {
subtitle.textContent = `${mem.player_name || 'Unknown'} • ${mem.faction_name}`;
} else {
subtitle.textContent = `${mem.player_name || 'Unknown'} • Open Torn Home once`;
}
}
const statusEl = qs('#cc-status');
if (statusEl) {
statusEl.textContent = state.paused ? 'Stopped.' : (mem.last_status || 'Waiting...');
}
if (panel) {
panel.classList.toggle('cc-compact', window.innerWidth <= 768);
panel.classList.toggle('cc-paused', state.paused);
}
refreshCountdown();
applyVisibilityState();
updateSettingsButtonVisibility();
}
function openSettingsModal() {
if (!isFactionYourPage()) return;
closeSettingsModal();
settingsModal = document.createElement('div');
settingsModal.id = 'cc-settings-modal';
const startStopClass = state.paused ? 'start' : 'stop';
const startStopText = state.paused ? 'Start' : 'Stop';
settingsModal.innerHTML = `
<div id="cc-settings-backdrop"></div>
<div id="cc-settings-card">
<div id="cc-settings-head">
<div>
<div id="cc-settings-title">Chain cordinator settings</div>
<div id="cc-settings-sub">Only available on faction page</div>
</div>
<button type="button" id="cc-settings-close">✕</button>
</div>
<div class="cc-settings-body">
<label class="cc-settings-field">
<span>FFScouter API key</span>
<input id="cc-set-ff-key" type="text" value="${esc(mem.ff_api_key || '')}" placeholder="Enter FFScouter API key">
</label>
<div class="cc-settings-grid">
<label class="cc-settings-field">
<span>Min FF</span>
<input id="cc-set-ff-min" type="text" inputmode="decimal" value="${esc(mem.ff_min || DEFAULT_FF_MIN)}">
</label>
<label class="cc-settings-field">
<span>Max FF</span>
<input id="cc-set-ff-max" type="text" inputmode="decimal" value="${esc(mem.ff_max || DEFAULT_FF_MAX)}">
</label>
</div>
<div class="cc-settings-grid">
<label class="cc-settings-field">
<span>Target count</span>
<input id="cc-set-target-count" type="text" inputmode="numeric" value="${esc(mem.target_count || DEFAULT_TARGET_COUNT)}">
</label>
<label class="cc-settings-field">
<span>Default watch hours</span>
<input id="cc-set-watch-hours" type="text" inputmode="decimal" value="${esc(mem.watch_duration_hours || '1')}">
</label>
</div>
<label class="cc-settings-field">
<span>Share energy</span>
<select id="cc-set-share-energy">
<option value="1"${String(mem.share_energy || '1') === '1' ? ' selected' : ''}>Yes</option>
<option value="0"${String(mem.share_energy || '1') === '0' ? ' selected' : ''}>No</option>
</select>
</label>
<div class="cc-settings-actions">
<button type="button" id="cc-save-settings">Save</button>
<button type="button" id="cc-reset-position">Reset position</button>
<button type="button" id="cc-toggle-pause-now" class="cc-start-stop-btn ${startStopClass}">${startStopText}</button>
</div>
</div>
</div>
`;
(document.body || document.documentElement).appendChild(settingsModal);
qs('#cc-settings-close', settingsModal)?.addEventListener('click', closeSettingsModal);
qs('#cc-settings-backdrop', settingsModal)?.addEventListener('click', closeSettingsModal);
qs('#cc-save-settings', settingsModal)?.addEventListener('click', async () => {
mem.ff_api_key = cleanString(qs('#cc-set-ff-key', settingsModal)?.value || '');
mem.ff_min = cleanString(qs('#cc-set-ff-min', settingsModal)?.value || DEFAULT_FF_MIN).replace(/[^0-9.]/g, '') || DEFAULT_FF_MIN;
mem.ff_max = cleanString(qs('#cc-set-ff-max', settingsModal)?.value || DEFAULT_FF_MAX).replace(/[^0-9.]/g, '') || DEFAULT_FF_MAX;
mem.target_count = cleanString(qs('#cc-set-target-count', settingsModal)?.value || DEFAULT_TARGET_COUNT).replace(/[^0-9]/g, '') || String(DEFAULT_TARGET_COUNT);
mem.watch_duration_hours = cleanString(qs('#cc-set-watch-hours', settingsModal)?.value || '1').replace(/[^0-9.]/g, '') || '1';
mem.share_energy = String(qs('#cc-set-share-energy', settingsModal)?.value || '1') === '0' ? '0' : '1';
await persistNow();
closeSettingsModal();
renderActiveView();
updateUI();
if (!state.paused) {
syncPresence().catch(() => {});
fetchFullState('settings').catch(() => {});
}
});
qs('#cc-reset-position', settingsModal)?.addEventListener('click', () => {
state.panelX = null;
state.panelY = null;
state.miniAnchorX = 'right';
state.miniAnchorY = 'bottom';
state.miniRight = 18;
state.miniBottom = 52;
state.miniLeft = 18;
state.miniTop = 80;
state.settingsAnchorX = 'right';
state.settingsAnchorY = 'bottom';
state.settingsRight = 14;
state.settingsBottom = 104;
state.settingsLeft = 14;
state.settingsTop = 120;
positionPanelDefault();
positionMiniButton();
positionSettingsButton();
queuePersist();
});
qs('#cc-toggle-pause-now', settingsModal)?.addEventListener('click', async () => {
setPaused(!state.paused);
await persistNow();
closeSettingsModal();
updateUI();
if (!state.paused) {
syncPresence().catch(() => {});
fetchFullState('start').catch(() => {});
}
});
}
function closeSettingsModal() {
if (settingsModal) {
try { settingsModal.remove(); } catch {}
settingsModal = null;
}
}
function setPaused(next) {
state.paused = !!next;
if (state.paused) {
state.hidden = true;
mem.last_status = 'Stopped.';
} else {
state.hidden = false;
mem.last_status = 'Started.';
}
queuePersist();
applyVisibilityState();
updateUI();
}
function positionPanelDefault() {
if (!panel) return;
const defaultX = window.innerWidth - 290;
const defaultY = 16;
const x = clamp(state.panelX ?? defaultX, 0, Math.max(0, window.innerWidth - 260));
const y = clamp(state.panelY ?? defaultY, 0, Math.max(0, window.innerHeight - 80));
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
}
function buildPanel() {
if (document.getElementById('cc-panel')) {
panel = document.getElementById('cc-panel');
return;
}
panel = document.createElement('div');
panel.id = 'cc-panel';
if (state.collapsed) panel.classList.add('collapsed');
panel.innerHTML = `
<div id="cc-header">
<div id="cc-header-icon">⏱</div>
<div id="cc-header-title-wrap">
<div id="cc-header-title">Chain cordinator</div>
<div id="cc-header-countdown">--:--:--</div>
</div>
<div id="cc-header-actions">
<button class="cc-hdr-btn" id="cc-collapse-btn" title="Collapse / Expand">${state.collapsed ? '▲' : '▼'}</button>
<button class="cc-hdr-btn" id="cc-hide-btn" title="Minimize">✕</button>
</div>
</div>
<div id="cc-body">
<div id="cc-subtitle">Waiting for active page data...</div>
<div id="cc-view-host"></div>
</div>
`;
(document.body || document.documentElement).appendChild(panel);
positionPanelDefault();
const header = qs('#cc-header', panel);
const collapseBtn = qs('#cc-collapse-btn', panel);
const hideBtn = qs('#cc-hide-btn', panel);
makeDraggable(header, panel, 'panel');
collapseBtn?.addEventListener('click', (e) => {
e.stopPropagation();
state.collapsed = !state.collapsed;
panel.classList.toggle('collapsed', state.collapsed);
collapseBtn.textContent = state.collapsed ? '▲' : '▼';
queuePersist();
updateUI();
});
hideBtn?.addEventListener('click', (e) => {
e.stopPropagation();
state.hidden = true;
queuePersist();
applyVisibilityState();
updateUI();
});
renderActiveView();
applyVisibilityState();
}
function ensureMiniButton() {
if (miniBtn) return;
miniBtn = document.createElement('button');
miniBtn.id = 'cc-mini-btn';
miniBtn.textContent = '⏱';
miniBtn.title = 'Open Chain cordinator';
(document.body || document.documentElement).appendChild(miniBtn);
makeDraggable(miniBtn, miniBtn, 'mini');
miniBtn.addEventListener('click', (e) => {
if (miniBtn.__dragMoved) {
e.preventDefault();
e.stopPropagation();
return;
}
if (state.paused) return;
state.hidden = false;
state.collapsed = false;
if (panel) panel.classList.remove('collapsed');
queuePersist();
applyVisibilityState();
updateUI();
});
positionMiniButton();
}
function ensureSettingsButton() {
if (settingsBtn) return;
settingsBtn = document.createElement('button');
settingsBtn.id = 'cc-settings-launcher';
settingsBtn.textContent = '⚙ Chain';
settingsBtn.title = 'Chain cordinator settings';
(document.body || document.documentElement).appendChild(settingsBtn);
makeDraggable(settingsBtn, settingsBtn, 'settings');
settingsBtn.addEventListener('click', (e) => {
if (settingsBtn.__dragMoved) {
e.preventDefault();
e.stopPropagation();
return;
}
openSettingsModal();
});
updateSettingsButtonVisibility();
positionSettingsButton();
}
function updateSettingsButtonVisibility() {
if (!settingsBtn) return;
settingsBtn.style.display = isFactionYourPage() ? 'flex' : 'none';
if (isFactionYourPage()) positionSettingsButton();
}
function setMiniBtnStyleByRemaining(ms) {
if (!miniBtn) return;
miniBtn.classList.remove(
'cc-mini-blue',
'cc-mini-green',
'cc-mini-yellow',
'cc-mini-orange',
'cc-mini-red',
'cc-mini-icon-mode',
'cc-mini-text-mode'
);
const txt = String(miniBtn.textContent || '');
const iconMode = txt === '⏱';
miniBtn.classList.add(iconMode ? 'cc-mini-icon-mode' : 'cc-mini-text-mode');
miniBtn.classList.add(miniToneClass(ms));
}
function positionMiniButton() {
if (!miniBtn) return;
const width = miniBtn.offsetWidth || 44;
const height = miniBtn.offsetHeight || 44;
let x;
let y;
if (state.miniAnchorX === 'left') x = clamp(state.miniLeft, 0, Math.max(0, window.innerWidth - width));
else x = clamp(window.innerWidth - width - state.miniRight, 0, Math.max(0, window.innerWidth - width));
if (state.miniAnchorY === 'top') y = clamp(state.miniTop, 0, Math.max(0, window.innerHeight - height));
else y = clamp(window.innerHeight - height - state.miniBottom, 0, Math.max(0, window.innerHeight - height));
miniBtn.style.left = `${x}px`;
miniBtn.style.top = `${y}px`;
}
function positionSettingsButton() {
if (!settingsBtn) return;
const width = settingsBtn.offsetWidth || 74;
const height = settingsBtn.offsetHeight || 34;
let x;
let y;
if (state.settingsAnchorX === 'left') x = clamp(state.settingsLeft, 0, Math.max(0, window.innerWidth - width));
else x = clamp(window.innerWidth - width - state.settingsRight, 0, Math.max(0, window.innerWidth - width));
if (state.settingsAnchorY === 'top') y = clamp(state.settingsTop, 0, Math.max(0, window.innerHeight - height));
else y = clamp(window.innerHeight - height - state.settingsBottom, 0, Math.max(0, window.innerHeight - height));
settingsBtn.style.left = `${x}px`;
settingsBtn.style.top = `${y}px`;
settingsBtn.style.right = 'auto';
settingsBtn.style.bottom = 'auto';
}
function updateAnchorFromPosition(kind, x, y, width, height) {
const distLeft = x;
const distRight = window.innerWidth - (x + width);
const distTop = y;
const distBottom = window.innerHeight - (y + height);
if (kind === 'mini') {
if (distLeft <= distRight) {
state.miniAnchorX = 'left';
state.miniLeft = Math.max(0, distLeft);
} else {
state.miniAnchorX = 'right';
state.miniRight = Math.max(0, distRight);
}
if (distTop <= distBottom) {
state.miniAnchorY = 'top';
state.miniTop = Math.max(0, distTop);
} else {
state.miniAnchorY = 'bottom';
state.miniBottom = Math.max(0, distBottom);
}
return;
}
if (kind === 'settings') {
if (distLeft <= distRight) {
state.settingsAnchorX = 'left';
state.settingsLeft = Math.max(0, distLeft);
} else {
state.settingsAnchorX = 'right';
state.settingsRight = Math.max(0, distRight);
}
if (distTop <= distBottom) {
state.settingsAnchorY = 'top';
state.settingsTop = Math.max(0, distTop);
} else {
state.settingsAnchorY = 'bottom';
state.settingsBottom = Math.max(0, distBottom);
}
}
}
function applyVisibilityState() {
if (!panel) return;
if (state.paused) {
panel.style.display = 'none';
if (miniBtn) miniBtn.style.display = 'none';
return;
}
if (state.hidden) {
panel.style.display = 'none';
ensureMiniButton();
miniBtn.style.display = 'flex';
positionMiniButton();
return;
}
panel.style.display = '';
if (miniBtn) miniBtn.style.display = 'none';
}
function makeDraggable(handle, target, kind = 'panel') {
let dragging = false;
let pointerId = null;
let ox = 0;
let oy = 0;
let sx = 0;
let sy = 0;
handle.style.touchAction = 'none';
handle.addEventListener('pointerdown', (e) => {
if (e.button !== undefined && e.button !== 0) return;
if (kind === 'panel' && e.target && e.target.closest('.cc-hdr-btn')) return;
dragging = true;
pointerId = e.pointerId;
sx = e.clientX;
sy = e.clientY;
if (kind === 'mini' || kind === 'settings') target.__dragMoved = false;
const rect = target.getBoundingClientRect();
ox = e.clientX - rect.left;
oy = e.clientY - rect.top;
try { handle.setPointerCapture(pointerId); } catch {}
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!dragging || e.pointerId !== pointerId) return;
if (kind === 'mini' || kind === 'settings') {
if (Math.abs(e.clientX - sx) > 5 || Math.abs(e.clientY - sy) > 5) target.__dragMoved = true;
}
const x = clamp(e.clientX - ox, 0, Math.max(0, window.innerWidth - target.offsetWidth));
const y = clamp(e.clientY - oy, 0, Math.max(0, window.innerHeight - target.offsetHeight));
target.style.left = `${x}px`;
target.style.top = `${y}px`;
target.style.right = 'auto';
target.style.bottom = 'auto';
if (kind === 'panel') {
state.panelX = x;
state.panelY = y;
} else if (kind === 'mini' || kind === 'settings') {
updateAnchorFromPosition(kind, x, y, target.offsetWidth || 44, target.offsetHeight || 44);
}
});
const stop = (e) => {
if (!dragging || e.pointerId !== pointerId) return;
dragging = false;
try { handle.releasePointerCapture(pointerId); } catch {}
pointerId = null;
queuePersist();
if (kind === 'mini' || kind === 'settings') {
setTimeout(() => { target.__dragMoved = false; }, 60);
}
};
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
}
function startTimers() {
if (state.chainFastTimerId) clearInterval(state.chainFastTimerId);
state.chainFastTimerId = setInterval(() => {
try {
fastChainScan();
refreshCountdown();
} catch {}
}, CHAIN_FAST_SCAN_MS);
if (state.identityTimerId) clearInterval(state.identityTimerId);
state.identityTimerId = setInterval(() => {
refreshIdentityFromActivePage().catch(() => {});
}, IDENTITY_SCAN_MS);
if (state.discoveryTimerId) clearInterval(state.discoveryTimerId);
state.discoveryTimerId = setInterval(() => {
sendDiscoveredPlayers().catch(() => {});
}, DISCOVERY_SCAN_MS);
if (state.uiTimerId) clearInterval(state.uiTimerId);
state.uiTimerId = setInterval(() => {
updateUI();
}, UI_REFRESH_MS);
if (state.targetPollTimerId) clearInterval(state.targetPollTimerId);
state.targetPollTimerId = setInterval(() => {
loadTargetCache(true).catch(() => {});
}, TARGET_CACHE_POLL_MS);
if (state.routeTimerId) clearInterval(state.routeTimerId);
state.routeTimerId = setInterval(() => {
if (location.href !== state.lastRoute) {
state.lastRoute = location.href;
setTimeout(async () => {
try {
await sleep(900);
await loadTargetCache(true);
await refreshIdentityFromActivePage();
sendDiscoveredPlayers().catch(() => {});
renderActiveView();
updateUI();
const identity = getIdentityForSync();
if (!state.paused && identity.player_id && identity.faction_id) {
await syncPresence();
await fetchFullState('route');
}
} catch (e) {
warn('route refresh failed', e?.message || e);
}
}, 450);
}
}, ROUTE_CHECK_MS);
scheduleAlignedSync();
window.addEventListener('resize', () => {
try {
if (panel && panel.style.display !== 'none') {
const x = clamp(parseInt(panel.style.left || '0', 10), 0, Math.max(0, window.innerWidth - panel.offsetWidth));
const y = clamp(parseInt(panel.style.top || '0', 10), 0, Math.max(0, window.innerHeight - panel.offsetHeight));
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
state.panelX = x;
state.panelY = y;
}
positionMiniButton();
positionSettingsButton();
queuePersist();
updateUI();
} catch {}
});
window.addEventListener('focus', () => {
loadTargetCache(true).catch(() => {});
fastChainScan();
refreshCountdown();
});
window.addEventListener('beforeunload', () => {
persistNow().catch(() => {});
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
persistNow().catch(() => {});
} else {
loadTargetCache(true).catch(() => {});
fastChainScan();
refreshCountdown();
}
});
}
function injectStyles() {
const css = cssText();
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
return;
}
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
async function waitForMountPoint() {
if (document.body || document.documentElement) return;
await new Promise(resolve => {
const t = setInterval(() => {
if (document.body || document.documentElement) {
clearInterval(t);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(t);
resolve();
}, 5000);
});
}
async function boot() {
await waitForMountPoint();
await loadPersistedState();
await loadTargetCache(true);
setupTargetBroadcast();
injectStyles();
buildPanel();
ensureSettingsButton();
renderActiveView();
updateUI();
await sleep(BOOT_DOM_DELAY_MS);
if (!state.paused) {
fastChainScan();
await refreshIdentityFromActivePage();
await sleep(500);
fastChainScan();
await refreshIdentityFromActivePage();
}
renderActiveView();
updateUI();
startTimers();
if (!state.paused) sendDiscoveredPlayers().catch(() => {});
const identity = getIdentityForSync();
if (!state.paused && identity.player_id && identity.faction_id) {
setTimeout(async () => {
fastChainScan();
await refreshIdentityFromActivePage();
await syncPresence();
await fetchFullState('boot');
}, BOOT_SYNC_DELAY_MS);
} else if (!state.paused) {
setTimeout(() => {
fetchFullState('boot').catch(() => {});
}, BOOT_SYNC_DELAY_MS);
}
}
function cssText() {
return `
#cc-panel, #cc-panel * {
box-sizing: border-box;
font-family: 'Segoe UI', system-ui, sans-serif;
line-height: 1.35;
}
#cc-panel {
position: fixed;
z-index: 2147483647;
width: 255px;
border-radius: 14px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(120,170,255,0.16),
0 8px 40px rgba(0,0,0,0.55),
0 2px 8px rgba(0,0,0,0.40);
backdrop-filter: blur(12px) saturate(1.35);
-webkit-backdrop-filter: blur(12px) saturate(1.35);
background: linear-gradient(160deg, rgba(20,25,38,0.95) 0%, rgba(14,18,27,0.96) 100%);
border: 1px solid rgba(120,170,255,0.20);
user-select: none;
}
#cc-header {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 10px;
cursor: grab;
background: linear-gradient(90deg, rgba(90,130,220,0.14) 0%, rgba(60,100,190,0.08) 100%);
border-bottom: 1px solid rgba(120,170,255,0.15);
}
#cc-header:active { cursor: grabbing; }
#cc-header-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
#cc-header-title-wrap { flex: 1; min-width: 0; }
#cc-header-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
color: #d8e7ff;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#cc-header-countdown {
margin-top: 2px;
font-size: 10px;
color: rgba(255,255,255,0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#cc-header-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.cc-hdr-btn {
width: 20px;
height: 20px;
border: none;
background: rgba(255,255,255,0.07);
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.62);
font-size: 10px;
padding: 0;
}
.cc-hdr-btn:hover {
background: rgba(120,170,255,0.18);
color: #d8e7ff;
}
#cc-body {
display: flex;
flex-direction: column;
overflow: hidden;
height: 390px;
max-height: 70vh;
}
#cc-panel.collapsed #cc-body { display: none; }
#cc-panel.collapsed #cc-header { padding: 7px 10px; }
#cc-panel.collapsed #cc-header-title { font-size: 9px; }
#cc-panel.collapsed #cc-header-countdown { font-size: 11px; color: #fff; }
#cc-subtitle {
padding: 8px 12px 6px;
font-size: 11px;
color: rgba(255,255,255,0.78);
border-bottom: 1px solid rgba(255,255,255,0.06);
min-height: 42px;
}
#cc-view-host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.cc-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.cc-section {
padding: 8px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.cc-alert-section {
margin: 0 0 10px 0;
border: 1px solid rgba(255,217,120,0.22);
border-radius: 10px;
background: rgba(255,217,120,0.08);
}
.cc-view-summary,
.cc-view-schedule {
height: 100%;
}
.cc-view-schedule .cc-schedule-section {
flex: 1 1 auto;
overflow-y: auto;
}
.cc-main-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.cc-main-block { min-width: 0; }
.cc-main-label {
font-size: 10px;
color: rgba(255,255,255,0.42);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cc-ff-label {
margin-top: 8px;
margin-bottom: -4px;
}
.cc-main-value {
margin-top: 3px;
font-size: 13px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cc-small-muted {
display: inline-block;
margin-left: 4px;
font-size: 10px;
font-weight: 600;
color: rgba(255,255,255,0.52);
}
.cc-wrap-value {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.4;
}
.cc-summary-subline {
margin-top: 7px;
font-size: 11px;
color: rgba(255,255,255,0.72);
font-weight: 700;
}
#cc-status {
margin-top: auto;
padding: 8px 12px 10px;
font-size: 10px;
color: rgba(255,255,255,0.46);
line-height: 1.25;
border-top: 1px solid rgba(255,255,255,0.06);
}
.cc-toolbar-row {
display: flex;
gap: 6px;
align-items: center;
}
.cc-grow-btn { flex: 1; }
.cc-small-btn {
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
padding: 8px 10px;
color: #fff;
background: rgba(255,255,255,0.08);
}
.cc-small-btn:hover { background: rgba(255,255,255,0.12); }
.cc-small-btn.danger:hover { background: rgba(255,80,80,0.20); }
.cc-save-watch-main {
background: rgba(80,170,120,0.22);
border: 1px solid rgba(120,255,170,0.20);
}
.cc-save-watch-main:hover {
background: rgba(80,170,120,0.34);
}
.cc-clear-watch-main {
background: rgba(255,80,80,0.28);
border: 1px solid rgba(255,120,120,0.32);
}
.cc-clear-watch-main:hover {
background: rgba(255,80,80,0.46);
}
#cc-mini-btn {
position: fixed;
height: 44px;
min-height: 44px;
padding: 0;
border-radius: 50%;
cursor: pointer;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #fff;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
touch-action: none;
border: 1px solid rgba(255,255,255,0.18);
white-space: nowrap;
overflow: hidden;
}
#cc-mini-btn.cc-mini-icon-mode {
width: 44px !important;
min-width: 44px !important;
max-width: 44px !important;
height: 44px !important;
padding: 0 !important;
border-radius: 50% !important;
font-size: 20px;
}
#cc-mini-btn.cc-mini-text-mode {
width: 58px;
min-width: 58px;
max-width: 58px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
}
#cc-mini-btn.cc-mini-blue {
background: linear-gradient(135deg, #15203a 0%, #1b2c52 100%);
border-color: rgba(120,170,255,0.42);
}
#cc-mini-btn.cc-mini-green {
background: linear-gradient(135deg, #113921 0%, #1f6b39 100%);
border-color: rgba(120,255,170,0.45);
}
#cc-mini-btn.cc-mini-yellow {
background: linear-gradient(135deg, #5f4a14 0%, #8e6f16 100%);
border-color: rgba(255,217,120,0.55);
}
#cc-mini-btn.cc-mini-orange {
background: linear-gradient(135deg, #6a3810 0%, #b35f12 100%);
border-color: rgba(255,170,90,0.60);
}
#cc-mini-btn.cc-mini-red {
background: linear-gradient(135deg, #5a1717 0%, #aa2323 100%);
border-color: rgba(255,130,130,0.70);
}
.cc-good { color: #93efaf !important; }
.cc-info { color: #8fc1ff !important; }
.cc-warn { color: #ffd978 !important; }
.cc-orange { color: #ffbd7a !important; }
.cc-bad { color: #ff9b9b !important; font-weight: 900 !important; }
.cc-week-tab {
width: 100%;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.05);
border-radius: 10px;
padding: 8px 4px;
color: #fff;
cursor: pointer;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.cc-week-tab.active {
background: rgba(120,170,255,0.18);
border-color: rgba(120,170,255,0.40);
}
.cc-editor-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0 0 10px;
}
.cc-ranges-title {
font-size: 13px;
font-weight: 700;
color: #fff;
}
.cc-range-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.cc-range-row {
display: grid;
grid-template-columns: 1fr auto 1fr auto;
gap: 6px;
align-items: center;
margin-top: 10px;
}
.cc-range-row.watch-time-row {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
}
.cc-range-row.targets {
grid-template-columns: 1fr auto 1fr;
}
.cc-range-select,
.cc-range-input {
width: 100%;
min-width: 0;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 7px;
padding: 7px 8px;
font-size: 12px;
color: #fff;
outline: none;
}
.cc-range-select:disabled { opacity: 0.5; }
.cc-range-select option {
color: #fff;
background: #162038;
}
.cc-range-sep {
font-size: 12px;
color: rgba(255,255,255,0.72);
}
.cc-empty-day {
font-size: 12px;
color: rgba(255,255,255,0.72);
line-height: 1.45;
}
.cc-watch-mode-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 10px;
}
.cc-timeline-row,
.cc-target-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
}
.cc-timeline-row.active {
border-color: rgba(120,170,255,0.38);
background: rgba(120,170,255,0.12);
}
.cc-timeline-main,
.cc-target-main {
min-width: 0;
flex: 1;
}
.cc-timeline-title,
.cc-target-title {
font-size: 12px;
font-weight: 800;
color: #fff;
}
.cc-timeline-sub,
.cc-target-sub {
font-size: 11px;
color: rgba(255,255,255,0.72);
margin-top: 3px;
}
.cc-target-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.cc-target-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 6px 8px;
border-radius: 8px;
text-decoration: none;
color: #fff;
border: 1px solid rgba(255,255,255,0.18);
font-size: 11px;
font-weight: 800;
}
.cc-target-btn.attack {
background: rgba(255,80,80,0.34);
border-color: rgba(255,120,120,0.46);
}
.cc-target-btn.attack:hover {
background: rgba(255,80,80,0.50);
}
.cc-target-btn.profile {
background: rgba(80,210,130,0.30);
border-color: rgba(120,255,170,0.42);
}
.cc-target-btn.profile:hover {
background: rgba(80,210,130,0.46);
}
.cc-graph-head {
display: flex;
justify-content: space-between;
color: rgba(255,255,255,0.62);
font-size: 10px;
margin: 10px 0 6px;
}
.cc-graph-wrap {
display: grid;
grid-template-columns: repeat(48, minmax(0, 1fr));
gap: 2px;
}
.cc-graph-cell {
height: 18px;
border-radius: 4px;
background: rgba(255,255,255,0.05);
}
.cc-graph-cell.gap { background: rgba(255,80,80,0.12); }
.cc-graph-cell.low { background: rgba(120,170,255,0.22); }
.cc-graph-cell.mid { background: rgba(255,217,120,0.28); }
.cc-graph-cell.high { background: rgba(120,255,170,0.28); }
.cc-graph-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
font-size: 10px;
color: rgba(255,255,255,0.72);
}
.cc-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 4px;
vertical-align: -1px;
}
.cc-dot.gap { background: rgba(255,80,80,0.55); }
.cc-dot.low { background: rgba(120,170,255,0.7); }
.cc-dot.mid { background: rgba(255,217,120,0.8); }
.cc-dot.high { background: rgba(120,255,170,0.75); }
#cc-settings-launcher {
position: fixed;
z-index: 2147483646;
display: none;
align-items: center;
justify-content: center;
border: 1px solid rgba(120,170,255,0.35);
background: rgba(20,25,38,0.94);
color: #fff;
border-radius: 999px;
padding: 8px 10px;
font-size: 12px;
font-weight: 800;
box-shadow: 0 4px 15px rgba(0,0,0,0.45);
cursor: grab;
touch-action: none;
user-select: none;
}
#cc-settings-launcher:active {
cursor: grabbing;
}
#cc-settings-modal,
#cc-settings-modal * {
box-sizing: border-box;
font-family: 'Segoe UI', system-ui, sans-serif;
}
#cc-settings-modal {
position: fixed;
inset: 0;
z-index: 2147483647;
}
#cc-settings-backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
#cc-settings-card {
position: absolute;
top: 50%;
left: 50%;
width: min(420px, calc(100vw - 24px));
max-height: calc(100vh - 40px);
transform: translate(-50%, -50%);
background: #141923;
color: #fff;
border: 1px solid rgba(120,170,255,0.28);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 60px rgba(0,0,0,0.65);
}
#cc-settings-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(120,170,255,0.08);
}
#cc-settings-title {
font-size: 15px;
font-weight: 900;
}
#cc-settings-sub {
margin-top: 3px;
font-size: 11px;
color: rgba(255,255,255,0.55);
}
#cc-settings-close {
border: none;
background: rgba(255,255,255,0.08);
color: #fff;
border-radius: 8px;
width: 30px;
height: 30px;
cursor: pointer;
}
.cc-settings-body {
padding: 14px 16px 16px;
overflow-y: auto;
max-height: calc(100vh - 130px);
}
.cc-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cc-settings-field {
display: block;
margin-bottom: 10px;
}
.cc-settings-field span {
display: block;
margin-bottom: 5px;
font-size: 11px;
color: rgba(255,255,255,0.62);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cc-settings-field input,
.cc-settings-field select {
width: 100%;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #fff;
border-radius: 9px;
padding: 9px 10px;
outline: none;
font-size: 13px;
}
.cc-settings-field select option {
background: #141923;
color: #fff;
}
.cc-settings-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cc-settings-actions button {
flex: 1;
min-width: 120px;
border: none;
background: rgba(120,170,255,0.18);
color: #fff;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
font-weight: 800;
}
.cc-settings-actions button:hover {
background: rgba(120,170,255,0.26);
}
.cc-start-stop-btn.start {
background: rgba(80,210,130,0.30) !important;
border: 1px solid rgba(120,255,170,0.36) !important;
}
.cc-start-stop-btn.start:hover {
background: rgba(80,210,130,0.46) !important;
}
.cc-start-stop-btn.stop {
background: rgba(255,80,80,0.34) !important;
border: 1px solid rgba(255,120,120,0.42) !important;
}
.cc-start-stop-btn.stop:hover {
background: rgba(255,80,80,0.50) !important;
}
#cc-panel.cc-compact { width: 238px; }
@media (max-width: 768px) {
#cc-panel {
width: 238px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: #141923;
}
}
`;
}
boot().catch((e) => errlog('boot fatal failed', e));
})();