Milkyway Idle - Current Loot Tracker

Tracks loot with overlay, total coin value via ask prices, improved UI/CSS, and fixed display logic.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Milkyway Idle - Current Loot Tracker
// @namespace    https://milkywayidle.com/
// @version      2.1
// @description  Tracks loot with overlay, total coin value via ask prices, improved UI/CSS, and fixed display logic.
// @match        https://www.milkywayidle.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const playerLootData = {};
  const previousLootCounts = {};
  const lastBattleLoot = {};
  let myPlayerName = null;
  let activePlayer = null;
  let selfTabSelected = false;
  let isMinimized = localStorage.getItem("lootOverlayMinimized") === "true";
  let isLootListMinimized =
    localStorage.getItem("lootListMinimized") === "true";
  let overlayReady = false;

  let marketData = {};

  fetch(
    "https://raw.githubusercontent.com/holychikenz/MWIApi/main/medianmarket.json"
  )
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    })
    .then((data) => {
      marketData = data;
      console.log("[LootTracker] Market data loaded successfully.");
      if (activePlayer && document.getElementById("lootOverlay")) {
        updateLootDisplay(activePlayer);
      }
    })
    .catch((err) =>
      console.error("[LootTracker] Failed to load market data:", err)
    );

  function formatGold(value) {
    const numValue = Number(value) || 0;
    return Math.round(numValue).toLocaleString() + " coin";
  }

  function detectPlayerName() {
    const nameDiv =
      document.querySelector(".CharacterStatus_playerName__XXXXX") ||
      document.querySelector(".CharacterName_name__1amXp[data-name]");

    if (nameDiv) {
      myPlayerName = nameDiv.dataset.name || nameDiv.textContent.trim();
      if (
        overlayReady &&
        myPlayerName &&
        playerLootData[myPlayerName] &&
        !selfTabSelected
      ) {
        selfTabSelected = true;
        switchTab(myPlayerName);
      }
    } else {
      setTimeout(detectPlayerName, 1000);
    }
  }

  function createOverlay() {
    if (overlayReady || document.getElementById("lootOverlay")) return;
    overlayReady = true;

    const panel = document.createElement("div");
    panel.id = "lootOverlay";
    panel.style.top = localStorage.getItem("lootOverlayTop") || "100px";
    panel.style.left = localStorage.getItem("lootOverlayLeft") || "20px";

    panel.innerHTML = `
        <div id="lootHeader">
          <span id="lootTitle">📦 Current Loot</span>
          <div id="lootHeaderButtons">
            <button id="lootExportBtn" class="loot-btn" data-tooltip="Export current player's loot as CSV">CSV</button>
            <button id="lootClearBtn" class="loot-btn" data-tooltip="Clear ALL tracked loot">⟳</button>
            <button id="lootMinBtn" class="loot-btn" data-tooltip="Minimize/Restore Overlay">
              ${isMinimized ? "+" : "−"}
            </button>
          </div>
        </div>
        <div id="lootContent">
          <div id="lootTabs"></div>
          <div id="lootToggleHeader">
              Loot <span id="lootToggleIcon">${
                isLootListMinimized ? "▲" : "▼"
              }</span>
          </div>
          <div id="lootTotals"></div>
          <div id="lootBottomDragger">
            <div id="lootRevenueLine">Total Value: Calculating...</div>
            <div class="drag-spacer"></div>
          </div>
        </div>
      `;

    document.body.appendChild(panel);

    const style = document.createElement("style");
    style.textContent = `
        #lootOverlay {
          position: fixed;
          width: 260px;
          background: rgba(30, 30, 30, 0.95);
          color: #fff;
          font-family: monospace;
          font-size: 13px;
          border: 1px solid #555;
          border-radius: 8px;
          z-index: 99999;
          user-select: none;
          box-shadow: 0 4px 10px rgba(0,0,0,0.4);
        }
        #lootHeader {
          display: flex; justify-content: space-between; align-items: center;
          padding: 6px 10px; background: rgba(20, 20, 20, 0.85);
          border-bottom: 1px solid #333; border-radius: 8px 8px 0 0; cursor: move;
        }
        #lootTitle { font-weight: bold; }
        #lootHeaderButtons { display: flex; gap: 4px; }
        .loot-btn {
          background: none; border: none; color: #aaa; cursor: pointer;
          font-size: 14px; padding: 0 3px; position: relative;
        }
        .loot-btn:hover { color: #fff; }
        .loot-btn:hover::after {
          content: attr(data-tooltip); position: absolute; left: 50%; top: 110%;
          transform: translateX(-50%); background: #222; color: #fff; padding: 4px 8px;
          font-size: 11px; border-radius: 4px; white-space: nowrap; opacity: 0.95;
          pointer-events: none; z-index: 100000;
        }
        #lootContent {
          overflow: hidden; transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
          will-change: max-height, opacity;
        }
        #lootTabs {
          display: flex; flex-wrap: wrap; padding: 5px 10px; gap: 6px;
          border-bottom: 1px solid #333; background: rgba(24, 24, 24, 0.8); min-height: 26px;
        }
        #lootTabs button {
          background: none; border: 1px solid #444; color: #aaa; padding: 2px 6px;
          font-family: monospace; cursor: pointer; border-radius: 4px; font-size: 12px;
          transition: background-color 0.2s, color 0.2s, border-color 0.2s;
        }
        #lootTabs button:hover { background-color: #555; color: #fff; }
        #lootTabs button.active {
          background: #4caf50; color: #fff; border-color: #4caf50; font-weight: bold;
        }
        #lootToggleHeader {
          padding: 6px 10px; cursor: pointer; font-weight: bold; border-bottom: 1px solid #333;
          background: rgba(28, 28, 28, 0.8);
        }
        #lootToggleHeader:hover { background: rgba(40, 40, 40, 0.9); }
        #lootToggleIcon { display: inline-block; transition: transform 0.2s ease-out; margin-left: 5px; }
        #lootTotals {
          padding: 10px; overflow-y: auto; max-height: 400px;
          transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out;
          will-change: max-height, opacity, padding;
        }
         #lootTotals > div { margin-bottom: 3px; line-height: 1.3; }
        #lootBottomDragger {
          padding: 6px 10px; cursor: move; border-top: 1px solid #444;
          background: rgba(20, 20, 20, 0.85); border-radius: 0 0 8px 8px;
        }
        #lootRevenueLine {
          font-weight: bold; color: gold; cursor: inherit; padding-bottom: 4px;
          white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .drag-spacer { height: 8px; cursor: inherit; }
        @keyframes lootFlashText { 0% { color: #b6ffb8; transform: scale(1.02); } 100% { color: white; transform: scale(1); } }
        .flashLoot { animation: lootFlashText 1s ease-out; }
        .fadeGain {
          color: lime; font-weight: bold; font-size: 10px; vertical-align: super;
          opacity: 1; transition: opacity 2s ease-out; margin-left: 3px; display: inline-block;
        }
      `;
    document.head.appendChild(style);

    const content = document.getElementById("lootContent");
    const lootTotals = document.getElementById("lootTotals");
    content.style.maxHeight = isMinimized ? "0" : "1000px";
    content.style.opacity = isMinimized ? "0" : "1";
    lootTotals.style.maxHeight = isLootListMinimized ? "0" : "400px";
    lootTotals.style.opacity = isLootListMinimized ? "0" : "1";
    lootTotals.style.padding = isLootListMinimized ? "0 10px" : "10px";

    document.getElementById("lootMinBtn").onclick = () => {
      isMinimized = !isMinimized;
      content.style.maxHeight = isMinimized ? "0" : "1000px";
      content.style.opacity = isMinimized ? "0" : "1";
      document.getElementById("lootMinBtn").textContent = isMinimized
        ? "+"
        : "−";
      localStorage.setItem("lootOverlayMinimized", isMinimized);
    };
    document.getElementById("lootToggleHeader").onclick = () => {
      isLootListMinimized = !isLootListMinimized;
      lootTotals.style.maxHeight = isLootListMinimized ? "0" : "400px";
      lootTotals.style.opacity = isLootListMinimized ? "0" : "1";
      lootTotals.style.padding = isLootListMinimized ? "0 10px" : "10px";
      document.getElementById("lootToggleIcon").textContent =
        isLootListMinimized ? "▲" : "▼";
      localStorage.setItem("lootListMinimized", isLootListMinimized);
    };
    const exportBtn = document.getElementById("lootExportBtn");
    exportBtn.onclick = () => {
      if (
        !activePlayer ||
        !playerLootData[activePlayer] ||
        Object.keys(playerLootData[activePlayer]).length === 0
      ) {
        alert("No loot data available for the active player to export.");
        return;
      }
      try {
        const dataToExport = playerLootData[activePlayer];
        const csvContent = Object.entries(dataToExport)
          .map(([hrid, count]) => {
            let itemName = hrid.replace("/items/", "").replace(/_/g, " ");
            itemName = `"${itemName.replace(/"/g, '""')}"`;
            return `${itemName},${count}`;
          })
          .join("\n");
        const csvOutput = "Item Name,Count\n" + csvContent;
        navigator.clipboard
          .writeText(csvOutput)
          .then(() => {
            const originalText = exportBtn.textContent;
            exportBtn.textContent = "Copied!";
            exportBtn.style.color = "#4caf50";
            setTimeout(() => {
              exportBtn.textContent = originalText;
              exportBtn.style.color = "";
            }, 1500);
          })
          .catch((err) => {
            console.error(
              "[LootTracker] Failed to copy CSV to clipboard:",
              err
            );
            alert("Failed to copy CSV. See console.");
          });
      } catch (error) {
        console.error("[LootTracker] Error generating CSV:", error);
        alert("Error generating CSV data.");
      }
    };
    document.getElementById("lootClearBtn").onclick = () => {
      if (
        confirm(
          "Are you sure you want to clear ALL tracked loot data? This cannot be undone."
        )
      ) {
        clearAllLootData();
      }
    };

    let dragging = false;
    let offsetX = 0;
    let offsetY = 0;
    function beginDrag(e) {
      if (e.target.closest("button")) return;
      dragging = true;
      panel.style.transition = "none";
      offsetX = e.clientX - panel.offsetLeft;
      offsetY = e.clientY - panel.offsetTop;
      document.body.style.userSelect = "none";
      document.body.style.cursor = "move";
    }
    document
      .getElementById("lootHeader")
      .addEventListener("mousedown", beginDrag);
    document
      .getElementById("lootBottomDragger")
      .addEventListener("mousedown", beginDrag);
    document.addEventListener("mousemove", (e) => {
      if (!dragging) return;
      const newX = Math.max(
        0,
        Math.min(window.innerWidth - panel.offsetWidth, e.clientX - offsetX)
      );
      const newY = Math.max(
        0,
        Math.min(window.innerHeight - panel.offsetHeight, e.clientY - offsetY)
      );
      panel.style.left = `${newX}px`;
      panel.style.top = `${newY}px`;
    });
    document.addEventListener("mouseup", () => {
      if (!dragging) return;
      dragging = false;
      panel.style.transition = "";
      document.body.style.userSelect = "";
      document.body.style.cursor = "";
      localStorage.setItem("lootOverlayTop", panel.style.top);
      localStorage.setItem("lootOverlayLeft", panel.style.left);
    });

    console.log("[LootTracker] Overlay created.");
  }

  function updateLootDisplay(playerName) {
    const container = document.getElementById("lootTotals");
    const revenueLine = document.getElementById("lootRevenueLine");

    if (!container) {
      console.error(
        "[LootTracker] updateLootDisplay: Could not find #lootTotals element!"
      );
      if (revenueLine) revenueLine.textContent = "Total Value: Error (UI)";
      return;
    }
    if (!revenueLine) {
      console.warn(
        "[LootTracker] updateLootDisplay: Could not find #lootRevenueLine element."
      );
    }

    if (!playerLootData[playerName]) {
      container.innerHTML = "<i>Waiting for player data...</i>";
      if (revenueLine) revenueLine.textContent = "Total Value: N/A";
      return;
    }
    if (!previousLootCounts[playerName]) previousLootCounts[playerName] = {};

    const currentLoot = playerLootData[playerName];
    const sorted = Object.entries(currentLoot).sort(
      (a, b) => b[1] - a[1] || a[0].localeCompare(b[0])
    );

    let html = "";
    let totalRevenue = 0;
    let marketDataAvailable =
      marketData &&
      marketData.market &&
      Object.keys(marketData.market).length > 0;

    if (sorted.length === 0) {
      html = "<i>No loot tracked yet.</i>";
      totalRevenue = 0;
    } else {
      sorted.forEach(([itemHrid, count]) => {
        const prevDisplayCount = previousLootCounts[playerName][itemHrid] || 0;
        const lastBattleStartCount =
          lastBattleLoot[playerName] && lastBattleLoot[playerName][itemHrid]
            ? lastBattleLoot[playerName][itemHrid]
            : prevDisplayCount;
        const gain = count - lastBattleStartCount;
        const flash = count > prevDisplayCount;
        const name = itemHrid.replace("/items/", "").replace(/_/g, " ");
        const gainHTML =
          gain > 0 ? `<span class="fadeGain">+${gain}</span>` : "";

        let itemValue = 0;
        let priceFound = false;
        if (itemHrid.endsWith("/coin")) {
          itemValue = count;
          priceFound = true;
        } else if (marketDataAvailable) {
          const marketKey = name
            .split(" ")
            .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
            .join(" ");
          if (marketData.market[marketKey]?.ask) {
            itemValue = count * marketData.market[marketKey].ask;
            priceFound = true;
          }
        }
        totalRevenue += itemValue;

        html += `<div class="${flash ? "flashLoot" : ""}">
                • ${name} × ${count}${gainHTML} ${
          !priceFound && !itemHrid.endsWith("/coin")
            ? '<span style="color:gray;" title="Price data unavailable">?</span>'
            : ""
        }
              </div>`;
        previousLootCounts[playerName][itemHrid] = count;
      });
    }

    const hasNonCoinItems = sorted.some(([hrid]) => !hrid.endsWith("/coin"));
    let finalRevenueText = "";
    if (!marketDataAvailable && hasNonCoinItems && sorted.length > 0) {
      finalRevenueText = `Total Value: Calculating...`;
    } else if (sorted.length === 0) {
      finalRevenueText = `Total Value: ${formatGold(0)}`;
    } else {
      finalRevenueText = `Total Value: ${formatGold(totalRevenue)}`;
    }

    try {
      container.innerHTML = html;

      if (revenueLine) {
        revenueLine.textContent = finalRevenueText;
      }
    } catch (uiError) {
      console.error(
        `[LootTracker] CRITICAL: Error occurred during DOM update!`,
        uiError
      );
    }

    document.querySelectorAll(".fadeGain").forEach((el) => {
      setTimeout(() => {
        void el.offsetWidth;
        el.style.opacity = "0";
      }, 100);
    });
    if (
      lastBattleLoot[playerName] &&
      Object.keys(lastBattleLoot[playerName]).length > 0
    ) {
      lastBattleLoot[playerName] = {};
    }
  }

  function switchTab(playerName) {
    activePlayer = playerName;

    document.querySelectorAll("#lootTabs button").forEach((btn) => {
      btn.classList.toggle("active", btn.dataset.name === playerName);
    });
    updateLootDisplay(playerName);
  }

  function addTab(player) {
    const playerName = player.name;
    const lootMap = player.totalLootMap || {};
    const container = document.getElementById("lootTabs");
    if (!container) {
      console.error("[LootTracker] Loot tabs container not found!");
      return;
    }
    if (!playerLootData[playerName]) playerLootData[playerName] = {};
    if (!previousLootCounts[playerName]) previousLootCounts[playerName] = {};
    if (!lastBattleLoot[playerName]) lastBattleLoot[playerName] = {};

    let tabNeedsUpdate = false;
    for (const key in lootMap) {
      const { itemHrid, count } = lootMap[key];
      if (playerLootData[playerName][itemHrid] !== count) {
        lastBattleLoot[playerName][itemHrid] =
          playerLootData[playerName][itemHrid] || 0;
        playerLootData[playerName][itemHrid] = count;
        tabNeedsUpdate = true;
      }
    }

    let tabButton = container.querySelector(
      `button[data-name="${playerName}"]`
    );
    if (!tabButton) {
      tabButton = document.createElement("button");
      tabButton.textContent = playerName;
      tabButton.dataset.name = playerName;
      tabButton.onclick = () => switchTab(playerName);
      container.appendChild(tabButton);

      if (!activePlayer) activePlayer = playerName;
    }

    if (playerName === myPlayerName && !selfTabSelected) {
      selfTabSelected = true;
      switchTab(playerName);
      tabNeedsUpdate = false;
    } else if (playerName === activePlayer && tabNeedsUpdate) {
      updateLootDisplay(playerName);
    }
    if (playerName === activePlayer) {
      document.querySelectorAll("#lootTabs button").forEach((btn) => {
        btn.classList.toggle("active", btn.dataset.name === activePlayer);
      });
    }
  }

  function clearAllLootData() {
    console.log("[LootTracker] Clearing all loot data.");

    for (const p in playerLootData) {
      playerLootData[p] = {};
      previousLootCounts[p] = {};
      lastBattleLoot[p] = {};
    }

    const tabsContainer = document.getElementById("lootTabs");
    const totalsContainer = document.getElementById("lootTotals");
    const revenueLine = document.getElementById("lootRevenueLine");

    if (tabsContainer) tabsContainer.innerHTML = "";
    if (totalsContainer)
      totalsContainer.innerHTML = "<i>Loot data cleared.</i>";
    if (revenueLine) revenueLine.textContent = "Total Value: N/A";

    activePlayer = null;
    selfTabSelected = false;
  }

  (function injectWebSocketInterceptor() {
    const scriptId = "milkyway-websocket-interceptor";

    if (document.getElementById(scriptId)) return;

    const s = document.createElement("script");
    s.id = scriptId;
    s.textContent = `
          (function() {

            if (window.originalWebSocket) { return; }
            window.originalWebSocket = window.WebSocket;


            window.WebSocket = new Proxy(window.originalWebSocket, {
              construct(target, args) {

                const wsInstance = new target(...args);
                try {
                    const url = args[0];

                    if (typeof url === 'string' && (url.includes("api.milkywayidle.com/ws") || url.includes("api-test.milkywayidle.com/ws"))) {


                        wsInstance.addEventListener("message", (event) => {
                          try {
                            const data = JSON.parse(event.data);

                            if (data.type === "new_battle" && data.players) {

                              window.dispatchEvent(new CustomEvent("LootTrackerBattle", { detail: data }));
                            }

                            else if ( data.type === "new_character_action" && data.newCharacterActionData?.shouldClearQueue && data.newCharacterActionData.actionHrid?.startsWith("/actions/combat/") ) {

                              window.dispatchEvent(new CustomEvent("LootTrackerCombatReset"));
                            }
                          } catch (parseOrDispatchError) {
                              console.error('[LootTracker WS Interceptor] Error processing message:', parseOrDispatchError, 'Raw Data:', event.data);
                          }
                        });


                        wsInstance.addEventListener("open", () => {

                        });


                        wsInstance.addEventListener("close", (event) => {

                            console.log(\`[LootTracker WS Interceptor] Target WebSocket connection closed. Code: \${event.code}, Reason: \${event.reason}. Dispatching LootTrackerWSClosed event.\`);

                            window.dispatchEvent(new CustomEvent("LootTrackerWSClosed", {
                                detail: { code: event.code, reason: event.reason }
                            }));
                        });


                        wsInstance.addEventListener("error", (event) => {
                            console.error('[LootTracker WS Interceptor] Target WebSocket error:', event);
                        });

                    }
                } catch (proxyConstructError) {
                    console.error('[LootTracker WS Interceptor] Error setting up WebSocket proxy:', proxyConstructError);
                }

                return wsInstance;
              }
            });

            console.log('[LootTracker WS Interceptor] WebSocket Proxy installed.');
          })();
        `;

    (document.head || document.documentElement).appendChild(s);
  })();

  window.addEventListener("LootTrackerBattle", (e) => {
    if (!overlayReady) {
      console.warn(
        "[LootTracker] Overlay not ready when battle event received, skipping update."
      );
      return;
    }
    const data = e.detail;

    if (data && data.players && Array.isArray(data.players)) {
      data.players.forEach((player) => {
        if (player && player.name) {
          addTab(player);
        } else {
          console.warn(
            "[LootTracker] Player data missing name in battle event:",
            player
          );
        }
      });
    } else {
      console.warn(
        "[LootTracker] Invalid data received in LootTrackerBattle event:",
        data
      );
    }
  });

  window.addEventListener("LootTrackerWSClosed", (e) => {
    console.log(
      `[LootTracker] Detected WebSocket closure (Code: ${e.detail?.code}, Reason: ${e.detail?.reason}). Clearing all loot data and resetting player name.`
    );

    myPlayerName = null;

    activePlayer = null;
    selfTabSelected = false;

    if (overlayReady) {
      clearAllLootData();
    } else {
      console.warn(
        "[LootTracker] WebSocket closed, but overlay not ready. Data should be clear on next init."
      );
    }
  });

  window.addEventListener("LootTrackerCombatReset", (e) => {
    if (!overlayReady) {
      console.warn(
        "[LootTracker] Overlay not ready when reset event received, skipping clear."
      );
      return;
    }
    console.log("[LootTracker] Calling clearAllLootData due to combat reset.");
    clearAllLootData();
  });

  function initialize() {
    console.log("[LootTracker] Initializing...");
    createOverlay();
    detectPlayerName();
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initialize);
  } else {
    initialize();
  }
})();