Greasy Fork is available in English.

Xiangqi Game Assistant

Add tooltips for player info divs in the game and provide game-related assistance

// ==UserScript==
// @name        Xiangqi Game Assistant
// @namespace   Violentmonkey Scripts
// @match       https://play.xiangqi.com/*
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @version     1.33
// @author      -
// @description Add tooltips for player info divs in the game and provide game-related assistance
// @license     MIT
// @icon        https://play.xiangqi.com/icon.svg
// ==/UserScript==

(function () {
  // Indicates if the player has triggered the abandon action
  let hasAbandoned = false;

  // Regex pattern to identify guest usernames
  const guestNamePattern = /^guest\w{5}$/;

  // Stores tooltip elements for player info
  const tooltips = createTooltips();

  // Stores player information objects
  const playerInfos = [];

  // Player's rating
  let playerRating;

  // Flag indicating if the player has played with a guest in the past
  let hasPlayedWithGuest;

  // Options in the confirmation dialog to abandon a game
  const confirmOptions = ["是", "Yes", "Đúng"];

  // Instructions for navigating to the lobby
  const lobbyGotos = ["前往大厅", "前往大廳", "Go to lobby", "Đi đến sảnh đợi"];

  // Instance of the configuration menu
  let configMenu;

  /**
   * Creates and returns an array of tooltip elements.
   */
  function createTooltips() {
    return Array.from({ length: 2 }, createTooltipElement);
  }

  /**
   * Creates a single tooltip element and appends it to the document body.
   */
  function createTooltipElement() {
    const tooltip = document.createElement("div");
    tooltip.style.cssText = `
                position: fixed;
                background-color: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 5px;
                border-radius: 5px;
                display: none;
            `;
    document.body.appendChild(tooltip);
    return tooltip;
  }

  /**
   * Fetches player statistics for the given player index from the Xiangqi API and updates the playerInfos array.
   */
  async function fetchPlayerStats(playerIndex) {
    const playerName = playerInfos[playerIndex]?.name;
    const isFakeGuest = playerInfos[playerIndex]?.isFakeGuest;
    if (playerName && (!guestNamePattern.test(playerName) || isFakeGuest)) {
      await analyzeGameResponse(playerName, playerIndex);
      const response = await fetchUserInfo(playerName);
      handleResponse(response, playerIndex);
    }
  }

  /**
   * Fetches user information from the Xiangqi API.
   */
  async function fetchUserInfo(playerName) {
    return GM_xmlhttpRequestAsync({
      method: "GET",
      url: `https://api.xiangqi.com/api/users/account/${playerName}`,
    });
  }

  /**
   * Fetches games for a specific player from the Xiangqi API.
   */
  async function fetchGamesForPlayer(playerName) {
    return GM_xmlhttpRequestAsync({
      method: "GET",
      url: `https://api.xiangqi.com/api/users/games/${playerName}?page=1`,
    });
  }

  /**
   * Analyzes the response from the fetchGamesForPlayer function to determine
   * if the player has played with a guest and calculates the average remaining time and rage quitting rate.
   */
  async function analyzeGameResponse(playerName, playerIndex) {
    const response = await fetchGamesForPlayer(playerName);
    hasPlayedWithGuest = true;
    playerInfos[playerIndex].rageQuittingRate = 0;
    playerInfos[playerIndex].avgRemTimeRecentGames = 0;
    if (response.status === 200) {
      const jsonData = JSON.parse(response.responseText);
      if (jsonData.length === 0) {
        return;
      }
      const games = jsonData.items.filter((game) => game.end_reason);
      if (playerInfos[playerIndex].stats?.gamesAccess === "0") {
        hasPlayedWithGuest = games.some(
          (game) =>
            guestNamePattern.test(game.rplayer.username) ||
            guestNamePattern.test(game.bplayer.username)
        );
      }
      const recentLostCount = games.filter(
        (game) =>
          (game.bplayer.username === playerName &&
            game.bplayer.result === 10) ||
          (game.rplayer.username === playerName && game.rplayer.result === 10)
      ).length;
      let totalRageQuittingCount = 0;
      let totalRemTime = 0;
      for (const game of games) {
        const currentPlayer =
          game.bplayer.username === playerName ? game.bplayer : game.rplayer;
        const remainingTime = currentPlayer.seconds;
        if (game.end_reason === 200 && currentPlayer.result === 10) {
          ++totalRageQuittingCount;
        }
        totalRemTime += remainingTime;
      }
      if (totalRageQuittingCount > 0) {
        playerInfos[playerIndex].rageQuittingRate =
          totalRageQuittingCount / recentLostCount;
      }
      if (totalRemTime > 0) {
        playerInfos[playerIndex].avgRemTimeRecentGames =
          totalRemTime / games.length;
      }
    }
    playerInfos[playerIndex].rageQuittingRate = Math.ceil(
      playerInfos[playerIndex].rageQuittingRate * 100
    );
    playerInfos[playerIndex].avgRemTimeRecentGames = Math.ceil(
      playerInfos[playerIndex].avgRemTimeRecentGames
    );
  }

  /**
   * Handles the response from the API, extracting player statistics and updating
   * the corresponding player info object.
   */
  function handleResponse(response, playerIndex) {
    if (response.status === 200) {
      const playerStats = extractPlayerStatsFromResponse(response);
      playerInfos[playerIndex].stats = playerStats;
      updateTooltip(playerIndex, null);
    }
  }

  /**
   * Extracts player statistics from the API response.
   */
  function extractPlayerStatsFromResponse(response) {
    const {
      avg_opponent_rating,
      badges,
      draw_count,
      games_access,
      games_played_count,
      losses_count,
      winning_percentage,
      wins_count,
    } = JSON.parse(response.responseText);

    const win_streak =
      badges.temp.find((badge) => badge.win_streak)?.win_streak || 0;

    return {
      avgOpponentRating: avg_opponent_rating,
      draws: draw_count,
      gamesAccess: games_access,
      gamesPlayed: games_played_count,
      losses: losses_count,
      winRate: winning_percentage,
      wins: wins_count,
      winStreak: win_streak,
    };
  }

  /**
   * Formats a given number of seconds into a "minutes:seconds" string.
   */
  function formatTime(seconds) {
    const minutes = Math.floor(seconds / 60);
    const remainingSeconds = seconds % 60;
    return `${minutes}m${remainingSeconds}s`;
  }

  /**
   * Updates the content of the tooltip with player information and positions it based on the mouse event location.
   */
  function updateTooltip(event, playerIndex) {
    const tooltip = tooltips[playerIndex];
    const playerInfo = playerInfos[playerIndex];
    if (!tooltip || !playerInfo) return;

    tooltip.style.visibility = "hidden";
    tooltip.style.display = "block";

    const tooltipWidth = tooltip.offsetWidth;
    const tooltipHeight = tooltip.offsetHeight;
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;
    const margin = 20; // Margin from window edges

    let left = event.clientX + margin;
    let top = event.clientY + margin;

    // Adjust left position if tooltip overflows the window's right edge
    if (left + tooltipWidth > windowWidth) {
      left = windowWidth - tooltipWidth - margin;
    }

    // Adjust top position if tooltip overflows the window's top or bottom edge
    if (top < tooltipHeight) {
      top += event.target.offsetHeight - margin;
    } else if (windowHeight - top < tooltipHeight) {
      top -= tooltipHeight + margin;
    }

    tooltip.style.left = `${left}px`;
    tooltip.style.top = `${top}px`;
    tooltip.style.visibility = "visible";

    if (playerInfo.stats) {
      const {
        gamesPlayed,
        wins,
        losses,
        draws,
        winRate,
        winStreak,
        avgOpponentRating,
      } = playerInfo.stats;
      const rageQuittingRate = playerInfos[playerIndex].rageQuittingRate;
      const formattedTime = formatTime(
        playerInfos[playerIndex].avgRemTimeRecentGames
      );

      tooltip.innerHTML = `
                Games Played: ${gamesPlayed}<br>
                Wins: ${wins} | Losses: ${losses} | Draws: ${draws}<br>
                Win Rate: ${winRate}% | Win Streak: ${winStreak}<br>
                Avg. Opponent Rating: ${avgOpponentRating}<br>
                Rage Quitting Rate for Recent Games: ${rageQuittingRate}%<br>
                Avg. Remaining Time for Recent Games: ${formattedTime}
            `;
      tooltip.style.display = "block";
    } else {
      tooltip.style.display = "none";
    }
  }

  /**
   * Attaches mouseover and mouseout events to player info divs to show and hide tooltips.
   */
  function attachTooltip(playerIndex, playerInfoDiv) {
    playerInfoDiv.addEventListener("mouseover", (event) =>
      updateTooltip(event, playerIndex)
    );
    playerInfoDiv.addEventListener("mouseout", () => {
      tooltips[playerIndex].style.display = "none";
    });
  }

  /**
   * Monitors for URL changes and updates player information divs accordingly.
   */
  function listenForUrlChange(callback, interval) {
    let currentURL = window.location.href;
    setInterval(() => {
      const newURL = window.location.href;
      if (currentURL !== newURL) {
        callback();
        currentURL = newURL;
      }
    }, interval);
  }

  /**
   * Monitors modal events and closes the sign-in modal if it appears.
   */
  function listenForModalEvents(interval) {
    setInterval(() => {
      const modalWrapper = document.querySelector("div.Wrapper-zur8fw-0");
      if (modalWrapper) {
        const signInLink = modalWrapper.querySelector("a.sign-in-link");
        if (signInLink) {
          const modalPortal = modalWrapper.closest("div.ReactModalPortal");
          if (modalPortal) {
            const closeButton = modalPortal.querySelector("button.btn-close");
            if (closeButton) {
              closeButton.click();
            } else {
              modalPortal.style.display = "none";
            }
          }
        }
      }
    }, interval);
  }

  /**
   * Checks for the existence of player info divs and sets up tooltips and data fetching.
   */
  async function checkPlayerInfoDivs() {
    tooltips.forEach((tooltip) => (tooltip.style.display = "none"));

    const intervalId = setInterval(async () => {
      const allPlayerInfoDivs = document.querySelectorAll("div.playerInfo");
      const playerInfoDivs = Array.from(allPlayerInfoDivs).filter(
        (div) => !div.querySelector("div.userNameNdRating")
      );

      if (playerInfoDivs.length === 2) {
        clearInterval(intervalId);

        await Promise.all(
          playerInfoDivs.map(async (div, index) => {
            const nameElement = div.querySelector("div.userName span");

            if (nameElement) {
              const ratingElement =
                playerInfoDivs[0].querySelector("span.playerRating");
              playerRating = parseInt(
                ratingElement.textContent.replace(/^\(|\)$/g, "")
              );
              const playerName = nameElement.textContent;
              const isFakeGuest =
                guestNamePattern.test(playerName) &&
                div.querySelector("a > div.userName");
              playerInfos[index] = {
                div,
                name: playerName,
                stats: null,
                rageQuittingRate: 0,
                avgRemTimeRecentGames: 0,
                isFakeGuest,
              };

              await fetchPlayerStats(index);
              attachTooltip(index, div);

              if (index === 0) {
                await handleAbandonAction();
              }
            } else {
              clearInterval(intervalId);
              setTimeout(checkPlayerInfoDivs, 1000);
            }
          })
        );
      }
    }, 1000);
  }

  /**
   * Checks if the current game should be abandoned based on various conditions and initiates the abandon action if needed.
   */
  async function handleAbandonAction() {
    const abandonImage = document.querySelector('img[alt="abandon"]');

    if (abandonImage) {
      if (shouldAbandon()) {
        const abandonLink = abandonImage.parentElement;
        abandonLink.click();

        const responseSpan = document.querySelector("span.responseButton");
        const childSpan = responseSpan?.querySelector("span");
        if (childSpan && confirmOptions.includes(childSpan.textContent)) {
          const parentAnchor = responseSpan.parentElement;
          if (!hasAbandoned) {
            parentAnchor.click();
            hasAbandoned = true;
            setTimeout(gotoLobby, 1000);
          }
        }
      }
    }
  }

  /**
   * Navigates the player to the lobby if the abandon action is confirmed.
   */
  function gotoLobby() {
    const laddaLabelSpans = document.querySelectorAll("span.ladda-label");
    laddaLabelSpans.forEach((span) => {
      if (lobbyGotos.includes(span.textContent)) {
        const parentButton = span.closest("button");
        if (parentButton) {
          parentButton.click();
          hasAbandoned = false;
          return;
        }
      }
    });
    setTimeout(gotoLobby, 1000);
  }

  /**
   * Determines whether the current game should be abandoned.
   */
  function shouldAbandon() {
    const opponentInfo = playerInfos[0];
    if (!opponentInfo) {
      return false;
    }

    if (playerRating && playerRating < getAbandonRating()) {
      return true;
    }

    const opponentName = opponentInfo.name;
    if (guestNamePattern.test(opponentName) && !opponentInfo.isFakeGuest) {
      return shouldAbandonGuestEnabled();
    }

    return shouldAbandonPlayersWithoutGuestsEnabled() && !hasPlayedWithGuest;
  }

  /**
   * Wraps GM_xmlhttpRequest in a promise for async/await usage.
   */
  async function GM_xmlhttpRequestAsync(details) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        ...details,
        onload: (response) => resolve(response),
      });
    });
  }

  /**
   * Initialization function to set up the user script on page load.
   */
  async function initialize() {
    // Create a floating element for configuration
    const configButton = document.createElement("div");
    configButton.style.cssText = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        background-color: #3498db;
        color: white;
        padding: 10px;
        border-radius: 5px;
        cursor: pointer;
    `;

    // Append the floating element to the body
    document.body.appendChild(configButton);

    // Listen for click event on the configButton
    configButton.addEventListener("click", openConfigMenu);

    await checkPlayerInfoDivs();
    listenForUrlChange(checkPlayerInfoDivs, 1000);
    listenForModalEvents(1000);
  }

  /**
   * Opens the configuration menu for the user script.
   */
  function openConfigMenu() {
    // If the menu instance already exists, return early
    if (configMenu) {
      return;
    }

    // Create a new menu instance
    configMenu = document.createElement("div");
    configMenu.style.cssText = `
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background-color: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 20px;
      border-radius: 5px;
      display: flex;
      flex-direction: column;
      align-items: center;
  `;

    // Add title to the menu
    const title = document.createElement("h3");
    title.textContent = "Xiangqi Game Assistant Configuration";
    title.style.marginBottom = "10px";
    configMenu.appendChild(title);

    // Add configuration options to the menu
    const abandonByRatingLabel = createAbandonRatingLabel();
    const enableAbandonGuestLabel = createConfigCheckboxLabel(
      "enableAbandonGuest",
      "Abandon guest players"
    );
    const enableAbandonPlayersWithoutGuestsLabel = createConfigCheckboxLabel(
      "enableAbandonPlayersWithoutGuests",
      "Abandon players not participating in games with guest players"
    );

    const saveButton = createConfigButton("Save");
    saveButton.addEventListener("click", saveConfiguration);

    const cancelButton = createConfigButton("Cancel");
    cancelButton.addEventListener("click", removeConfigMenu);

    configMenu.appendChild(abandonByRatingLabel);
    configMenu.appendChild(enableAbandonGuestLabel);
    configMenu.appendChild(enableAbandonPlayersWithoutGuestsLabel);
    configMenu.appendChild(saveButton);
    configMenu.appendChild(cancelButton);
    document.body.appendChild(configMenu);
  }

  /**
   * Removes the configuration menu from the document.
   */
  function removeConfigMenu() {
    if (configMenu) {
      configMenu.remove();
      configMenu = null;
    }
  }

  /**
   * Creates a label for the "abandon by rating" configuration option.
   */
  function createAbandonRatingLabel() {
    const label = document.createElement("label");
    label.style.cssText = `
      margin-bottom: 10px;
    `;
    label.appendChild(
      document.createTextNode("Abandon players whose rating are below: ")
    );
    const number = document.createElement("input");
    number.type = "number";
    number.value = getAbandonRating();
    number.name = "abandonRating";
    number.style.width = "60px";
    label.appendChild(number);
    return label;
  }

  /**
   * Creates a label for configuration checkboxes.
   */
  function createConfigCheckboxLabel(settingName, labelText) {
    const label = document.createElement("label");
    label.style.cssText = `
      margin-bottom: 10px;
    `;
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = GM_getValue(settingName, false);
    checkbox.name = settingName;
    label.appendChild(checkbox);
    label.appendChild(document.createTextNode(`  ${labelText}`));
    return label;
  }

  /**
   * Creates a button for the configuration menu.
   */
  function createConfigButton(buttonText) {
    const button = document.createElement("button");
    button.textContent = buttonText;
    button.style.cssText = `
      margin-top: 10px;
      font-size: 16px;
    `;
    return button;
  }

  /**
   * Retrieves the abandon rating threshold from storage.
   */
  function getAbandonRating() {
    return GM_getValue("abandonRating", 0); // Default value: 0
  }

  /**
   * Checks if the script is configured to abandon games with guest players.
   */
  function shouldAbandonGuestEnabled() {
    // Retrieve the saved configuration for "abandon guest" logic
    return GM_getValue("enableAbandonGuest", false); // Default value: false
  }

  /**
   * Checks if the script is configured to abandon games with players who haven't played with guests.
   */
  function shouldAbandonPlayersWithoutGuestsEnabled() {
    // Retrieve the saved configuration for "abandon players without guests" logic
    return GM_getValue("enableAbandonPlayersWithoutGuests", false); // Default value: false
  }

  /**
   * Saves the user's configuration choices.
   */
  function saveConfiguration() {
    const abandonRating = document.querySelector(
      'input[type="number"][name="abandonRating"]'
    );
    const enableAbandonGuestCheckbox = document.querySelector(
      'input[type="checkbox"][name="enableAbandonGuest"]'
    );
    const enableAbandonPlayersWithoutGuestsCheckbox = document.querySelector(
      'input[type="checkbox"][name="enableAbandonPlayersWithoutGuests"]'
    );

    GM_setValue("abandonRating", parseInt(abandonRating.value));
    GM_setValue("enableAbandonGuest", enableAbandonGuestCheckbox.checked);
    GM_setValue(
      "enableAbandonPlayersWithoutGuests",
      enableAbandonPlayersWithoutGuestsCheckbox.checked
    );

    removeConfigMenu();
  }

  // Call the initialize function to set everything up
  initialize();
})();