Torn War Tracker

Tracks enemy faction users' status with live timers

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Torn War Tracker
// @namespace    http://tampermonkey.net/
// @version      1.53
// @description  Tracks enemy faction users' status with live timers
// @match        https://www.torn.com/*
// @grant        GM_notification
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// ==/UserScript==

(function () {
    'use strict';

    let pollInterval = null;
    let renderInterval = null;

    const tracked = new Map();

    const LS = {
        faction: "ht_faction",
        api: "ht_api"
    };

    const UI_LAYOUT_KEY = "ht_ui_layout"

    const STATE_KEY = "ht_state";

    window.addEventListener("load", init);

    function init() {
        createUI();
        loadSettings();
        restoreState();
    }

    function makeDraggable(panel, handle) {
        let dragging = false;
        let offsetX = 0;
        let offsetY = 0;

        handle.style.cursor = "move";

        handle.addEventListener("mousedown", (e) => {
            dragging = true;
            offsetX = e.clientX - panel.offsetLeft;
            offsetY = e.clientY - panel.offsetTop;
        });

        document.addEventListener("mousemove", (e) => {
            if (!dragging) return;

            panel.style.left = `${e.clientX - offsetX}px`;
            panel.style.top = `${e.clientY - offsetY}px`;
        });

        document.addEventListener("mouseup", () => {
            dragging = false;
            saveLayout(panel);
        });
    }

    function makeResizable(panel) {
        const resizer = document.createElement("div");

        Object.assign(resizer.style, {
            width: "10px",
            height: "10px",
            position: "absolute",
            right: "0",
            bottom: "0",
            cursor: "se-resize",
            background: "#666",
            zIndex: 999999
        });

        panel.appendChild(resizer);

        let resizing = false;
        let startX = 0;
        let startY = 0;
        let startW = 0;
        let startH = 0;

        const minW = 200;
        const minH = 150;

        resizer.addEventListener("mousedown", (e) => {
            resizing = true;

            startX = e.clientX;
            startY = e.clientY;

            startW = panel.offsetWidth;
            startH = panel.offsetHeight;

            e.preventDefault();
            e.stopPropagation();
        });

        document.addEventListener("mousemove", (e) => {
            if (!resizing) return;

            const newW = startW + (e.clientX - startX);
            const newH = startH + (e.clientY - startY);

            panel.style.width = Math.max(minW, newW) + "px";
            panel.style.height = Math.max(minH, newH) + "px";
        });

        document.addEventListener("mouseup", () => {
            resizing = false;
            saveLayout(panel);
        });
    }

    function createUI() {
        const panel = document.createElement("div");

        panel.innerHTML = `
            <div id="hdr" style="
            font-weight:700;
            font-size:14px;
            padding-bottom:10px;
            margin-bottom:10px;
            border-bottom:1px solid #343a46;
            display:flex;
            justify-content:space-between;
            align-items:center;
                ">
                    <span>⚔️ War Tracker</span>
                    <span id="liveDot">●</span>
            </div>

            <input id="factionId" placeholder="Enemy Faction ID" class="wt-input"><br><br>
            <input id="apiKey" placeholder="Public API Key" type="password" class="wt-input"><br><br>

            <div id="btnRow">
                <button id="startBtn">Start</button>
                <button id="stopBtn">Stop</button>
            </div>


            <div id="status" style="
                background:#252932;
                border:1px solid #343a46;
                border-radius:8px;
                padding:6px 8px;
                color:#9aa4b2;
                margin-bottom:8px;
            ">
                Idle
            </div>
            <div id="list" style="flex:1;overflow:auto;margin-top:10px;"></div>
        `;

        Object.assign(panel.style, {
            position: "fixed",
            top: "120px",
            left: "20px",
            right: "auto",
            width: "260px",
            height: "400px",
            background: "#1e2128",
            border: "1px solid #343a46",
            boxShadow: "0 8px 30px rgba(0,0,0,.35)",
            borderRadius: "12px",
            color: "white",
            padding: "10px",
            fontSize: "12px",
            zIndex: 999999,
            overflow: "hidden",
            display: "flex",
            flexDirection: "column"
        });

        panel.style.left = "20px";
        panel.style.right = "auto";

        document.body.appendChild(panel);

        restoreLayout(panel);

        document.getElementById("liveDot").style.color = "#55ff88";

        makeDraggable(panel, document.getElementById("hdr"));
        makeResizable(panel);

        document.getElementById("startBtn").onclick = start;
        document.getElementById("stopBtn").onclick = stop;

        const startBtn = document.getElementById("startBtn");
        const stopBtn = document.getElementById("stopBtn");

        Object.assign(document.getElementById("btnRow").style, {
            display: "flex",
            gap: "8px",
            marginBottom: "10px"
        });

        Object.assign(startBtn.style, {
            flex: "1",
            backgroundColor: "#28a745",
            color: "white",
            border: "none",
            padding: "8px 12px",
            borderRadius: "8px",
            cursor: "pointer",
            fontWeight: "600",
            transition: "all .15s ease"
        });

        Object.assign(stopBtn.style, {
            flex: "1",
            backgroundColor: "#dc3545",
            color: "white",
            border: "none",
            padding: "8px 12px",
            borderRadius: "8px",
            cursor: "pointer",
            fontWeight: "600",
            transition: "all .15s ease"
        });

        startBtn.onmouseenter = () => {
            startBtn.style.transform = "translateY(-1px)";
            startBtn.style.boxShadow = "0 3px 10px rgba(40,167,69,.45)";
        };

        startBtn.onmouseleave = () => {
            startBtn.style.transform = "translateY(0)";
            startBtn.style.boxShadow = "none";
        };

        stopBtn.onmouseenter = () => {
            stopBtn.style.transform = "translateY(-1px)";
            stopBtn.style.boxShadow = "0 3px 10px rgba(220,53,69,.45)";
        };

        stopBtn.onmouseleave = () => {
            stopBtn.style.transform = "translateY(0)";
            stopBtn.style.boxShadow = "none";
        };

        const style = document.createElement("style");
        style.textContent = `
            #list a {
                color: white;
                text-decoration: none;
                font-weight:600;
                transition:.15s;
            }

            #list a:visited {
                color: white;
            }

            #list a:hover {
                color: #bb88ff;
                text-decoration: underline;
            }

            .wt-input {
                width:100%;
                box-sizing:border-box;

                background:#252932;
                border:1px solid #343a46;

                color:white;

                border-radius:8px;

                padding:8px;

                margin-bottom:8px;
            }

            #list, #hdr, .wt-input {
                box-sizing: border-box;
            }
`;
        document.head.appendChild(style);
    }

    function loadSettings() {
        const f = localStorage.getItem(LS.faction);
        const a = localStorage.getItem(LS.api);

        if (f) document.getElementById("factionId").value = f;
        if (a) document.getElementById("apiKey").value = a;
    }

    function saveLayout(panel) {
        localStorage.setItem(UI_LAYOUT_KEY, JSON.stringify({
            left: panel.offsetLeft,
            top: panel.offsetTop,
            width: panel.offsetWidth,
            height: panel.offsetHeight
        }));
    }

    function restoreLayout(panel) {
        const raw = localStorage.getItem(UI_LAYOUT_KEY);
        if (!raw) return;

        try {
            const s = JSON.parse(raw);

            panel.style.left = (s.left ?? 20) + "px";
            panel.style.top = (s.top ?? 120) + "px";
            panel.style.width = (s.width ?? 260) + "px";
            panel.style.height = (s.height ?? 400) + "px";

            panel.style.right = "auto";
        } catch (e) {
            console.error("layout restore failed", e);
        }
    }

    function saveState(isRunning) {
        localStorage.setItem(STATE_KEY, JSON.stringify({
            running: isRunning,
            data: Array.from(tracked.entries())
        }));
    }

    function restoreState() {
        const raw = localStorage.getItem(STATE_KEY);
        if (!raw) return;

        try {
            const state = JSON.parse(raw);

            if (state.data) {
                tracked.clear();

                for (const [id, p] of state.data) {
                    tracked.set(id, p);
                }

                render();
            }

            if (state.running) {
                setTimeout(() => {
                    start();
                    refreshData();
                }, 500);
            }

        } catch (e) {
            console.error("restore failed", e);
        }
    }

    function saveSettings() {
        localStorage.setItem(LS.faction, document.getElementById("factionId").value);
        localStorage.setItem(LS.api, document.getElementById("apiKey").value);
    }

    function formatTime(sec) {
        sec = Math.max(0, sec);

        const days = Math.floor(sec / 86400);
        sec %= 86400;

        const hours = Math.floor(sec / 3600);
        sec %= 3600;

        const mins = Math.floor(sec / 60);
        const secs = sec % 60;

        const parts = [];

        if (days) parts.push(`${days}d`);
        if (hours) parts.push(`${hours}h`);
        if (mins) parts.push(`${mins}m`);
        if (secs) parts.push(`${secs}s`);

        const filtered = parts.filter(Boolean);

        if (!filtered.length) return "0s";

        return filtered.slice(0, 2).join(" ");
    }

    async function fetchFaction(fid, key) {
        const url = `https://api.torn.com/faction/${fid}?selections=basic&key=${key}`;
        const res = await fetch(url);
        return await res.json();
    }

    async function refreshData() {
        const fid = document.getElementById("factionId").value;
        const key = document.getElementById("apiKey").value;

        if (!fid || !key) return;

        saveSettings();

        const data = await fetchFaction(fid, key);

        if (!data || !data.members) {
            document.getElementById("status").innerText = "API Error";
            return;
        }

        tracked.clear();

        for (const [id, m] of Object.entries(data.members)) {
            if (m.status?.state === "Hospital") {
                tracked.set(id, {
                    id,
                    name: m.name,
                    until: m.status.until,
                    details: m.status.details,
                    online: m.last_action.status
                });
            } else if (m.status?.state === "Okay") {
                tracked.set(id, {
                    id,
                    name: m.name,
                    until: 0,
                    online: m.last_action.status
                });
            }
        }

        saveState(true);

        document.getElementById("status").innerText =
            `Tracking ${tracked.size} users`;
    }

    function emoji(p) {
        if (p.online === "Online") {
            return "🟢"
        } else if (p.online === "Idle") {
            return "🟡"
        } else {
            return "⚫"
        }
    }

    function render() {
        const list = document.getElementById("list");
        const now = Math.floor(Date.now() / 1000);

        const arr = Array.from(tracked.values());

        arr.sort((a, b) => a.until - b.until);

        let html = "";

        const ok = arr.filter(p => p.until <= now);
        const hosp = arr.filter(p => p.until > now);

        html += `
            <div style="
                color:#9aa4b2;
                font-size:11px;
                font-weight:700;
                text-transform:uppercase;
                letter-spacing:.5px;
                margin-bottom:8px;
            ">
                ✅ Available (${ok.length})
            </div>
            `;

        if (!ok.length) {
            html += `<div>None</div>`;
        } else {
            ok.forEach(p => {
                html += `<div style="background:#252932; border:1px solid #343a46; border-radius:8px; padding:8px; margin-bottom:6px;">${emoji(p)} <b><a href="https://www.torn.com/profiles.php?XID=${p.id}">${p.name}</a></b> - <span style="color:#55ff55">Okay</span><br>ㅤ</div>`;
            });
        }

        // html += `<hr style="border:1px solid #444;">`;
        html += `
            <div style="
                color:#9aa4b2;
                font-size:11px;
                font-weight:700;
                text-transform:uppercase;
                letter-spacing:.5px;
                margin:12px 0 8px;
            ">
                🏥 Hospital (${hosp.length})
            </div>
            `;

        if (!hosp.length) {
            html += `<div>None</div>`;
        } else {
            hosp.forEach(p => {
                const remaining = p.until - now;
                let timeColor;

                if (remaining <=61) {
                    timeColor = "#4da6ff"; // blue
                } else if (remaining <= 301) {
                    timeColor = "#efbb2b"; // yellow
                } else if (remaining <= 901) {
                    timeColor = "#ff9900"; // orange
                } else {
                    timeColor = "#ff5555"; // red
                }

                html += `
                    <div style="background:#252932; border:1px solid #343a46; border-radius:8px; padding:8px; margin-bottom:6px;">
                        ${emoji(p)} <b><a href="https://www.torn.com/profiles.php?XID=${p.id}">${p.name}</a></b> - <span style="color:#adadad">${p.details}</span><br>
                        <span style="color:${timeColor}">
                        🕒 ${formatTime(remaining-1)}
                        </span>
                    </div>
                `;
            });
        }

        list.innerHTML = html;
    }

    function start() {
        const fid = document.getElementById("factionId").value;
        const key = document.getElementById("apiKey").value;

        if (!fid || !key) {
            alert("Enter faction ID and API key");
            return;
        }

        stop();
        saveSettings();

        refreshData();

        pollInterval = setInterval(refreshData, 30000);
        renderInterval = setInterval(render, 1000);

        saveState(true);

        document.getElementById("status").innerText = "Running...";
    }

    function stop() {
        clearInterval(pollInterval);
        clearInterval(renderInterval);

        pollInterval = null;
        renderInterval = null;

        saveState(false);

        document.getElementById("status").innerText = "Stopped";
    }

})();