Torn Chain Watcher

Sidebar widget that monitors your faction's chain — alerts on chain start, timeout warnings, and upcoming bonus milestones.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Chain Watcher
// @namespace    torn_chain_watcher
// @version      1.1.4
// @description  Sidebar widget that monitors your faction's chain — alerts on chain start, timeout warnings, and upcoming bonus milestones.
// @author       TheOddSod (2640064)
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  // ─── Duplicate injection guard ───────────────────────────────────────────────
  if (window._tcwLoaded) return;
  window._tcwLoaded = true;

  // ─── Constants ───────────────────────────────────────────────────────────────
  const NS          = 'tcw';
  const API_BASE    = 'https://api.torn.com/v2';
  const COMMENT     = 'TornChainWatcher';

  // Chain bonus milestones (standard Torn chain bonuses)
  const BONUS_MILESTONES = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];

  // Polling intervals (ms)
  const POLL_ACTIVE = 10_000;  // 10s during active chain
  const POLL_IDLE   = 10_000;  // 10s when idle — fast enough to catch chain starts

  // ─── Changelog ───────────────────────────────────────────────────────────────
  // v1.1.4 — Update API key requirement to Limited access (attacks endpoint
  //           requires more than Minimal).
  // v1.1.3 — Fix last hit: remove faction ID derivation entirely.
  // v1.1.2 — Simplify last hit: first outgoing faction attack in descending list.
  // v1.1.1 — Fix last hit showing pre-chain attacks: restore chain.start filter.
  // v1.1.0 — Fix last hit blank: derive faction ID by matching logged-in player.
  // v1.0.9 — Simplify last hit logic: most recent outgoing faction attack.
  // v1.0.8 — Fix last hit showing stale attacker from previous chain.
  // v1.0.7 — Show last attacker name during active chain ("Last hit: Name").
  // v1.0.6 — Perfect countdown: derive remaining time from API `end` timestamp.
  // v1.0.5 — Fix countdown drift: subtract elapsed time since API response.
  // v1.0.4 — Fix GM_xmlhttpRequest casing (capital H broke Tampermonkey grant).
  // v1.0.3 — Idle poll 10s; visibilitychange re-poll on tab focus.
  // v1.0.2 — Pulse animation on header when any chain is active (current > 0).
  //           Chain start notification threshold lowered from 10 to 1.
  // v1.0.1 — Fix header cramping, checkbox rendering (Torn CSS override), volume
  //           slider width, redundant no-key body message.
  // v1.0.0 — Initial release. Sidebar widget with chain tracking, timeout
  //           warnings, bonus milestone display, visual/browser/sound alerts,
  //           configurable thresholds, full theme support.

  // ─── Theme system (mirrors OCM design system) ────────────────────────────────
  const THEMES = {
    default: {
      '--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
      '--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
      '--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
      '--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
      '--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#e05a00','--ocm-border-section':'#333',
      '--ocm-accent':'#ff7700','--ocm-accent-hover':'#e05a00','--ocm-accent-dim':'#cc5500',
      '--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
      '--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
      '--ocm-font-scale':'1',
    },
    torn: {
      '--ocm-bg-deep':'#111','--ocm-bg-dark':'#1a1a1a','--ocm-bg-base':'#222',
      '--ocm-bg-card':'#1c1c1c','--ocm-bg-header':'#1c1c1c','--ocm-bg-hover':'#2a2a2a',
      '--ocm-bg-input':'#2a2a2a','--ocm-bg-dropdown':'#111','--ocm-bg-row':'#1a1a1a',
      '--ocm-border-faint':'#2a2a2a','--ocm-border-card':'#3a3a3a','--ocm-border-strip':'#333',
      '--ocm-border-input':'#555','--ocm-border-accent':'#c03020','--ocm-border-section':'#444',
      '--ocm-accent':'#e04030','--ocm-accent-hover':'#c03020','--ocm-accent-dim':'#aa2010',
      '--ocm-text-primary':'#ddd','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#333',
      '--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
      '--ocm-font-scale':'1',
    },
    highcontrast: {
      '--ocm-bg-deep':'#000','--ocm-bg-dark':'#000','--ocm-bg-base':'#000',
      '--ocm-bg-card':'#0a0a0a','--ocm-bg-header':'#000','--ocm-bg-hover':'#1a1a1a',
      '--ocm-bg-input':'#111','--ocm-bg-dropdown':'#000','--ocm-bg-row':'#000',
      '--ocm-border-faint':'#444','--ocm-border-card':'#fff','--ocm-border-strip':'#888',
      '--ocm-border-input':'#fff','--ocm-border-accent':'#ffff00','--ocm-border-section':'#888',
      '--ocm-accent':'#ffff00','--ocm-accent-hover':'#ffee00','--ocm-accent-dim':'#cccc00',
      '--ocm-text-primary':'#fff','--ocm-text-card':'#fff','--ocm-text-secondary':'#eee',
      '--ocm-text-label':'#ddd','--ocm-text-muted':'#bbb','--ocm-text-disabled':'#888','--ocm-text-dead':'#666',
      '--ocm-status-ok':'#00ff88','--ocm-status-warn':'#ffdd00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003318','--ocm-status-warn-bg':'#332200','--ocm-status-crit-bg':'#330000',
      '--ocm-status-ok-border':'#00ff88','--ocm-status-warn-border':'#ffdd00','--ocm-status-crit-border':'#ff4444',
      '--ocm-font-scale':'1',
    },
    lowvision: {
      '--ocm-bg-deep':'#080d18','--ocm-bg-dark':'#0a1020','--ocm-bg-base':'#0d1628',
      '--ocm-bg-card':'#111828','--ocm-bg-header':'#111828','--ocm-bg-hover':'#161c30',
      '--ocm-bg-input':'#0a2a50','--ocm-bg-dropdown':'#080d18','--ocm-bg-row':'#0a1020',
      '--ocm-border-faint':'#333','--ocm-border-card':'#4a4a7a','--ocm-border-strip':'#2a3a6a',
      '--ocm-border-input':'#4a6a9a','--ocm-border-accent':'#ff8800','--ocm-border-section':'#555',
      '--ocm-accent':'#ff9900','--ocm-accent-hover':'#ff7700','--ocm-accent-dim':'#dd6600',
      '--ocm-text-primary':'#ffffff','--ocm-text-card':'#eee','--ocm-text-secondary':'#ccc',
      '--ocm-text-label':'#aaa','--ocm-text-muted':'#888','--ocm-text-disabled':'#666','--ocm-text-dead':'#555',
      '--ocm-status-ok':'#66ffaa','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff5555',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#33cc77','--ocm-status-warn-border':'#998800','--ocm-status-crit-border':'#cc2200',
      '--ocm-font-scale':'1.1',
    },
    light: {
      '--ocm-bg-deep':'#dde4f0','--ocm-bg-dark':'#e8eef8','--ocm-bg-base':'#eef2fa',
      '--ocm-bg-card':'#f4f6fc','--ocm-bg-header':'#f4f6fc','--ocm-bg-hover':'#e8ecf8',
      '--ocm-bg-input':'#dde4f0','--ocm-bg-dropdown':'#dde4f0','--ocm-bg-row':'#e8eef8',
      '--ocm-border-faint':'#ccd4e8','--ocm-border-card':'#b8c4dc','--ocm-border-strip':'#c8d4e8',
      '--ocm-border-input':'#9aaac8','--ocm-border-accent':'#cc5500','--ocm-border-section':'#b0bcd8',
      '--ocm-accent':'#cc5500','--ocm-accent-hover':'#aa4400','--ocm-accent-dim':'#993300',
      '--ocm-text-primary':'#1a1a2e','--ocm-text-card':'#222','--ocm-text-secondary':'#444',
      '--ocm-text-label':'#666','--ocm-text-muted':'#777','--ocm-text-disabled':'#999','--ocm-text-dead':'#aaa',
      '--ocm-status-ok':'#006622','--ocm-status-warn':'#885500','--ocm-status-crit':'#cc1111',
      '--ocm-status-ok-bg':'#d4f0dd','--ocm-status-warn-bg':'#fff0cc','--ocm-status-crit-bg':'#ffe0dd',
      '--ocm-status-ok-border':'#44aa66','--ocm-status-warn-border':'#cc8800','--ocm-status-crit-border':'#dd4444',
      '--ocm-font-scale':'1',
    },
  };

  // ─── Config helpers ───────────────────────────────────────────────────────────
  function cfgGet(key, def) { return GM_getValue(`${NS}_${key}`, def); }
  function cfgSet(key, val) { GM_setValue(`${NS}_${key}`, val); }

  function loadConfig() {
    return {
      apiKey:          cfgGet('api_key', ''),
      theme:           cfgGet('theme', 'default'),
      warnSecs:        cfgGet('warn_secs', 60),        // seconds before timeout to warn
      notifVisual:     cfgGet('notif_visual', true),   // show in-page banner
      notifBrowser:    cfgGet('notif_browser', true),  // browser Notification API
      soundEnabled:    cfgGet('sound_enabled', true),  // play beep alerts
      soundVolume:     cfgGet('sound_volume', 0.5),    // 0–1
      collapsed:       cfgGet('collapsed', false),
    };
  }

  // ─── Web Audio beep ───────────────────────────────────────────────────────────
  /**
   * Plays a short beep using the Web Audio API.
   * @param {number} freq   - Hz
   * @param {number} dur    - seconds
   * @param {number} vol    - 0–1
   * @param {'sine'|'square'|'sawtooth'|'triangle'} type
   */
  function beep(freq, dur, vol, type = 'sine') {
    try {
      const ctx  = new (window.AudioContext || window.webkitAudioContext)();
      const osc  = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.connect(gain);
      gain.connect(ctx.destination);
      osc.type      = type;
      osc.frequency.value = freq;
      gain.gain.value     = vol;
      osc.start();
      osc.stop(ctx.currentTime + dur);
      osc.onended = () => ctx.close();
    } catch (e) {
      // AudioContext not available — fail silently
    }
  }

  /** Play chain-start jingle (ascending two-tone) */
  function soundChainStart(vol) {
    beep(440, 0.12, vol);
    setTimeout(() => beep(660, 0.18, vol), 130);
  }

  /** Play timeout-warning alert (urgent triple beep) */
  function soundWarn(vol) {
    beep(880, 0.1, vol, 'square');
    setTimeout(() => beep(880, 0.1, vol, 'square'), 160);
    setTimeout(() => beep(880, 0.15, vol, 'square'), 320);
  }

  /** Play bonus milestone chime (happy ascending) */
  function soundBonus(vol) {
    beep(523, 0.1, vol);
    setTimeout(() => beep(659, 0.1, vol), 110);
    setTimeout(() => beep(784, 0.15, vol), 220);
  }

  // ─── Browser notifications ────────────────────────────────────────────────────
  /** Request browser notification permission if needed. */
  function requestNotifPermission() {
    if ('Notification' in window && Notification.permission === 'default') {
      Notification.requestPermission();
    }
  }

  /** Show a browser notification if permitted. */
  function browserNotif(title, body, icon = '⚔') {
    if (!('Notification' in window) || Notification.permission !== 'granted') return;
    new Notification(title, { body, icon: 'https://www.torn.com/favicon.ico' });
  }

  // ─── API fetch (GM_xmlHttpRequest for external domain) ───────────────────────
  /**
   * Fetches a Torn API v2 endpoint.
   * Always uses GM_xmlHttpRequest to avoid Torn's CSP blocking external fetches.
   * @param {string} path  - e.g. '/faction?selections=chain'
   * @param {string} key   - API key
   * @returns {Promise<object>}
   */
  function apiFetch(path, key) {
    return new Promise((resolve, reject) => {
      const sep = path.includes('?') ? '&' : '?';
      const url = `${API_BASE}${path}${sep}key=${key}&comment=${COMMENT}`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload(resp) {
          try {
            const data = JSON.parse(resp.responseText);
            if (data.error) reject(new Error(`API ${data.error.code}: ${data.error.error}`));
            else resolve(data);
          } catch (e) {
            reject(new Error('JSON parse error'));
          }
        },
        onerror() { reject(new Error('Network error')); },
        ontimeout() { reject(new Error('Timeout')); },
        timeout: 10000,
      });
    });
  }

  // ─── Bonus milestone helpers ──────────────────────────────────────────────────
  /**
   * Returns the next bonus milestone above current hits.
   * @param {number} current
   * @returns {number|null}
   */
  function nextBonus(current) {
    return BONUS_MILESTONES.find(m => m > current) ?? null;
  }

  /**
   * Returns the most recent bonus milestone at or below current hits.
   * @param {number} current
   * @returns {number|null}
   */
  function prevBonus(current) {
    const passed = BONUS_MILESTONES.filter(m => m <= current);
    return passed.length ? passed[passed.length - 1] : null;
  }

  // ─── Format seconds as mm:ss ─────────────────────────────────────────────────
  function fmtTime(s) {
    if (s <= 0) return '00:00';
    const m = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
  }

  // ─── CSS injection ────────────────────────────────────────────────────────────
  GM_addStyle(`
    /* ── Chain active pulse ──────────────────────────────────────────────────── */
    @keyframes tcw-pulse {
      0%, 100% { border-top-color: var(--ocm-border-accent); }
      50%       { border-top-color: var(--ocm-accent); box-shadow: 0 -1px 6px var(--ocm-accent); }
    }
    #tcw-root.tcw-chain-active {
      animation: tcw-pulse 1.8s ease-in-out infinite;
    }

    /* ── Chain Watcher root ─────────────────────────────────────────────────── */
    #tcw-root {
      background: var(--ocm-bg-card);
      border-top: 2px solid var(--ocm-border-accent);
      border-bottom: 1px solid var(--ocm-border-card);
      font-size: 11px;
      font-family: Arial, sans-serif;
      line-height: 1.5;
      color: var(--ocm-text-primary);
    }

    /* ── Header bar ─────────────────────────────────────────────────────────── */
    #tcw-header {
      display: flex;
      align-items: center;
      gap: 4px;
      padding: 5px 8px;
      cursor: pointer;
      background: var(--ocm-bg-header);
      border-bottom: 1px solid var(--ocm-border-strip);
      user-select: none;
      min-width: 0; /* allow flex children to shrink */
    }
    #tcw-header:hover { background: var(--ocm-bg-hover); }
    #tcw-header-title {
      color: var(--ocm-accent);
      font-weight: bold;
      font-size: 10px;
      letter-spacing: .5px;
      text-transform: uppercase;
      flex-shrink: 0; /* never shrink the label */
    }
    #tcw-header-status {
      flex: 1 1 0;   /* grow to fill, shrink as needed */
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 10px;
      color: var(--ocm-text-secondary);
    }
    #tcw-header-timer {
      font-size: 10px;
      font-weight: bold;
      font-variant-numeric: tabular-nums;
      flex-shrink: 0;
      min-width: 36px;
      text-align: right;
    }
    #tcw-cfg-toggle {
      flex-shrink: 0;
    }
    #tcw-collapse-btn {
      color: var(--ocm-text-muted);
      font-size: 10px;
      flex-shrink: 0;
    }

    /* ── Body ───────────────────────────────────────────────────────────────── */
    #tcw-body {
      padding: 6px 8px;
    }

    /* ── Stat row ───────────────────────────────────────────────────────────── */
    .tcw-stat-row {
      display: flex;
      justify-content: space-between;
      align-items: baseline;
      margin-bottom: 3px;
    }
    .tcw-stat-label {
      font-size: 9px;
      color: var(--ocm-text-muted);
      text-transform: uppercase;
      letter-spacing: .5px;
    }
    .tcw-stat-value {
      font-size: 13px;
      font-weight: bold;
      color: var(--ocm-accent);
      font-variant-numeric: tabular-nums;
    }
    .tcw-stat-value.ok   { color: var(--ocm-status-ok); }
    .tcw-stat-value.warn { color: var(--ocm-status-warn); }
    .tcw-stat-value.crit { color: var(--ocm-status-crit); }

    /* ── Progress bar ───────────────────────────────────────────────────────── */
    .tcw-progress-wrap {
      height: 4px;
      background: var(--ocm-bg-deep);
      border-radius: 3px;
      overflow: hidden;
      margin: 3px 0 6px;
    }
    .tcw-progress-fill {
      height: 100%;
      border-radius: 3px;
      transition: width .4s ease, background-color .4s ease;
      background: var(--ocm-status-ok);
    }
    .tcw-progress-fill.warn { background: var(--ocm-status-warn); }
    .tcw-progress-fill.crit { background: var(--ocm-status-crit); }

    /* ── Bonus row ──────────────────────────────────────────────────────────── */
    #tcw-bonus-row {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 4px 0;
      border-top: 1px solid var(--ocm-border-faint);
      margin-top: 2px;
    }
    .tcw-bonus-label {
      font-size: 9px;
      color: var(--ocm-text-muted);
      text-transform: uppercase;
      letter-spacing: .5px;
    }
    .tcw-bonus-val {
      font-size: 11px;
      font-weight: bold;
      color: var(--ocm-accent);
    }
    .tcw-bonus-val.none { color: var(--ocm-text-disabled); }

    /* ── Modifier badge ─────────────────────────────────────────────────────── */
    #tcw-modifier {
      display: inline-block;
      background: var(--ocm-bg-deep);
      border: 1px solid var(--ocm-border-input);
      border-radius: 3px;
      padding: 1px 5px;
      font-size: 10px;
      color: var(--ocm-text-secondary);
    }

    /* ── Alert banner ───────────────────────────────────────────────────────── */
    #tcw-alert {
      display: none;
      margin: 4px 0 2px;
      padding: 3px 7px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
      border: 1px solid;
    }
    #tcw-alert.ok   { display:block; background:var(--ocm-status-ok-bg);   border-color:var(--ocm-status-ok-border);   color:var(--ocm-status-ok); }
    #tcw-alert.warn { display:block; background:var(--ocm-status-warn-bg); border-color:var(--ocm-status-warn-border); color:var(--ocm-status-warn); }
    #tcw-alert.crit { display:block; background:var(--ocm-status-crit-bg); border-color:var(--ocm-status-crit-border); color:var(--ocm-status-crit); }

    /* ── No-chain placeholder ───────────────────────────────────────────────── */
    #tcw-no-chain {
      padding: 6px 0;
      font-size: 10px;
      color: var(--ocm-text-disabled);
      text-align: center;
    }

    /* ── Config panel ───────────────────────────────────────────────────────── */
    #tcw-config {
      display: none;
      padding: 8px;
      background: var(--ocm-bg-dark);
      border-top: 1px solid var(--ocm-border-strip);
      font-size: 11px;
    }
    .tcw-cfg-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 6px;
      gap: 6px;
    }
    .tcw-cfg-label {
      font-size: 10px;
      color: var(--ocm-text-secondary);
      flex-shrink: 0;
    }
    .tcw-cfg-note {
      font-size: 9px;
      color: var(--ocm-text-muted);
      margin-bottom: 6px;
      line-height: 1.4;
    }
    .tcw-input {
      background: var(--ocm-bg-input);
      border: 1px solid var(--ocm-border-input);
      border-radius: 4px;
      color: var(--ocm-text-primary);
      padding: 3px 6px;
      font-size: 11px;
      width: 100%;
      box-sizing: border-box;
    }
    .tcw-input:focus { outline: none; border-color: var(--ocm-accent); }
    .tcw-input[type="number"] { width: 64px !important; }
    .tcw-input[type="range"]  {
      width: 90px !important;
      padding: 0 !important;
      background: none !important;
      border: none !important;
      height: 18px !important;
      cursor: pointer;
    }
    select.tcw-input { cursor: pointer; }
    /* Checkboxes — use !important to beat Torn's global input CSS resets */
    .tcw-checkbox {
      -webkit-appearance: checkbox !important;
      appearance: checkbox !important;
      accent-color: var(--ocm-accent);
      width: 14px !important;
      height: 14px !important;
      min-width: 14px !important;
      min-height: 14px !important;
      cursor: pointer;
      flex-shrink: 0;
      padding: 0 !important;
      border: none !important;
      background: none !important;
    }
    .tcw-btn-row {
      display: flex;
      gap: 6px;
      margin-top: 8px;
    }
    .tcw-btn-primary {
      background: var(--ocm-accent-hover);
      border: none;
      border-radius: 4px;
      color: #fff;
      padding: 4px 10px;
      cursor: pointer;
      font-size: 11px;
      flex: 1;
    }
    .tcw-btn-primary:hover { background: var(--ocm-accent); }
    .tcw-btn-secondary {
      background: var(--ocm-bg-input);
      border: 1px solid var(--ocm-border-input);
      border-radius: 4px;
      color: var(--ocm-text-secondary);
      padding: 3px 8px;
      cursor: pointer;
      font-size: 11px;
    }
    .tcw-btn-secondary:hover { background: var(--ocm-bg-hover); color: var(--ocm-text-primary); }
    #tcw-save-status {
      font-size: 10px;
      color: var(--ocm-status-ok);
      margin-left: 4px;
      opacity: 0;
      transition: opacity .3s;
    }
    #tcw-cfg-toggle {
      background: none;
      border: none;
      color: var(--ocm-text-muted);
      font-size: 10px;
      cursor: pointer;
      padding: 0;
    }
    #tcw-cfg-toggle:hover { color: var(--ocm-text-secondary); }

    /* ── Config section title ────────────────────────────────────────────────── */
    .tcw-section-title {
      font-size: 9px;
      color: var(--ocm-text-disabled);
      text-transform: uppercase;
      letter-spacing: .5px;
      margin: 8px 0 4px;
      border-bottom: 1px solid var(--ocm-border-section);
      padding-bottom: 2px;
    }
  `);

  // ─── State ────────────────────────────────────────────────────────────────────
  let cfg            = loadConfig();
  let chainState     = null;  // last known chain data from API
  let chainEndTs     = 0;     // Unix timestamp (s) when current chain window ends
  let countdown      = 0;     // local countdown derived from chainEndTs
  let lastAttacker   = null;  // name of last faction member to land a chain hit
  let myFactionId    = null;  // derived from first attack response, used as filter
  let countdownTimer = null;  // setInterval handle
  let pollTimer      = null;  // setTimeout handle
  let lastNotifType  = null;  // tracks last notification sent to avoid spam
  let lastChainCount = 0;     // tracks last known hit count for start detection

  // ─── Get player/faction data from DOM ────────────────────────────────────────
  function getTornUser() {
    try {
      const el = document.getElementById('torn-user');
      if (el) return JSON.parse(el.value);
    } catch (e) { /* ignore */ }
    return null;
  }

  function getPlayerId() {
    return getTornUser()?.id || null;
  }

  // ─── Apply theme ──────────────────────────────────────────────────────────────
  function applyTheme(key) {
    const root  = document.getElementById('tcw-root');
    if (!root) return;
    const theme = THEMES[key] || THEMES.default;
    for (const [prop, val] of Object.entries(theme)) {
      if (prop.startsWith('--')) root.style.setProperty(prop, val);
    }
    root.style.fontSize = `${11 * parseFloat(theme['--ocm-font-scale'] || '1')}px`;
  }

  // ─── UI ───────────────────────────────────────────────────────────────────────
  function buildUI() {
    const root = document.createElement('div');
    root.id = 'tcw-root';
    root.innerHTML = `
      <!-- Header -->
      <div id="tcw-header">
        <span id="tcw-header-title">⚔ Chain</span>
        <span id="tcw-header-status">No chain</span>
        <span id="tcw-header-timer">--:--</span>
        <button id="tcw-cfg-toggle" title="Settings">⚙</button>
        <span id="tcw-collapse-btn">▼</span>
      </div>

      <!-- Config panel -->
      <div id="tcw-config">
        <div class="tcw-section-title">API Access</div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-api-key">API Key</label>
        </div>
        <div class="tcw-cfg-note">Requires: <b>Limited</b> access (for chain + attacks data).</div>
        <div class="tcw-cfg-row">
          <input id="tcw-api-key" class="tcw-input" type="password" placeholder="Your Torn API key" autocomplete="off" />
        </div>

        <div class="tcw-section-title">Theme</div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-theme">Theme</label>
          <select id="tcw-theme" class="tcw-input" style="width:auto">
            <option value="default">Default (Dark Blue)</option>
            <option value="torn">Torn Classic</option>
            <option value="highcontrast">High Contrast</option>
            <option value="lowvision">Low Vision</option>
            <option value="light">Light Mode</option>
          </select>
        </div>

        <div class="tcw-section-title">Alerts</div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-warn-secs">Warn at (secs left)</label>
          <input id="tcw-warn-secs" class="tcw-input" type="number" min="10" max="300" step="5" />
        </div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-notif-visual">Visual banner</label>
          <input id="tcw-notif-visual" class="tcw-checkbox" type="checkbox" />
        </div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-notif-browser">Browser notification</label>
          <input id="tcw-notif-browser" class="tcw-checkbox" type="checkbox" />
        </div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-sound">Sound alerts</label>
          <input id="tcw-sound" class="tcw-checkbox" type="checkbox" />
        </div>
        <div class="tcw-cfg-row">
          <label class="tcw-cfg-label" for="tcw-volume">Volume</label>
          <input id="tcw-volume" class="tcw-input" type="range" min="0" max="1" step="0.05" />
        </div>

        <div class="tcw-btn-row">
          <button id="tcw-save-btn" class="tcw-btn-primary">Save</button>
          <button id="tcw-reset-btn" class="tcw-btn-secondary">Reset</button>
          <span id="tcw-save-status">Saved ✓</span>
        </div>
      </div>

      <!-- Body (chain data) -->
      <div id="tcw-body">
        <div id="tcw-no-chain">No active chain</div>

        <div id="tcw-chain-data" style="display:none">
          <!-- Alert banner -->
          <div id="tcw-alert"></div>

          <!-- Hit counter -->
          <div class="tcw-stat-row">
            <span class="tcw-stat-label">Hits</span>
            <span id="tcw-hits" class="tcw-stat-value ok">0</span>
          </div>
          <div class="tcw-progress-wrap">
            <div id="tcw-hits-bar" class="tcw-progress-fill" style="width:0%"></div>
          </div>

          <!-- Timeout countdown -->
          <div class="tcw-stat-row">
            <span class="tcw-stat-label">Time left</span>
            <span id="tcw-timeout" class="tcw-stat-value">--:--</span>
          </div>
          <div class="tcw-progress-wrap">
            <div id="tcw-time-bar" class="tcw-progress-fill" style="width:100%"></div>
          </div>

          <!-- Bonus row -->
          <div id="tcw-bonus-row">
            <div>
              <div class="tcw-bonus-label">Last bonus</div>
              <div id="tcw-bonus-prev" class="tcw-bonus-val none">—</div>
            </div>
            <div style="text-align:right">
              <div class="tcw-bonus-label">Next bonus</div>
              <div id="tcw-bonus-next" class="tcw-bonus-val">—</div>
            </div>
          </div>

          <!-- Chain modifier -->
          <div class="tcw-stat-row" style="margin-top:4px">
            <span class="tcw-stat-label">Modifier</span>
            <span id="tcw-modifier">×1</span>
          </div>

          <!-- Last hit -->
          <div class="tcw-stat-row" style="margin-top:2px">
            <span class="tcw-stat-label">Last hit</span>
            <span id="tcw-last-attacker" style="font-size:11px;color:var(--ocm-text-card)">—</span>
          </div>
        </div>
      </div>
    `;
    return root;
  }

  // ─── Populate config panel with current values ────────────────────────────────
  function populateConfig() {
    document.getElementById('tcw-api-key').value        = cfg.apiKey;
    document.getElementById('tcw-theme').value          = cfg.theme;
    document.getElementById('tcw-warn-secs').value      = cfg.warnSecs;
    document.getElementById('tcw-notif-visual').checked = cfg.notifVisual;
    document.getElementById('tcw-notif-browser').checked= cfg.notifBrowser;
    document.getElementById('tcw-sound').checked        = cfg.soundEnabled;
    document.getElementById('tcw-volume').value         = cfg.soundVolume;
  }

  // ─── Wire config panel events ─────────────────────────────────────────────────
  function wireConfig() {
    // Config toggle button
    document.getElementById('tcw-cfg-toggle').addEventListener('click', (e) => {
      e.stopPropagation();
      const panel = document.getElementById('tcw-config');
      const open  = panel.style.display !== 'block';
      panel.style.display = open ? 'block' : 'none';
      if (open) populateConfig();
    });

    // Live theme preview
    document.getElementById('tcw-theme').addEventListener('change', function () {
      applyTheme(this.value);
    });

    // Save button
    document.getElementById('tcw-save-btn').addEventListener('click', () => {
      const newKey = document.getElementById('tcw-api-key').value.trim();
      cfg.apiKey        = newKey;
      cfg.theme         = document.getElementById('tcw-theme').value;
      cfg.warnSecs      = parseInt(document.getElementById('tcw-warn-secs').value, 10) || 60;
      cfg.notifVisual   = document.getElementById('tcw-notif-visual').checked;
      cfg.notifBrowser  = document.getElementById('tcw-notif-browser').checked;
      cfg.soundEnabled  = document.getElementById('tcw-sound').checked;
      cfg.soundVolume   = parseFloat(document.getElementById('tcw-volume').value);

      cfgSet('api_key',       cfg.apiKey);
      cfgSet('theme',         cfg.theme);
      cfgSet('warn_secs',     cfg.warnSecs);
      cfgSet('notif_visual',  cfg.notifVisual);
      cfgSet('notif_browser', cfg.notifBrowser);
      cfgSet('sound_enabled', cfg.soundEnabled);
      cfgSet('sound_volume',  cfg.soundVolume);

      applyTheme(cfg.theme);

      // Show save confirmation briefly
      const status = document.getElementById('tcw-save-status');
      status.style.opacity = '1';
      setTimeout(() => { status.style.opacity = '0'; }, 1800);

      // Request browser notification permission if enabled
      if (cfg.notifBrowser) requestNotifPermission();

      // Close panel & restore body, then restart poll with new key
      document.getElementById('tcw-config').style.display = 'none';
      document.getElementById('tcw-body').style.display   = '';
      restartPoll();
    });

    // Reset defaults
    document.getElementById('tcw-reset-btn').addEventListener('click', () => {
      if (!confirm('Reset Chain Watcher settings to defaults?')) return;
      cfgSet('warn_secs',     60);
      cfgSet('notif_visual',  true);
      cfgSet('notif_browser', true);
      cfgSet('sound_enabled', true);
      cfgSet('sound_volume',  0.5);
      cfgSet('theme',         'default');
      cfg = loadConfig();
      populateConfig();
      applyTheme(cfg.theme);
    });

    // Collapse toggle (click on header except cfg button)
    document.getElementById('tcw-header').addEventListener('click', (e) => {
      if (e.target.id === 'tcw-cfg-toggle') return;
      const body     = document.getElementById('tcw-body');
      const config   = document.getElementById('tcw-config');
      const btn      = document.getElementById('tcw-collapse-btn');
      const collapsed = body.style.display === 'none';
      body.style.display   = collapsed ? '' : 'none';
      config.style.display = 'none'; // always close config on collapse toggle
      btn.textContent = collapsed ? '▼' : '▲';
      cfg.collapsed = !collapsed;
      cfgSet('collapsed', cfg.collapsed);
    });
  }

  // ─── Update UI from chain data ────────────────────────────────────────────────
  /**
   * Renders chain data into the sidebar widget.
   * @param {object|null} chain  - chain object from API or null for no-chain
   */
  function renderChain(chain) {
    const noChain   = document.getElementById('tcw-no-chain');
    const dataBlock = document.getElementById('tcw-chain-data');

    // Determine if chain is active: current > 0 OR timeout > 0
    const active = chain && (chain.current > 0 || chain.timeout > 0);

    // Toggle pulse animation on root
    const root = document.getElementById('tcw-root');
    if (root) root.classList.toggle('tcw-chain-active', !!active);

    if (!active) {
      noChain.style.display   = '';
      dataBlock.style.display = 'none';
      document.getElementById('tcw-header-status').textContent = 'No chain';
      document.getElementById('tcw-header-timer').textContent  = '--:--';
      document.getElementById('tcw-header-timer').style.color  = 'var(--ocm-text-muted)';
      lastAttacker = null;
      setAlert(null);
      return;
    }

    noChain.style.display   = 'none';
    dataBlock.style.display = '';

    const hits    = chain.current;
    const timeout = countdown; // use local countdown for smoothness
    const max     = chain.max || 10;
    const mod     = chain.modifier || 1;

    // ── Hits bar ──────────────────────────────────────────────────────────────
    const nb       = nextBonus(hits);
    const pb       = prevBonus(hits);
    const base     = pb || 0;
    const segTotal = (nb || max) - base;
    const segProg  = Math.max(0, hits - base);
    const hitsPct  = segTotal > 0 ? Math.min(100, (segProg / segTotal) * 100) : 100;

    document.getElementById('tcw-hits').textContent = hits.toLocaleString();
    document.getElementById('tcw-hits-bar').style.width = `${hitsPct}%`;

    // ── Timeout bar & colour ──────────────────────────────────────────────────
    const maxTimeout = 300; // assume 5-minute max window; bar scales against this
    const timePct    = Math.min(100, (timeout / maxTimeout) * 100);

    let timeClass = 'ok';
    if (timeout <= cfg.warnSecs)         timeClass = 'warn';
    if (timeout <= Math.floor(cfg.warnSecs / 2)) timeClass = 'crit';

    const timeEl  = document.getElementById('tcw-timeout');
    const timeBar = document.getElementById('tcw-time-bar');
    timeEl.textContent = fmtTime(timeout);
    timeEl.className   = `tcw-stat-value ${timeClass}`;
    timeBar.style.width = `${timePct}%`;
    timeBar.className   = `tcw-progress-fill ${timeClass}`;

    // ── Header mirror ─────────────────────────────────────────────────────────
    document.getElementById('tcw-header-status').textContent = `${hits.toLocaleString()} hits`;
    document.getElementById('tcw-header-timer').textContent  = fmtTime(timeout);
    document.getElementById('tcw-header-timer').style.color  =
      timeClass === 'crit' ? 'var(--ocm-status-crit)' :
      timeClass === 'warn' ? 'var(--ocm-status-warn)' : 'var(--ocm-status-ok)';

    // ── Bonus milestones ──────────────────────────────────────────────────────
    const prevEl = document.getElementById('tcw-bonus-prev');
    const nextEl = document.getElementById('tcw-bonus-next');
    if (pb) { prevEl.textContent = pb.toLocaleString(); prevEl.className = 'tcw-bonus-val ok'; }
    else    { prevEl.textContent = '—';                  prevEl.className = 'tcw-bonus-val none'; }
    if (nb) { nextEl.textContent = `${nb.toLocaleString()} (${(nb - hits)} to go)`; nextEl.className = 'tcw-bonus-val'; }
    else    { nextEl.textContent = 'Max!';                nextEl.className = 'tcw-bonus-val ok'; }

    // ── Modifier ──────────────────────────────────────────────────────────────
    document.getElementById('tcw-modifier').textContent = `×${mod}`;

    // ── Last attacker ─────────────────────────────────────────────────────────
    const lastEl = document.getElementById('tcw-last-attacker');
    if (lastEl) lastEl.textContent = lastAttacker || '—';

    // ── Alert logic ───────────────────────────────────────────────────────────
    if (timeout <= cfg.warnSecs && timeout > 0) {
      setAlert('crit', `⚠ Chain breaks in ${fmtTime(timeout)}!`);
    } else {
      setAlert(null);
    }
  }

  // ─── Set visual alert banner ──────────────────────────────────────────────────
  function setAlert(type, msg = '') {
    if (!cfg.notifVisual) return;
    const el = document.getElementById('tcw-alert');
    if (!el) return;
    el.className = type ? `${type}` : '';
    el.textContent = msg;
    el.style.display = type ? '' : 'none';
  }

  // ─── Notification + sound dispatcher ─────────────────────────────────────────
  /**
   * Fires notifications based on chain state transitions.
   * Uses lastNotifType to suppress duplicate alerts.
   * @param {object} chain   - API chain data
   * @param {boolean} isNew  - true if chain just started (detected by hit count jump)
   */
  function dispatchNotifs(chain, isNew) {
    const hits    = chain.current;
    const timeout = chain.timeout;

    // ── Chain start ───────────────────────────────────────────────────────────
    if (isNew && lastNotifType !== 'start') {
      lastNotifType = 'start';
      if (cfg.soundEnabled) soundChainStart(cfg.soundVolume);
      if (cfg.notifBrowser) browserNotif('⚔ Chain started!', `${hits} hits — chain is live`);
    }

    // ── Timeout warning ───────────────────────────────────────────────────────
    if (timeout > 0 && timeout <= cfg.warnSecs && lastNotifType !== 'warn') {
      lastNotifType = 'warn';
      if (cfg.soundEnabled) soundWarn(cfg.soundVolume);
      if (cfg.notifBrowser) browserNotif('⚠ Chain timing out!', `${fmtTime(timeout)} remaining`);
    }

    // ── Bonus milestone just passed ───────────────────────────────────────────
    // Detect when current crosses a milestone (hits > prev count and milestone in range)
    const justHit = BONUS_MILESTONES.filter(m => m > lastChainCount && m <= hits);
    if (justHit.length > 0) {
      if (cfg.soundEnabled) soundBonus(cfg.soundVolume);
      if (cfg.notifBrowser) browserNotif('🎉 Chain bonus!', `Hit milestone: ${justHit[justHit.length - 1].toLocaleString()}`);
    }

    // ── Reset warn suppression when chain recovers (timeout > warn threshold) ─
    if (timeout > cfg.warnSecs && lastNotifType === 'warn') {
      lastNotifType = null;
    }
  }

  // ─── Fetch last chain attacker ────────────────────────────────────────────────
  // The faction attacks endpoint only returns attacks involving our faction.
  // Attacks are descending by time. The first entry with a non-null attacker
  // is the most recent outgoing hit by one of our members.
  async function fetchLastAttacker(apiKey) {
    try {
      const data = await apiFetch('/faction?selections=attacks', apiKey);
      if (!data.attacks || !data.attacks.length) return;

      for (const attack of data.attacks) {
        if (attack.attacker) {
          lastAttacker = attack.attacker.name;
          return;
        }
      }
    } catch (e) {
      // Non-critical — fail silently
    }
  }


  // Derives remaining seconds from chainEndTs vs Date.now() on every tick —
  // this means the countdown is always accurate regardless of poll interval,
  // matching Torn's own chain bar which uses the same approach.
  function startCountdown(endTimestamp) {
    stopCountdown();
    chainEndTs = endTimestamp;
    countdownTimer = setInterval(() => {
      const remaining = Math.max(0, chainEndTs - Math.floor(Date.now() / 1000));
      countdown = remaining;
      if (chainState && (chainState.current > 0 || chainState.timeout > 0)) {
        renderChain(chainState);
      }
      if (remaining <= 0) stopCountdown();
    }, 1000);
  }

  function stopCountdown() {
    if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
  }

  // ─── Poll the API ─────────────────────────────────────────────────────────────
  async function pollChain() {
    if (!cfg.apiKey) {
      // No key — show config prompt
      document.getElementById('tcw-no-chain').textContent = 'Set API key in ⚙ settings';
      return;
    }

    let data;
    try {
      data = await apiFetch('/faction?selections=chain', cfg.apiKey);
    } catch (e) {
      console.warn('[TCW] API error:', e.message);
      schedulePoll(POLL_IDLE);
      return;
    }

    const chain = data.chain;
    if (!chain) { schedulePoll(POLL_IDLE); return; }

    const active  = chain.current > 0 || chain.timeout > 0;
    const wasActive = chainState && (chainState.current > 0 || chainState.timeout > 0);
    const isNew   = active && !wasActive && chain.current > 0;

    // Track previous hit count for bonus milestone detection
    const prevCount = lastChainCount;
    lastChainCount = chain.current;

    // Dispatch notifications
    if (active) dispatchNotifs(chain, isNew);

    // Fetch last attacker name only when chain is active (non-blocking)
    if (active) fetchLastAttacker(cfg.apiKey);

    // Derive remaining time directly from the end timestamp vs local clock.
    // This is always accurate regardless of API latency or poll interval.
    const remaining = Math.max(0, chain.end - Math.floor(Date.now() / 1000));
    chainState = chain;
    countdown  = remaining;
    if (active) startCountdown(chain.end);

    // Render
    renderChain(chain);

    // Schedule next poll — faster when chain is active
    schedulePoll(active ? POLL_ACTIVE : POLL_IDLE);
  }

  function schedulePoll(delay) {
    if (pollTimer) { clearTimeout(pollTimer); }
    pollTimer = setTimeout(pollChain, delay);
  }

  function restartPoll() {
    if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
    stopCountdown();
    pollChain();
  }

  // ─── Inject into sidebar ──────────────────────────────────────────────────────
  function inject() {
    // Guard: don't inject twice
    if (document.getElementById('tcw-root')) return;

    const sidebar = document.getElementById('sidebar');
    if (!sidebar) return;

    const widget = buildUI();
    // Insert at top of sidebar, below any existing scripts' first widget
    sidebar.insertBefore(widget, sidebar.firstChild);

    // Apply saved theme
    applyTheme(cfg.theme);

    // Restore collapsed state
    if (cfg.collapsed) {
      document.getElementById('tcw-body').style.display   = 'none';
      document.getElementById('tcw-collapse-btn').textContent = '▲';
    }

    // Wire events
    wireConfig();

    // If no API key, open config panel immediately and hide body
    if (!cfg.apiKey) {
      document.getElementById('tcw-config').style.display = 'block';
      document.getElementById('tcw-body').style.display   = 'none';
    }

    // Request browser notification permission upfront if enabled
    if (cfg.notifBrowser) requestNotifPermission();

    // Start polling
    pollChain();
  }

  // ─── Entry point with retry ───────────────────────────────────────────────────
  let attempts = 0;
  const tryInject = setInterval(() => {
    attempts++;
    const sidebar = document.getElementById('sidebar');
    if (sidebar) {
      clearInterval(tryInject);
      inject();
    } else if (attempts >= 20) {
      // 10 seconds elapsed, give up
      clearInterval(tryInject);
    }
  }, 500);

  // Re-poll immediately when the tab becomes visible again (e.g. switching back
  // to Torn from another tab) — ensures chain starts are never missed.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible' && cfg.apiKey) {
      restartPoll();
    }
  });

  // Also re-inject on hash changes (Torn is a SPA)
  window.addEventListener('hashchange', () => {
    if (!document.getElementById('tcw-root')) {
      attempts = 0;
      const retry = setInterval(() => {
        attempts++;
        if (document.getElementById('sidebar')) {
          clearInterval(retry);
          inject();
        } else if (attempts >= 20) {
          clearInterval(retry);
        }
      }, 500);
    }
  });

})();