Torn War Coordinator

Faction war coordination

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/575828/1811042/Torn%20War%20Coordinator.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn War Coordinator
// @namespace    torn.warcoord
// @version      4.3.0
// @description  Faction war coordination
// @author       Your Faction
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @connect      camofdestin.workers.dev
// @connect      workers.dev
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

const SERVER_URL = "https://torn-war.camofdestin.workers.dev";
const FACTION_KEY = "Moonlit";

(function () {
  "use strict";

  function detectMe() {
    const link = document.querySelector('a[href*="/profiles.php?XID="]');
    if (link) {
      const m = link.href.match(/XID=(\d+)/);
      const name = link.textContent.trim();
      if (m) return { id: m[1], name };
    }
    return GM_getValue("me", null);
  }

  let ME = detectMe();
  if (ME) GM_setValue("me", ME);
  if (!ME) setTimeout(() => { ME = detectMe(); if (ME) GM_setValue("me", ME); }, 3000);

  let ADMIN_KEY = GM_getValue("admin_key", "");
  let knownAttackIds = new Set(JSON.parse(GM_getValue("known_attacks", "[]")));

  function api(path, opts = {}) {
    return new Promise((resolve, reject) => {
      const headers = { "Content-Type": "application/json", "X-Faction-Key": FACTION_KEY };
      if (ADMIN_KEY) headers["X-Admin-Key"] = ADMIN_KEY;
      GM_xmlhttpRequest({
        method: opts.method || "GET",
        url: SERVER_URL + path,
        headers,
        data: opts.body ? JSON.stringify(opts.body) : undefined,
        onload: (res) => {
          try {
            const data = JSON.parse(res.responseText);
            if (res.status === 401) resolve({ __error: data.error || "Unauthorized" });
            else resolve(data);
          } catch (e) { resolve({ __error: "Invalid response" }); }
        },
        onerror: () => reject(new Error("Network error")),
      });
    });
  }

  function tornApi(endpoint, keyOverride) {
    const apiKey = keyOverride || GM_getValue("torn_api_key", "");
    if (!apiKey) return Promise.reject(new Error("No Torn API key set"));
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: `https://api.torn.com/${endpoint}${endpoint.includes("?") ? "&" : "?"}key=${apiKey}`,
        onload: (res) => {
          try {
            const data = JSON.parse(res.responseText);
            if (data.error) reject(new Error(data.error.error || "Torn API error"));
            else resolve(data);
          } catch (e) { reject(new Error("Bad Torn API response")); }
        },
        onerror: () => reject(new Error("Torn API network error")),
      });
    });
  }

  function scrapeChainCount() {
    const bar = document.querySelector('[class*="bar-chain"], [class*="chain-bar"], #barChain');
    if (!bar) return null;
    const text = bar.textContent || "";
    const m = text.match(/(\d+)\s*\/\s*(\d+)/);
    if (!m) return null;
    const c = parseInt(m[1], 10);
    if (!c) return null;
    return { chainCount: c };
  }

  setInterval(async () => {
    const c = scrapeChainCount();
    if (c) { try { await api("/chain-update", { method: "POST", body: c }); } catch {} }
  }, 8000);

  async function checkAutoHit() {
    const apiKey = GM_getValue("my_personal_api_key", "");
    if (!apiKey || !ME || !lastState) return;
    const enemyIds = new Set((lastState.enemyFaction?.members || []).map(e => String(e.id)));
    if (!enemyIds.size) return;
    try {
      const data = await tornApi(`user/?selections=attacks`, apiKey);
      const attacks = data.attacks || {};
      const recent = Object.entries(attacks).filter(([attackId, a]) => {
        if (knownAttackIds.has(attackId)) return false;
        if (String(a.attacker_id) !== String(ME.id)) return false;
        if (!enemyIds.has(String(a.defender_id))) return false;
        if (a.result !== "Hospitalized" && a.result !== "Mugged" && a.result !== "Attacked" && a.result !== "Special") return false;
        const ts = (a.timestamp_ended || 0) * 1000;
        return Date.now() - ts < 5 * 60 * 1000;
      });
      for (const [attackId, a] of recent) {
        knownAttackIds.add(attackId);
        const target = (lastState.enemyFaction.members.find(e => String(e.id) === String(a.defender_id)))?.name || a.defender_name;
        await api("/auto-hit", { method: "POST", body: { id: ME.id, name: ME.name, target, attackId } });
      }
      const arr = Array.from(knownAttackIds).slice(-200);
      knownAttackIds = new Set(arr);
      GM_setValue("known_attacks", JSON.stringify(arr));
    } catch (e) {}
  }
  setInterval(checkAutoHit, 60000);

  let audioCtx = null;
  function beep(freq, dur, vol) {
    try {
      if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      const osc = audioCtx.createOscillator();
      const gain = audioCtx.createGain();
      osc.frequency.value = freq;
      gain.gain.setValueAtTime(vol, audioCtx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur / 1000);
      osc.connect(gain).connect(audioCtx.destination);
      osc.start(); osc.stop(audioCtx.currentTime + dur / 1000);
    } catch {}
  }
  function alarmTurn() { beep(1200,150,0.4); setTimeout(()=>beep(1600,150,0.4),180); setTimeout(()=>beep(1200,200,0.4),360); }
  function alarmWarning() { beep(600,300,0.5); setTimeout(()=>beep(900,300,0.5),350); }
  function notify(t, b) { try { if (typeof GM_notification === "function") GM_notification({ title: t, text: b, timeout: 8000 }); } catch {} }

  GM_addStyle(`
    #wc-panel { position: fixed; top: 80px; right: 12px; width: 380px; height: 600px; min-width: 280px; min-height: 150px; background: linear-gradient(180deg,#1a0f0a,#0a0503); border: 1px solid #5a2a18; border-radius: 8px; color: #e8d8c8; font-family: 'Trebuchet MS',sans-serif; font-size: 12px; box-shadow: 0 8px 32px rgba(0,0,0,.7); z-index: 99999; overflow: hidden; display: flex; flex-direction: column; }
    #wc-panel.collapsed { height: 36px !important; min-height: 36px !important; }
    #wc-panel.flash-turn { animation: wc-flash 0.5s 6; }
    @keyframes wc-flash { 0%,100%{box-shadow:0 0 0 0 rgba(255,140,60,.9);} 50%{box-shadow:0 0 40px 20px rgba(255,140,60,.9);} }
    #wc-header { padding: 8px 12px; background: linear-gradient(90deg,#6b1d0e,#2a0c05); border-bottom: 1px solid #5a2a18; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; touch-action: none; flex-shrink: 0; }
    #wc-header .wc-title { color: #ffb38a; font-size: 11px; }
    #wc-header .wc-toggle { color: #ffb38a; font-size: 16px; cursor: pointer; padding: 0 6px; }
    #wc-body { padding: 10px; overflow-y: auto; flex: 1; }
    .wc-resize-h { position: absolute; z-index: 100000; }
    .wc-resize-e { right: 0; top: 36px; bottom: 14px; width: 6px; cursor: ew-resize; }
    .wc-resize-w { left: 0; top: 36px; bottom: 14px; width: 6px; cursor: ew-resize; }
    .wc-resize-s { bottom: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
    .wc-resize-se { right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; background: linear-gradient(135deg,transparent 50%,#ff8c3c 50%); }
    .wc-resize-sw { left: 0; bottom: 0; width: 14px; height: 14px; cursor: nesw-resize; }
    #wc-panel.collapsed .wc-resize-h { display: none; }
    .wc-section { margin-bottom: 12px; }
    .wc-section-title { font-size: 10px; color: #c47a55; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 6px; border-bottom: 1px solid #3a1a10; padding-bottom: 3px; }
    .wc-stat-row { display: flex; justify-content: space-between; padding: 3px 0; }
    .wc-stat-row .label { color: #a08070; }
    .wc-stat-row .val { color: #ffd8b8; font-weight: 700; }
    .wc-mybanner { background: linear-gradient(135deg,#c44a08,#ff6b1c,#c44a08); background-size: 200% 200%; animation: wc-flow 2s infinite, wc-pulse 0.8s infinite; border: 2px solid #ffb38a; border-radius: 8px; padding: 14px; text-align: center; margin-bottom: 10px; color: #fff; }
    @keyframes wc-flow { 0%,100%{background-position:0% 50%;} 50%{background-position:100% 50%;} }
    @keyframes wc-pulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.02);} }
    .wc-mybanner .label { font-size: 10px; letter-spacing: 3px; }
    .wc-mybanner .number { font-size: 56px; font-weight: 900; line-height: 1; margin: 4px 0; font-family: Georgia,serif; }
    .wc-mybanner .name { font-size: 14px; font-weight: 700; }
    .wc-mybanner.warn { animation: wc-warn 0.4s infinite; }
    @keyframes wc-warn { 0%,100%{background:#c41818;} 50%{background:#ff3838;} }
    .wc-targets-list { background: #1a0a05; border: 1px solid #4a1808; border-radius: 6px; padding: 8px; margin-top: 8px; }
    .wc-targets-list .label { font-size: 9px; color: #ffb38a; letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 4px; }
    .wc-target-link { display: block; padding: 6px 8px; margin: 3px 0; background: #2a1408; border: 1px solid #5a2a18; border-radius: 4px; color: #ffd8b8; text-decoration: none; font-weight: 700; }
    .wc-target-link:hover { background: #6b2810; color: #fff; }
    .wc-current-other { background: linear-gradient(90deg,#4a1808,#2a0c05); border: 1px solid #6b2810; border-radius: 6px; padding: 10px; text-align: center; margin-bottom: 8px; }
    .wc-current-other .label { font-size: 9px; color: #c47a55; letter-spacing: 2px; text-transform: uppercase; }
    .wc-current-other .number { font-size: 32px; font-weight: 700; color: #ffd8b8; font-family: Georgia,serif; }
    .wc-current-other .name { font-size: 13px; color: #fff5ec; }
    .wc-rogue-alert { background: #4a0808; border: 2px solid #ff3838; border-radius: 6px; padding: 8px; margin-bottom: 8px; color: #ffaaaa; }
    .wc-hold-banner { background: #6b0808; border: 2px solid #c41818; border-radius: 6px; padding: 10px; margin-bottom: 8px; text-align: center; color: #ffaaaa; font-weight: 700; animation: wc-hold 1s infinite; }
    @keyframes wc-hold { 0%,100%{opacity:1;} 50%{opacity:0.7;} }
    .wc-queue-item { display: flex; justify-content: space-between; align-items: center; padding: 5px 8px; border-radius: 4px; margin: 2px 0; background: #1f100a; border-left: 3px solid transparent; gap: 4px; font-size: 11px; }
    .wc-queue-item.current { border-left-color: #ff8c3c; background: #2a1408; }
    .wc-queue-item.next { border-left-color: #c47a55; }
    .wc-queue-item.is-me { color: #ffd8b8; font-weight: 700; }
    .wc-queue-num { color: #ff8c3c; font-weight: 700; min-width: 38px; font-family: Georgia,serif; }
    .wc-queue-name { flex: 1; }
    .wc-queue-energy { color: #1ac442; font-size: 9px; min-width: 30px; text-align: right; }
    .wc-queue-energy.med { color: #ffb800; }
    .wc-queue-energy.low { color: #ff6b1c; }
    .wc-queue-energy.stale { opacity: 0.5; }
    .wc-queue-targets-count { color: #c47a55; font-size: 9px; }
    .wc-queue-hits { color: #888; font-size: 10px; }
    .wc-queue-actions { display: flex; gap: 2px; }
    button.wc-btn { background: #4a1808; color: #ffd8b8; border: 1px solid #6b2810; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 700; text-transform: uppercase; font-family: inherit; }
    button.wc-btn:hover { background: #6b2810; }
    button.wc-btn.primary { background: #c44a08; border-color: #ff6b1c; }
    button.wc-btn.success { background: #0a5a1a; border-color: #1ac442; }
    button.wc-btn.danger { background: #6b0808; border-color: #c41818; }
    button.wc-btn.tiny { padding: 2px 5px; font-size: 9px; }
    .wc-btn-row { display: flex; gap: 4px; flex-wrap: wrap; }
    .wc-toggle-active { width: 100%; padding: 10px; font-size: 13px; margin-bottom: 8px; }
    .wc-toggle-active.active { background: #0a5a1a; border-color: #1ac442; }
    .wc-claim-admin-btn { width: 100%; padding: 10px; margin-bottom: 8px; }
    .wc-energy-row { display: flex; gap: 4px; align-items: center; margin-bottom: 8px; padding: 6px; background: #1f100a; border-radius: 4px; flex-wrap: wrap; }
    .wc-energy-row label { font-size: 10px; color: #c47a55; }
    .wc-log { font-size: 10px; color: #a08070; max-height: 100px; overflow-y: auto; background: #0a0503; padding: 6px; border-radius: 4px; }
    .wc-log-entry { padding: 1px 0; border-bottom: 1px dotted #2a1410; }
    .wc-admin-section { background: #2a0a05; padding: 8px; border-radius: 4px; margin-top: 8px; }
    .wc-admin-section .wc-section-title { color: #ff8c3c; }
    .wc-admin-row { display: flex; gap: 4px; margin-bottom: 4px; align-items: center; }
    .wc-admin-row label { font-size: 10px; color: #c47a55; min-width: 80px; }
    input.wc-input, textarea.wc-input { background: #0a0503; color: #e8d8c8; border: 1px solid #3a1a10; border-radius: 3px; padding: 4px; font-size: 11px; font-family: monospace; box-sizing: border-box; flex: 1; }
    .wc-error { color: #ff6b6b; font-size: 10px; padding: 4px; }
    .wc-timer-bar { height: 6px; background: #1f100a; border-radius: 3px; overflow: hidden; margin-top: 4px; }
    .wc-timer-fill { height: 100%; transition: width 1s linear, background 0.3s; }
    .wc-split { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
    .wc-split-col { flex: 1; min-width: 200px; background: #0a0503; padding: 6px; border-radius: 4px; max-height: 400px; overflow-y: auto; }
    .wc-split-col h4 { margin: 0 0 6px 0; font-size: 10px; color: #ffb38a; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #3a1a10; padding-bottom: 3px; }
    .wc-faction-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 4px; border-bottom: 1px solid #1f100a; gap: 4px; font-size: 10px; }
    .wc-faction-row:hover { background: #1f100a; }
    .wc-faction-name { flex: 1; color: #ffd8b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .wc-faction-status { font-size: 9px; min-width: 45px; text-align: right; }
    .wc-faction-energy { color: #1ac442; font-size: 9px; min-width: 28px; text-align: right; }
    .wc-faction-energy.med { color: #ffb800; }
    .wc-faction-energy.low { color: #ff6b1c; }
    .wc-faction-energy.stale { opacity: 0.4; }
    .wc-assigned-badge { color: #c47a55; font-size: 9px; }
    .wc-assign-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #1a0f0a; border: 2px solid #ff8c3c; border-radius: 8px; padding: 16px; z-index: 100000; width: 380px; max-height: 80vh; overflow-y: auto; }
    .wc-assign-modal h3 { margin: 0 0 8px 0; color: #ffb38a; font-size: 14px; }
    .wc-assign-row { display: flex; align-items: center; padding: 4px 6px; border-bottom: 1px solid #2a1410; }
    .wc-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.7); z-index: 99998; }
  `);

  const panel = document.createElement("div");
  panel.id = "wc-panel";
  panel.innerHTML = `
    <div id="wc-header"><span class="wc-title">⚔ War Coordinator</span><span class="wc-toggle">−</span></div>
    <div id="wc-body">Loading...</div>
    <div class="wc-resize-h wc-resize-e" data-dir="e"></div>
    <div class="wc-resize-h wc-resize-w" data-dir="w"></div>
    <div class="wc-resize-h wc-resize-s" data-dir="s"></div>
    <div class="wc-resize-h wc-resize-sw" data-dir="sw"></div>
    <div class="wc-resize-h wc-resize-se" data-dir="se"></div>
  `;
  document.body.appendChild(panel);

  const header = panel.querySelector("#wc-header");
  const body = panel.querySelector("#wc-body");
  const toggleBtn = panel.querySelector(".wc-toggle");

  const savedPos = GM_getValue("panel_pos", null);
  if (savedPos?.top !== undefined) {
    panel.style.top = savedPos.top + "px";
    panel.style.left = savedPos.left + "px";
    panel.style.right = "auto";
  }
  const savedSize = GM_getValue("panel_size", null);
  if (savedSize?.width) panel.style.width = savedSize.width + "px";
  if (savedSize?.height) panel.style.height = savedSize.height + "px";

  let dragging = false, dox = 0, doy = 0, dmoved = false;
  function dragStart(x, y, t) {
    if (t === toggleBtn) return false;
    const r = panel.getBoundingClientRect();
    dox = x - r.left; doy = y - r.top;
    dragging = true; dmoved = false;
    return true;
  }
  function dragMove(x, y) {
    if (!dragging) return;
    dmoved = true;
    panel.style.left = Math.max(0, Math.min(window.innerWidth - 50, x - dox)) + "px";
    panel.style.top = Math.max(0, Math.min(window.innerHeight - 40, y - doy)) + "px";
    panel.style.right = "auto";
  }
  function dragEnd() {
    if (!dragging) return;
    dragging = false;
    if (dmoved) {
      const r = panel.getBoundingClientRect();
      GM_setValue("panel_pos", { top: r.top, left: r.left });
    }
  }

  let resizing = null;
  function resizeStart(x, y, dir) {
    const r = panel.getBoundingClientRect();
    resizing = { dir, sx: x, sy: y, sw: r.width, sh: r.height, sl: r.left, st: r.top };
  }
  function resizeMove(x, y) {
    if (!resizing) return;
    const { dir, sx, sy, sw, sh, sl, st } = resizing;
    const dx = x - sx, dy = y - sy;
    let w = sw, h = sh, l = sl, t = st;
    if (dir.includes("e")) w = Math.max(280, sw + dx);
    if (dir.includes("w")) { w = Math.max(280, sw - dx); l = sl + (sw - w); }
    if (dir.includes("s")) h = Math.max(150, sh + dy);
    panel.style.width = w + "px";
    panel.style.height = h + "px";
    panel.style.left = l + "px";
    panel.style.top = t + "px";
    panel.style.right = "auto";
  }
  function resizeEnd() {
    if (!resizing) return;
    const r = panel.getBoundingClientRect();
    GM_setValue("panel_size", { width: r.width, height: r.height });
    GM_setValue("panel_pos", { top: r.top, left: r.left });
    resizing = null;
  }

  header.addEventListener("mousedown", (e) => { if (dragStart(e.clientX, e.clientY, e.target)) e.preventDefault(); });
  panel.querySelectorAll(".wc-resize-h").forEach(h => {
    h.addEventListener("mousedown", (e) => { e.stopPropagation(); e.preventDefault(); resizeStart(e.clientX, e.clientY, h.dataset.dir); });
  });
  document.addEventListener("mousemove", (e) => {
    if (resizing) resizeMove(e.clientX, e.clientY);
    else dragMove(e.clientX, e.clientY);
  });
  document.addEventListener("mouseup", () => {
    if (resizing) resizeEnd();
    else dragEnd();
  });
  toggleBtn.addEventListener("click", (e) => {
    e.stopPropagation();
    panel.classList.toggle("collapsed");
    toggleBtn.textContent = panel.classList.contains("collapsed") ? "+" : "−";
  });

  let lastState = null, lastTurnId = null, warningFiredFor = null;

  function fmtTime(s) { if (s == null || s < 0) return "--:--"; const m = Math.floor(s/60), x = s%60; return `${m}:${String(x).padStart(2,"0")}`; }

  function liveTimer(s) {
    if (!s.chainTimerAt || !s.chainTimerSec) return null;
    const elapsed = Math.floor((Date.now() - s.chainTimerAt) / 1000);
    const remaining = s.chainTimerSec - elapsed;
    if (remaining < 0) return 0;
    if (remaining > s.chainTimerSec) return null;
    return remaining;
  }

  function timerColor(s) { if (s<=30) return "#ff3838"; if (s<=60) return "#ff6b1c"; if (s<=120) return "#ffb800"; return "#1ac442"; }
  function statusColor(s) { if (s==="Okay") return "#1ac442"; if (s==="Hospital") return "#ff6b1c"; if (s==="Traveling"||s==="Abroad") return "#888"; return "#aaa"; }
  function energyClass(e) { if (e>=150) return ""; if (e>=50) return "med"; return "low"; }
  function energyStale(t) { return Date.now() - (t||0) > 30*60*1000; }

  async function claimAdmin() {
    const c = prompt("Enter War Dispatcher password:");
    if (!c) return;
    ADMIN_KEY = c.trim();
    const res = await api("/verify-admin");
    if (res?.admin) { GM_setValue("admin_key", ADMIN_KEY); alert("You are now War Dispatcher."); refresh(); }
    else { ADMIN_KEY = ""; alert("Wrong password."); }
  }
  function setApiKey() { const cur = GM_getValue("torn_api_key",""); const c = prompt("Dispatcher Torn API key:", cur); if (c===null) return; GM_setValue("torn_api_key", c.trim()); alert("Saved."); }
  function setMyPersonalApiKey() { const cur = GM_getValue("my_personal_api_key",""); const c = prompt("YOUR Limited Access key for auto-hit:", cur); if (c===null) return; GM_setValue("my_personal_api_key", c.trim()); alert(c.trim() ? "Auto-hit ON." : "Auto-hit OFF."); }
  async function pullMyFaction() {
    try {
      const data = await tornApi(`v2/faction/48480/members?`);
      const members = (data.members||[]).map(m => ({ id: String(m.id), name: m.name, level: m.level, status: m.status?.state||"Okay", statusUntil: m.status?.until||0 }));
      if (!members.length) return alert("No members");
      await api("/admin/set-my-faction", { method: "POST", body: { factionId: 48480, factionName: "Moon Lust", members } });
      alert(`Pulled ${members.length} members.`); refresh();
    } catch (e) { alert("Error: " + e.message); }
  }
  async function pullEnemyFaction() {
    const fid = prompt("Enter ENEMY faction ID:");
    if (!fid) return;
    const factionId = parseInt(fid.trim(), 10);
    if (!factionId) return alert("Invalid ID");
    try {
      const data = await tornApi(`v2/faction/${factionId}/members?`);
      const members = (data.members||[]).map(m => ({ id: String(m.id), name: m.name, level: m.level, status: m.status?.state||"Okay", statusUntil: m.status?.until||0 }));
      if (!members.length) return alert("No members");
      await api("/admin/set-enemy-faction", { method: "POST", body: { factionId, members } });
      alert(`Pulled ${members.length} enemies.`); refresh();
    } catch (e) { alert("Error: " + e.message); }
  }
  async function refreshStatuses() {
    if (!lastState) return;
    try {
      const tasks = [
        lastState.myFaction?.id ? tornApi(`v2/faction/${lastState.myFaction.id}/members?`) : Promise.resolve(null),
        lastState.enemyFaction?.id ? tornApi(`v2/faction/${lastState.enemyFaction.id}/members?`) : Promise.resolve(null),
      ];
      const [my, en] = await Promise.all(tasks);
      const b = {};
      if (my?.members) b.myMembers = my.members.map(m => ({ id: String(m.id), name: m.name, level: m.level, status: m.status?.state||"Okay", statusUntil: m.status?.until||0 }));
      if (en?.members) b.enemyMembers = en.members.map(m => ({ id: String(m.id), name: m.name, level: m.level, status: m.status?.state||"Okay", statusUntil: m.status?.until||0 }));
      await api("/admin/refresh-statuses", { method: "POST", body: b });
      refresh();
    } catch (e) { alert("Refresh failed: " + e.message); }
  }

  function openAssignModal(eId, eName) {
    if (!lastState) return;
    const ov = document.createElement("div"); ov.className = "wc-modal-overlay";
    const md = document.createElement("div"); md.className = "wc-assign-modal";
    const cur = (lastState.assignments && lastState.assignments[eId]) || [];
    const rows = lastState.roster.map(m => {
      const ch = cur.includes(m.id) ? "checked" : "";
      const er = lastState.energyReports?.[m.id];
      const ee = er && !energyStale(er.reportedAt) ? ` <span style="color:#1ac442;">${er.energy}E</span>` : "";
      return `<div class="wc-assign-row"><label style="flex:1;cursor:pointer;"><input type="checkbox" data-mid="${m.id}" ${ch}> ${m.name}${ee}</label></div>`;
    }).join("");
    md.innerHTML = `<h3>Assign target: ${eName}</h3>
      ${rows || '<div style="color:#888;">No members</div>'}
      <div style="display:flex;gap:6px;margin-top:12px;">
        <button class="wc-btn primary" id="wc-asv" style="flex:1;">Save</button>
        <button class="wc-btn" id="wc-acn">Cancel</button>
      </div>`;
    document.body.appendChild(ov); document.body.appendChild(md);
    const cl = () => { ov.remove(); md.remove(); };
    ov.onclick = cl; md.querySelector("#wc-acn").onclick = cl;
    md.querySelector("#wc-asv").onclick = async () => {
      const ids = Array.from(md.querySelectorAll("input[type=checkbox]:checked")).map(c => c.dataset.mid);
      await api("/admin/assign", { method: "POST", body: { enemyId: eId, memberIds: ids } });
      cl(); refresh();
    };
  }

  async function quickClaimHit() {
    if (!ME) return;
    await api("/claim-hit", { method: "POST", body: { id: ME.id, name: ME.name } });
    refresh();
  }

  function render(state) {
    if (state?.__error) { body.innerHTML = `<div class="wc-error">Error: ${state.__error}</div>`; return; }
    if (!state) { body.innerHTML = '<div class="wc-error">Could not reach server</div>'; return; }
    lastState = state;
    const isAdmin = !!state.isAdmin;
    const meId = ME?.id;
    const meInRoster = state.roster.find(m => m.id === meId);
    const meActive = !!meInRoster?.active;
    const currentId = state.queue[state.currentIndex];
    const current = state.roster.find(m => m.id === currentId);
    const isMyTurn = currentId === meId;
    const timerSec = liveTimer(state);
    const myEnergy = state.energyReports?.[meId];

    if (currentId && currentId !== lastTurnId) {
      warningFiredFor = null;
      if (isMyTurn) {
        panel.classList.remove("flash-turn"); void panel.offsetWidth; panel.classList.add("flash-turn");
        alarmTurn();
        notify("YOUR TURN", `Call out #${meInRoster?.assignedNumber || "?"}`);
      }
    }
    lastTurnId = currentId;

    if (isMyTurn && timerSec !== null && timerSec > 0 && timerSec <= state.warningSec && warningFiredFor !== currentId) {
      warningFiredFor = currentId; alarmWarning();
      notify("Time warning", `${fmtTime(timerSec)} left`);
    }

    const myTargetIds = [];
    if (state.assignments && state.enemyFaction?.members) {
      Object.keys(state.assignments).forEach(eid => { if (state.assignments[eid].includes(meId)) myTargetIds.push(eid); });
    }
    const myTargets = myTargetIds.map(eid => state.enemyFaction.members.find(e => e.id === eid)).filter(Boolean);

    const queueHtml = state.queue.map((id, i) => {
      const m = state.roster.find(r => r.id === id);
      if (!m) return "";
      const cls = ["wc-queue-item",
        i === state.currentIndex ? "current" : "",
        i === (state.currentIndex + 1) % state.queue.length ? "next" : "",
        id === meId ? "is-me" : ""].filter(Boolean).join(" ");
      const er = state.energyReports?.[m.id];
      const stale = er ? energyStale(er.reportedAt) : false;
      const eHtml = er ? `<span class="wc-queue-energy ${energyClass(er.energy)} ${stale ? "stale" : ""}">${er.energy}E</span>` : `<span class="wc-queue-energy stale">--</span>`;
      let tCount = 0;
      if (state.assignments) Object.keys(state.assignments).forEach(eid => { if (state.assignments[eid].includes(m.id)) tCount++; });
      const adminBtns = isAdmin ? `<div class="wc-queue-actions">
          <button class="wc-btn tiny" data-jump="${m.id}">▶</button>
          <button class="wc-btn tiny" data-up="${m.id}">↑</button>
          <button class="wc-btn tiny" data-down="${m.id}">↓</button>
          <button class="wc-btn tiny danger" data-remove="${m.id}">✕</button>
        </div>` : "";
      return `<div class="${cls}">
        <span class="wc-queue-num">#${m.assignedNumber || "?"}</span>
        <span class="wc-queue-name">${m.name}</span>
        ${eHtml}
        ${tCount ? `<span class="wc-queue-targets-count">🎯${tCount}</span>` : ""}
        <span class="wc-queue-hits">${m.hits || 0}</span>
        ${adminBtns}
      </div>`;
    }).join("");

    const inactiveHtml = state.roster.filter(m => !m.active).map(m =>
      `<div class="wc-queue-item" style="opacity:.5"><span class="wc-queue-name">${m.name}</span><span class="wc-queue-hits">inactive</span></div>`
    ).join("");

    const logHtml = (state.log || []).slice(0, 8).map(e => `<div class="wc-log-entry">${e.msg}</div>`).join("");
    const showHoldBanner = state.hitLock && meActive && !isMyTurn && current;
    const warnNow = isMyTurn && timerSec !== null && timerSec > 0 && timerSec <= state.warningSec;

    const timerDisplay = timerSec !== null ? fmtTime(timerSec) : "--:--";
    const timerColorVal = timerSec !== null ? timerColor(timerSec) : "#666";
    const timerBarWidth = timerSec !== null ? Math.min(100, (timerSec / 300) * 100) : 0;

    let splitHtml = "";
    if (isAdmin && (state.myFaction?.members?.length || state.enemyFaction?.members?.length)) {
      const myRows = (state.myFaction?.members || []).map(m => {
        const er = state.energyReports?.[m.id];
        const stale = er ? energyStale(er.reportedAt) : false;
        const e = er ? `<span class="wc-faction-energy ${energyClass(er.energy)} ${stale ? "stale" : ""}">${er.energy}E</span>` : `<span class="wc-faction-energy stale">--</span>`;
        return `<div class="wc-faction-row">
          <span class="wc-faction-name">${m.name} <span style="color:#666;">L${m.level}</span></span>
          ${e}
          <span class="wc-faction-status" style="color:${statusColor(m.status)}">${m.status}</span>
        </div>`;
      }).join("");
      const enemyRows = (state.enemyFaction?.members || []).map(e => {
        const assigned = (state.assignments && state.assignments[e.id]) || [];
        const names = assigned.map(mid => state.roster.find(m => m.id === mid)?.name).filter(Boolean);
        return `<div class="wc-faction-row">
          <span class="wc-faction-name">${e.name} <span style="color:#666;">L${e.level}</span></span>
          <span class="wc-faction-status" style="color:${statusColor(e.status)}">${e.status}</span>
          <span class="wc-assigned-badge">${names.length ? "→" + names.length : ""}</span>
          <button class="wc-btn tiny primary" data-assign-enemy="${e.id}" data-assign-name="${e.name}">Assign</button>
        </div>`;
      }).join("");
      splitHtml = `<div class="wc-split">
        <div class="wc-split-col"><h4>${state.myFaction?.name || "My Faction"} (${state.myFaction?.members?.length || 0})</h4>${myRows || '<div style="color:#666;font-size:10px;">Click "Pull MY Faction"</div>'}</div>
        <div class="wc-split-col"><h4>${state.enemyFaction?.name || "Enemy"} (${state.enemyFaction?.members?.length || 0})</h4>${enemyRows || '<div style="color:#666;font-size:10px;">Click "Pull Enemy Faction"</div>'}</div>
      </div>`;
    }

    body.innerHTML = `
      ${state.rogueAlert ? `<div class="wc-rogue-alert">
        <div style="font-weight:700;color:#ff6b6b;">ROGUE HIT</div>
        <div>Chain at <b>${state.rogueAlert.actual}</b>, expected <b>${state.rogueAlert.expected}</b>.</div>
        ${isAdmin ? `<div style="margin-top:6px;display:flex;gap:4px;">
          <button class="wc-btn primary" id="wc-sync-chain">Sync Numbers</button>
          <button class="wc-btn" id="wc-dismiss-rogue">Dismiss</button>
        </div>` : ""}
      </div>` : ""}

      ${isMyTurn && current ? `<div class="wc-mybanner ${warnNow ? "warn" : ""}">
        <div class="label">${warnNow ? "HIT NOW" : "YOUR TURN"}</div>
        <div class="number">#${current.assignedNumber || "?"}</div>
        <div class="name">${current.name}</div>
        <div style="font-size:11px;margin-top:4px;">${timerDisplay}</div>
      </div>` : current ? `<div class="wc-current-other">
        <div class="label">Currently Up</div>
        <div class="number">#${current.assignedNumber || "?"}</div>
        <div class="name">${current.name}</div>
      </div>` : ""}

      ${showHoldBanner ? `<div class="wc-hold-banner">HOLD - #${current.assignedNumber} IS UP</div>` : ""}

      ${myTargets.length ? `<div class="wc-targets-list">
        <div class="label">🎯 Your Assigned Targets - Click to Attack</div>
        ${myTargets.map(t => `<a class="wc-target-link" href="https://www.torn.com/loader.php?sid=attack&user2ID=${t.id}" target="_blank">${t.name} <span style="float:right;color:${statusColor(t.status)};font-size:10px;">${t.status}</span></a>`).join("")}
      </div>` : ""}

      <div class="wc-section">
        <div class="wc-section-title">${state.warTitle || "War"}</div>
        <div class="wc-stat-row"><span class="label">Chain</span><span class="val">${state.chainCount || 0}</span></div>
        <div class="wc-stat-row"><span class="label">Hits Logged</span><span class="val">${state.hitsLogged || 0}</span></div>
        <div class="wc-stat-row"><span class="label">Timer</span><span class="val" id="wc-timer-text" style="color:${timerColorVal}">${timerDisplay}</span></div>
        <div class="wc-timer-bar"><div class="wc-timer-fill" id="wc-timer-fill" style="width:${timerBarWidth}%;background:${timerColorVal}"></div></div>
        <div class="wc-stat-row" style="margin-top:6px;"><span class="label">Active</span><span class="val">${state.queue.length} / ${state.roster.length}</span></div>
      </div>

      ${ME ? `
      <div class="wc-energy-row">
        <label>My Energy:</label>
        <input type="number" class="wc-input" id="wc-energy-input" value="${myEnergy?.energy || ''}" placeholder="?" style="max-width:60px;">
        <button class="wc-btn" id="wc-energy-save">Save</button>
        <button class="wc-btn primary" id="wc-claim-hit">I Got My Hit</button>
      </div>
      <button class="wc-btn wc-toggle-active ${meActive ? "active" : ""}" id="wc-toggle-me">${meActive ? "I'M IN THE CHAIN" : "Join the chain"}</button>
      ` : `<div class="wc-error">Detecting Torn ID...</div>`}

      <div class="wc-section">
        <div class="wc-section-title">Chain Order</div>
        ${queueHtml || '<div style="color:#6a4a3a;font-size:11px;">No one active.</div>'}
      </div>

      ${inactiveHtml ? `<div class="wc-section"><div class="wc-section-title">Inactive</div>${inactiveHtml}</div>` : ""}
      ${logHtml ? `<div class="wc-section"><div class="wc-section-title">Recent</div><div class="wc-log">${logHtml}</div></div>` : ""}

      ${!isAdmin ? `<button class="wc-btn wc-claim-admin-btn" id="wc-claim-admin">🛡️ Become War Dispatcher</button>
      <div style="margin-top:8px;font-size:10px;color:#6a4a3a;text-align:center;">
        <button class="wc-btn tiny" id="wc-set-personal-key">${GM_getValue("my_personal_api_key", "") ? "Auto-hit ON ✓" : "Enable auto-hit"}</button>
      </div>` : `
      <div class="wc-admin-section">
        <div class="wc-section-title">Dispatcher <button class="wc-btn tiny" id="wc-release-admin" style="float:right">Step Down</button></div>
        <div class="wc-btn-row" style="margin-bottom:6px;">
          <button class="wc-btn primary" id="wc-next">Next ▶</button>
          <button class="wc-btn primary" id="wc-reset-timer">⏱ Start 5:00</button>
          <button class="wc-btn ${state.hitLock ? "success" : ""}" id="wc-toggle-lock">${state.hitLock ? "Lock ON" : "Lock OFF"}</button>
          <button class="wc-btn" id="wc-reset-hits">Reset Hits</button>
          <button class="wc-btn danger" id="wc-reset-all">Reset All</button>
        </div>
        <div class="wc-admin-row"><label>Start #</label><input type="number" class="wc-input" id="wc-start-num" value="${state.startingNumber}" min="1"><button class="wc-btn" id="wc-set-start">Set</button></div>
        <div class="wc-admin-row"><label>Warn (s)</label><input type="number" class="wc-input" id="wc-warn-sec" value="${state.warningSec}" min="10" max="290"><button class="wc-btn" id="wc-set-warn">Set</button></div>

        <div style="border-top:1px solid #3a1a10;margin-top:10px;padding-top:8px;">
          <div style="font-size:10px;color:#c47a55;margin-bottom:4px;">🎯 Faction Data</div>
          <div class="wc-btn-row">
            <button class="wc-btn" id="wc-set-apikey">Set API Key</button>
            <button class="wc-btn primary" id="wc-pull-my">Pull MY Faction</button>
            <button class="wc-btn primary" id="wc-pull-enemy">Pull Enemy Faction</button>
            <button class="wc-btn" id="wc-refresh-statuses">Refresh Statuses</button>
          </div>
          ${splitHtml}
        </div>

        ${state.discordConfigured ? `<div style="border-top:1px solid #3a1a10;margin-top:10px;padding-top:8px;">
          <div style="font-size:10px;color:#c47a55;margin-bottom:4px;">Discord</div>
          <div class="wc-btn-row">
            <button class="wc-btn ${state.discordEnabled ? "success" : ""}" id="wc-toggle-discord">${state.discordEnabled ? "Discord ON" : "Discord OFF"}</button>
            <button class="wc-btn primary" id="wc-start-war">Announce Start</button>
          </div>
        </div>` : ""}
      </div>`}
    `;

    const tBtn = body.querySelector("#wc-toggle-me");
    if (tBtn) tBtn.onclick = async () => { await api("/toggle-active", { method: "POST", body: { id: ME.id, name: ME.name, active: !meActive } }); refresh(); };
    const eBtn = body.querySelector("#wc-energy-save");
    if (eBtn) eBtn.onclick = async () => {
      const v = body.querySelector("#wc-energy-input").value.trim();
      if (!v) return;
      await api("/report-energy", { method: "POST", body: { id: ME.id, name: ME.name, energy: parseInt(v, 10) } });
      refresh();
    };
    const claimBtn2 = body.querySelector("#wc-claim-hit");
    if (claimBtn2) claimBtn2.onclick = quickClaimHit;
    const claimAdminBtn = body.querySelector("#wc-claim-admin");
    if (claimAdminBtn) claimAdminBtn.onclick = claimAdmin;
    const personalKeyBtn = body.querySelector("#wc-set-personal-key");
    if (personalKeyBtn) personalKeyBtn.onclick = setMyPersonalApiKey;
    const releaseBtn = body.querySelector("#wc-release-admin");
    if (releaseBtn) releaseBtn.onclick = () => { ADMIN_KEY = ""; GM_setValue("admin_key", ""); refresh(); };

    if (isAdmin) {
      body.querySelector("#wc-next").onclick = async () => { await api("/admin/next", { method: "POST", body: {} }); refresh(); };
      body.querySelector("#wc-reset-timer").onclick = async () => { await api("/admin/reset-timer", { method: "POST", body: {} }); refresh(); };
      body.querySelector("#wc-toggle-lock").onclick = async () => { await api("/admin/toggle-lock", { method: "POST", body: {} }); refresh(); };
      body.querySelector("#wc-reset-hits").onclick = async () => { if (confirm("Reset hits?")) { await api("/admin/reset", { method: "POST", body: { what: "hits" } }); refresh(); } };
      body.querySelector("#wc-reset-all").onclick = async () => { if (confirm("FULL RESET?")) { await api("/admin/reset", { method: "POST", body: { what: "all" } }); refresh(); } };
      body.querySelector("#wc-set-start").onclick = async () => { const n = parseInt(body.querySelector("#wc-start-num").value, 10); await api("/admin/set-starting-number", { method: "POST", body: { startingNumber: n } }); refresh(); };
      body.querySelector("#wc-set-warn").onclick = async () => { const n = parseInt(body.querySelector("#wc-warn-sec").value, 10); await api("/admin/set-warning", { method: "POST", body: { warningSec: n } }); refresh(); };
      body.querySelector("#wc-set-apikey").onclick = setApiKey;
      body.querySelector("#wc-pull-my").onclick = pullMyFaction;
      body.querySelector("#wc-pull-enemy").onclick = pullEnemyFaction;
      body.querySelector("#wc-refresh-statuses").onclick = refreshStatuses;
      body.querySelectorAll("[data-assign-enemy]").forEach(b => {
        b.onclick = (e) => { e.stopPropagation(); openAssignModal(b.dataset.assignEnemy, b.dataset.assignName); };
      });
      body.querySelectorAll("[data-jump]").forEach(b => { b.onclick = async (e) => { e.stopPropagation(); await api("/admin/jump-to", { method: "POST", body: { id: b.dataset.jump } }); refresh(); }; });
      body.querySelectorAll("[data-up]").forEach(b => { b.onclick = async (e) => { e.stopPropagation(); await api("/admin/move", { method: "POST", body: { id: b.dataset.up, direction: "up" } }); refresh(); }; });
      body.querySelectorAll("[data-down]").forEach(b => { b.onclick = async (e) => { e.stopPropagation(); await api("/admin/move", { method: "POST", body: { id: b.dataset.down, direction: "down" } }); refresh(); }; });
      body.querySelectorAll("[data-remove]").forEach(b => { b.onclick = async (e) => { e.stopPropagation(); if (confirm("Remove?")) { await api("/admin/remove-from-queue", { method: "POST", body: { id: b.dataset.remove } }); refresh(); } }; });
      const syncBtn = body.querySelector("#wc-sync-chain");
      if (syncBtn) syncBtn.onclick = async () => { await api("/admin/sync-chain", { method: "POST", body: {} }); refresh(); };
      const dismissBtn = body.querySelector("#wc-dismiss-rogue");
      if (dismissBtn) dismissBtn.onclick = async () => { await api("/admin/dismiss-rogue", { method: "POST", body: {} }); refresh(); };
      const dBtn = body.querySelector("#wc-toggle-discord");
      if (dBtn) dBtn.onclick = async () => { await api("/admin/toggle-discord", { method: "POST", body: {} }); refresh(); };
      const swBtn = body.querySelector("#wc-start-war");
      if (swBtn) swBtn.onclick = async () => { if (confirm("Announce to Discord?")) await api("/admin/start-war", { method: "POST", body: {} }); };
    }
  }

  setInterval(() => {
    if (!lastState) return;
    const sec = liveTimer(lastState);
    const text = body.querySelector("#wc-timer-text");
    const fill = body.querySelector("#wc-timer-fill");
    if (sec === null) {
      if (text) { text.textContent = "--:--"; text.style.color = "#666"; }
      if (fill) { fill.style.width = "0%"; }
    } else {
      if (text) { text.textContent = fmtTime(sec); text.style.color = timerColor(sec); }
      if (fill) { fill.style.width = Math.min(100, (sec / 300) * 100) + "%"; fill.style.background = timerColor(sec); }
    }
  }, 1000);

  async function refresh() {
    try { const state = await api("/state"); render(state); }
    catch (e) { body.innerHTML = `<div class="wc-error">Connection error: ${e.message}</div>`; }
  }

  refresh();
  setInterval(refresh, 6000);
})();