BGA Flip Seven Card Counter

Card counter for Flip Seven on BoardGameArena

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         BGA Flip Seven Card Counter
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Card counter for Flip Seven on BoardGameArena
// @author       KuRRe8, fpronto
// @match        https://boardgamearena.com/*/flipseven?table=*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  "use strict";

  function isInGameUrl(url) {
    return /https:\/\/boardgamearena\.com\/\d+\/flipseven\?table=\d+/.test(url);
  }

  // Card counting data initialization
  function getInitialCardDict() {
    return {
      "12card": 12,
      "11card": 11,
      "10card": 10,
      "9card": 9,
      "8card": 8,
      "7card": 7,
      "6card": 6,
      "5card": 5,
      "4card": 4,
      "3card": 3,
      "2card": 2,
      "1card": 1,
      "0card": 1,
      flip3: 3,
      "Second chance": 3,
      Freeze: 3,
      Plus2: 1,
      Plus4: 1,
      Plus6: 1,
      Plus8: 1,
      Plus10: 1,
      double: 1,
    };
  }

  let cardDict = null;
  let roundCardDict = null; // Current round card counting data
  let playerBoardDict = null; // All players' board cards, array, each element is a player's card object
  let busted_players = {};
  let stayedPlayers = {};
  let frozenPlayers = {};

  function getInitialPlayerBoardDict() {
    // Same structure as cardDict, all values initialized to 0
    return Object.fromEntries(
      Object.keys(getInitialCardDict()).map((k) => [k, 0])
    );
  }

  function clearPlayerBoardDict(idx) {
    // idx: optional, specify player index, if not provided, clear all
    if (Array.isArray(playerBoardDict)) {
      if (typeof idx === "number") {
        Object.keys(playerBoardDict[idx]).forEach(
          (k) => (playerBoardDict[idx][k] = 0)
        );
        // console.log(
        //   `[Flip Seven Counter] Player ${idx + 1} board cleared`,
        //   playerBoardDict[idx]
        // );
      } else {
        playerBoardDict.forEach((dict, i) => {
          Object.keys(dict).forEach((k) => (dict[k] = 0));
        });
        console.log(
          "[Flip Seven Counter] All players board cleared",
          playerBoardDict
        );
      }
    }
  }

  function clearRoundCardDict() {
    if (roundCardDict) {
      Object.keys(roundCardDict).forEach((k) => (roundCardDict[k] = 0));
      console.log(
        "[Flip Seven Counter] Round card data cleared",
        roundCardDict
      );
    }
  }

  function resetBustedPlayers() {
    const playerNames = window.flipsevenPlayerNames || [];
    busted_players = {};
    stayedPlayers = {};
    frozenPlayers = {};
    playerNames.forEach((name) => {
      busted_players[name] = false;
      stayedPlayers[name] = false;
      frozenPlayers[name] = false;
    });
  }

  function createCardCounterPanel() {
    // Create floating panel
    let panel = document.createElement("div");
    panel.id = "flipseven-card-counter-panel";
    panel.style.position = "fixed";
    panel.style.top = "80px";
    panel.style.right = "20px";
    panel.style.zIndex = "99999";
    panel.style.background = "rgba(173, 216, 230, 0.85)"; // light blue semi-transparent
    panel.style.border = "1px solid #5bb";
    panel.style.borderRadius = "8px";
    panel.style.boxShadow = "0 2px 8px rgba(0,0,0,0.15)";
    panel.style.padding = "12px 16px";
    panel.style.fontSize = "15px";
    panel.style.color = "#222";
    panel.style.maxHeight = "80vh";
    panel.style.overflowY = "auto";
    panel.style.minWidth = "180px";
    panel.style.userSelect = "text";
    panel.style.cursor = "move"; // draggable cursor
    panel.innerHTML =
      '<b>Flip Seven Counter</b><hr style="margin:6px 0;">' +
      renderCardDictTable(cardDict) +
      '<div style="height:18px;"></div>' +
      '<div style="font-size: 1.5em; font-weight: bold; text-align:left;">rate <span style="float:right;">100%</span></div>';
    document.body.appendChild(panel);
    makePanelDraggable(panel);
  }

  function getSafeRate() {
    // Calculate the probability of safe cards (not in any player's hand)
    let safe = 0,
      total = 0;
    for (const k in cardDict) {
      if (!playerBoardDict || playerBoardDict.every((dict) => dict[k] === 0)) {
        safe += cardDict[k];
      }
      total += cardDict[k];
    }
    if (total === 0) return 100;
    return Math.round((safe / total) * 100);
  }

  function getPlayerSafeRate(idx) {
    // Calculate the safe card probability for a specific player
    let safe = 0,
      total = 0;
    if (!playerBoardDict || !playerBoardDict[idx]) return 100;
    for (const k in cardDict) {
      if (playerBoardDict[idx][k] === 0) {
        safe += cardDict[k];
      }
      total += cardDict[k];
    }
    if (total === 0) return 100;
    return Math.round((safe / total) * 100);
  }

  function updateCardCounterPanel(flashKey) {
    const panel = document.getElementById("flipseven-card-counter-panel");
    if (panel) {
      const playerNames = window.flipsevenPlayerNames || [];
      let namesHtml = playerNames
        .map((n, idx) => {
          let shortName = n.length > 6 ? n.slice(0, 6) : n;
          if (busted_players[n]) {
            return `<div style="margin-bottom:2px;"><span style="display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;">${shortName}</span> <span style='color:#888;font-size:0.95em;'>Busted</span></div>`;
          } else if (frozenPlayers[n]) {
            return `<div style="margin-bottom:2px;"><span style="display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;">${shortName}</span> <span style='color:#888;font-size:0.95em;'>Frozen</span></div>`;
          } else if (stayedPlayers[n]) {
            return `<div style="margin-bottom:2px;"><span style="display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;">${shortName}</span> <span style='color:#888;font-size:0.95em;'>Stayed</span></div>`;
          } else {
            let rate = getPlayerSafeRate(idx);
            let rateColor;
            if (rate < 30) rateColor = "#b94a48";
            else if (rate < 50) rateColor = "#bfae3b";
            else rateColor = "#4a7b5b";
            return `<div style="margin-bottom:2px;"><span style="display:inline-block;max-width:6em;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;">${shortName}</span> <span style='color:${rateColor};font-size:0.95em;'>${rate}%</span></div>`;
          }
        })
        .join("");
      panel.innerHTML =
        '<b>Flip Seven Counter</b><hr style="margin:6px 0;">' +
        renderCardDictTable(cardDict) +
        '<div style="height:18px;"></div>' +
        `<div style="font-size: 1.2em; font-weight: bold; text-align:left;">${namesHtml}</div>`;
      if (flashKey) flashNumberCell(flashKey);
    }
  }

  // Draggable panel functionality
  function makePanelDraggable(panel) {
    let isDragging = false;
    let offsetX = 0,
      offsetY = 0;
    panel.addEventListener("mousedown", function (e) {
      isDragging = true;
      offsetX = e.clientX - panel.getBoundingClientRect().left;
      offsetY = e.clientY - panel.getBoundingClientRect().top;
      document.body.style.userSelect = "none";
    });
    document.addEventListener("mousemove", function (e) {
      if (isDragging) {
        panel.style.left = e.clientX - offsetX + "px";
        panel.style.top = e.clientY - offsetY + "px";
        panel.style.right = "";
      }
    });
    document.addEventListener("mouseup", function () {
      isDragging = false;
      document.body.style.userSelect = "";
    });
  }

  function renderCardDictTable(dict) {
    let html = '<table style="border-collapse:collapse;width:100%;">';
    const totalLeft = Object.values(dict).reduce((a, b) => a + b, 0) || 1;
    for (const [k, v] of Object.entries(dict)) {
      const percent = Math.round((v / totalLeft) * 100);

      const percentColor = "#888";

      let numColor = "#888";
      if (v === 1 || v === 2) numColor = "#2ecc40";
      else if (v >= 3 && v <= 5) numColor = "#ffdc00";
      else if (v > 5) numColor = "#ff4136";
      html += `<tr><td style='padding:2px 6px;'>${k}</td><td class='flipseven-anim-num' data-key='${k}' style='padding:2px 6px;text-align:right;color:${numColor};font-weight:bold;'>${v} <span style='font-size:0.9em;color:${percentColor};'>(${percent}%)</span></td></tr>`;
    }
    html += "</table>";
    return html;
  }

  function flashNumberCell(key) {
    const cell = document.querySelector(
      `#flipseven-card-counter-panel .flipseven-anim-num[data-key='${key}']`
    );
    if (cell) {
      cell.style.transition = "background 0.2s";
      cell.style.background = "#fff7b2";
      setTimeout(() => {
        cell.style.background = "";
      }, 200);
    }
  }

  function updatePlayerBoardDictFromDOM() {
    // Get player count
    const playerNames = window.flipsevenPlayerNames || [];
    const playerCount = playerNames.length;
    // Process each player
    for (let i = 0; i < playerCount; i++) {
      const container = document.querySelector(
        `#app > div > div > div.f7_scalable.f7_scalable_zoom > div > div.f7_players_container.grid > div:nth-child(${
          i + 1
        }) > div:nth-child(3)`
      );
      if (!container) {
        console.warn(
          `[Flip Seven Counter] Player ${i + 1} board container not found`
        );
        continue;
      }
      // Clear this player's stats
      clearPlayerBoardDict(i);
      // Count all cards
      const cardDivs = container.querySelectorAll(".flippable-front");
      cardDivs.forEach((frontDiv) => {
        // class like 'flippable-front sprite sprite-c8', get the number
        const classList = frontDiv.className.split(" ");
        const spriteClass = classList.find((cls) => cls.startsWith("sprite-c"));
        if (spriteClass) {
          const num = spriteClass.replace("sprite-c", "");
          if (/^\d+$/.test(num)) {
            const key = num + "card";
            if (playerBoardDict[i].hasOwnProperty(key)) {
              playerBoardDict[i][key] += 1;
            }
          }
        }
      });
      // console.log(`[Flip Seven Counter] Player ${i+1} board:`, JSON.parse(JSON.stringify(playerBoardDict[i])));
    }
  }

  // Periodic event: check every 300ms
  function startPlayerBoardMonitor() {
    setInterval(updatePlayerBoardDictFromDOM, 300);
  }

  // Log monitor
  let lc = 0; // log counter
  function startLogMonitor() {
    setInterval(() => {
      const logElem = document.getElementById("log_" + lc);
      if (!logElem) return; // No such log, wait for next
      // Check for new round
      const firstDiv = logElem.querySelector("div");
      console.log(
        `[Flip Seven Counter] firstDiv.innerText = ${firstDiv.innerText.trim()} `
      );
      if (
        firstDiv?.innerText &&
        (firstDiv.innerText.trim().includes("新的一轮") ||
          /new round/gi.exec(firstDiv.innerText))
      ) {
        clearRoundCardDict();
        resetBustedPlayers();
        updateCardCounterPanel();
        lc++;
        return;
      }
      if (
        firstDiv?.innerText &&
        (firstDiv.innerText.includes("弃牌堆洗牌") ||
          /shuffle/gi.exec(firstDiv.innerText))
      ) {
        cardDict = getInitialCardDict();
        for (const k in roundCardDict) {
          if (cardDict.hasOwnProperty(k)) {
            cardDict[k] = Math.max(0, cardDict[k] - roundCardDict[k]);
          }
        }
        updateCardCounterPanel();
        lc++;
        return;
      }
      if (
        firstDiv?.innerText &&
        (firstDiv.innerText.includes("爆牌") ||
          /bust/gi.exec(firstDiv.innerText))
      ) {
        // 查找 span.playername
        const nameSpan = firstDiv.querySelector("span.playername");
        if (nameSpan) {
          const bustedName = nameSpan.innerText.trim();
          if (busted_players.hasOwnProperty(bustedName)) {
            busted_players[bustedName] = true;
            updateCardCounterPanel();
          }
        }
      }
      if (firstDiv?.innerText && /stay/gi.exec(firstDiv.innerText)) {
        const nameSpan = firstDiv.querySelector("span.playername");
        if (nameSpan) {
          const stayedName = nameSpan.innerText.trim();
          if (stayedPlayers.hasOwnProperty(stayedName)) {
            stayedPlayers[stayedName] = true;
            updateCardCounterPanel();
          }
        }
      }
      if (firstDiv?.innerText && /freezes/gi.exec(firstDiv.innerText)) {
        const nameSpan = firstDiv.querySelector("span.playername");
        if (nameSpan) {
          const frozenName = nameSpan.innerText.trim();
          if (frozenPlayers.hasOwnProperty(frozenName)) {
            frozenPlayers[frozenName] = true;
            updateCardCounterPanel();
          }
        }
      }
      if (
        firstDiv?.innerText &&
        ((firstDiv.innerText.includes("第二次机会") &&
          firstDiv.innerText.includes("卡牌被弃除")) ||
          /second chance/gi.exec(firstDiv.innerText))
      ) {
        if (cardDict["Second chance"] > 0) {
          cardDict["Second chance"]--;
          console.log(
            '[Flip Seven Counter] "第二次机会"卡牌被弃除,cardDict[Second chance]--,当前剩余:',
            cardDict["Second chance"]
          );
          updateCardCounterPanel("Second chance");
        }
      }
      // Check for card type
      const cardElem = logElem.querySelector(
        ".visible_flippable.f7_token_card.f7_logs"
      );
      if (!cardElem) {
        lc++;
        return; // No card, skip
      }
      // Find the only child div's only child div
      let frontDiv = cardElem;
      frontDiv = frontDiv.children[0];
      frontDiv = frontDiv.children[0];
      if (!frontDiv?.className) {
        lc++;
        return;
      }
      // Parse className
      const classList = frontDiv.className.split(" ");
      const spriteClass = classList.find((cls) => cls.startsWith("sprite-"));
      if (!spriteClass) {
        lc++;
        return;
      }
      // Handle number cards
      let key = null;
      if (/^sprite-c(\d+)$/.test(spriteClass)) {
        const num = spriteClass.match(/^sprite-c(\d+)$/)[1];
        key = num + "card";
      } else if (/^sprite-s(\d+)$/.test(spriteClass)) {
        // Plus2/4/6/8/10
        const num = spriteClass.match(/^sprite-s(\d+)$/)[1];
        key = "Plus" + num;
      } else if (spriteClass === "sprite-sf") {
        key = "Freeze";
      } else if (spriteClass === "sprite-sch") {
        key = "Second chance";
      } else if (spriteClass === "sprite-sf3") {
        key = "flip3";
      } else if (spriteClass === "sprite-sx2") {
        key = "double";
      }
      if (
        key &&
        cardDict.hasOwnProperty(key) &&
        roundCardDict.hasOwnProperty(key)
      ) {
        if (cardDict[key] > 0) cardDict[key]--;
        roundCardDict[key]++;
        console.log(
          `[Flip Seven Counter] log_${lc} found ${key}, global left ${cardDict[key]}, round used ${roundCardDict[key]}`
        );
        updateCardCounterPanel(key);
      } else {
        console.log("spriteClass -> ", spriteClass);
        console.log(
          `[Flip Seven Counter] log_${lc} unknown card type`,
          spriteClass
        );
      }
      lc++;
      if (lc) {
        console.log(`[Flip Seven Counter] ${lc} unknown card type`);
      }
    }, 200);
  }

  function initializeGame() {
    cardDict = getInitialCardDict();
    roundCardDict = Object.fromEntries(
      Object.keys(cardDict).map((k) => [k, 0])
    );
    playerBoardDict = Array.from({ length: 12 }, () =>
      getInitialPlayerBoardDict()
    );
    resetBustedPlayers();
    console.log("[Flip Seven Counter] Card data initialized", cardDict);
    console.log(
      "[Flip Seven Counter] Round card data initialized",
      roundCardDict
    );
    console.log(
      "[Flip Seven Counter] All players board initialized",
      playerBoardDict
    );
    createCardCounterPanel();
    startPlayerBoardMonitor();
    startLogMonitor();
    // You can continue to extend initialization logic here
  }

  function runLogic() {
    setTimeout(() => {
      // Detect all player names
      let playerNames = [];
      for (let i = 1; i <= 12; i++) {
        const selector = `#app > div > div > div.f7_scalable.f7_scalable_zoom > div > div.f7_players_container > div:nth-child(${i}) > div.f7_player_name.flex.justify-between > div:nth-child(1)`;
        const nameElem = document.querySelector(selector);
        if (nameElem?.innerText?.trim()) {
          playerNames.push(nameElem.innerText.trim());
        } else {
          break;
        }
      }
      alert(
        `[Flip Seven Counter] Entered game room. Player list:\n` +
          playerNames.map((n, idx) => `${idx + 1}. ${n}`).join("\n")
      );
      window.flipsevenPlayerNames = playerNames; // global access
      initializeGame();
      // You can continue your logic here
    }, 1500);
  }

  // First enter page
  if (isInGameUrl(window.location.href)) {
    runLogic();
  }

  // Listen for SPA navigation
  function onUrlChange() {
    if (isInGameUrl(window.location.href)) {
      runLogic();
    }
  }
  const _pushState = history.pushState;
  const _replaceState = history.replaceState;
  history.pushState = function () {
    _pushState.apply(this, arguments);
    setTimeout(onUrlChange, 0);
  };
  history.replaceState = function () {
    _replaceState.apply(this, arguments);
    setTimeout(onUrlChange, 0);
  };
  window.addEventListener("popstate", onUrlChange);
})();