[GC] Avatar Checklist

Avatar, Site Themes, Relics, and Trophies checklist for Grundo's Cafe, visit https://www.grundos.cafe/~Tyco

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         [GC] Avatar Checklist
// @namespace    https://www.grundos.cafe/
// @version      4.0.0
// @description  Avatar, Site Themes, Relics, and Trophies checklist for Grundo's Cafe, visit https://www.grundos.cafe/~Tyco
// @author       soupfaerie, supercow64, arithmancer
// @match        https://www.grundos.cafe/~Tyco*
// @match        https://www.grundos.cafe/~tyco*
// @match        https://www.grundos.cafe/~TYCO*
// @grant        none
// @license      MIT
// ==/UserScript==

const textToHTML = (text) => new DOMParser().parseFromString(text, "text/html");

/**
 * Normalizes text by removing extra whitespace, newlines, and other special characters.
 * This helps ensure consistent comparison between strings that might have different formatting.
 *
 * @param {string} text The text to normalize
 * @returns {string} The normalized text
 */
const normalizeText = (text) => {
  if (!text) return '';
  return text
    // Convert to lowercase first
    .toLowerCase()
    // Replace special quotes/apostrophes with standard ones
    .replace(/[''""`´]/g, "'")
    // Remove all punctuation except apostrophes
    .replace(/[^\w\s']/g, ' ')
    // Replace multiple spaces with single space
    .replace(/\s+/g, ' ')
    // Trim leading/trailing whitespace
    .trim();
};

/**
 * Fetches /water/colouring/, parses it, and extracts the username from
 * the #user-info-username element.
 *
 * @returns {Promise<string>} The username
 */
const getUsernameFromColouringAsync = () =>
  fetch("/water/colouring/")
    .then((res) => res.text())
    .then(textToHTML)
    .then((doc) => {
      const username =
        doc.querySelector("#user-info-username")?.textContent?.trim() || "";
      if (!username) {
        throw new Error("Username not found on /water/colouring/");
      }
      return username;
    });

/**
 * Fetches /userlookup/?user={username}, parses it, and returns the Document.
 *
 * @param {string} username
 * @returns {Promise<Document>} The parsed userlookup Document
 */
const getUserLookupDocAsync = (username) =>
  fetch("/userlookup/?user=" + encodeURIComponent(username))
    .then((res) => res.text())
    .then(textToHTML);

/**
 * Convenience function that:
 * 1) Gets username via /water/colouring/
 * 2) Fetches and parses /userlookup/?user={username}
 *
 * @returns {Promise<{ username: string, userLookupDoc: Document }>}
 */
const getTrophiesViaColouringAsync = () =>
  getUsernameFromColouringAsync().then((username) =>
    getUserLookupDocAsync(username).then((doc) => ({
      username,
      userLookupDoc: doc,
    }))
  );

/**
 * Analyse the user lookup Document for a list of collected game trophies.
 * Returns the list as image basenames like "85_1.gif".
 *
 * @param {Node} node The root node (default: document)
 * @returns {string[]} basenames of collected game trophy images
 */
const getCollectedGameTrophies = (node = document) => {
  const imgs = Array.from(node.querySelectorAll('#gameTrophiesList img'));
  return imgs
    .map((img) => basename(img.src))
    .filter((name) => !!name);
};

/**
 * Expand trophy basenames to include implied lower ranks for the same game.
 * - Default rule: if _1 (gold) exists, also include _2 and _3; if _2 (silver) exists, also include _3.
 * - goldOnly games imply nothing (no silver/bronze).
 * - hasRunnerUp games: if rank is 1/2/3, also include _4 (runner-up).
 *   If the user only has _4, keep it and do not imply other ranks.
 *
 * @param {string[]} basenames
 * @returns {string[]} expanded list including implied lower ranks and runner-ups where applicable
 */
const expandTrophyBasenames = (basenames) => {
  const goldOnly = new Set([67, 41]); // games that only have gold trophies
  const hasRunnerUp = new Set([76]);  // games that have a runner-up trophy
  const set = new Set(basenames);

  basenames.forEach((name) => {
    // Recognize ranks 1–4, so we handle "only 4" gracefully.
    const m = name.match(/^(\d+)_([1-4])\.gif$/);
    if (!m) return;

    const game = Number(m[1]);
    const rank = Number(m[2]);

    // If the game supports runner-up and rank is 1–3, imply _4.
    if (hasRunnerUp.has(game) && rank >= 1 && rank <= 3) {
      set.add(`${game}_4.gif`);
    }

    // Gold-only games do not imply silver/bronze; keep whatever is present.
    if (goldOnly.has(game)) {
      return;
    }

    // Default implications for non-goldOnly games:
    if (rank === 1) {
      set.add(`${game}_2.gif`);
      set.add(`${game}_3.gif`);
    } else if (rank === 2) {
      set.add(`${game}_3.gif`);
    }
    // If rank === 3 or rank === 4, no further implications.
  });

  return Array.from(set);
};

/**
 * Fetch the user lookup via colouring and return collected game trophy basenames (expanded).
 *
 * @returns {Promise<string[]>}
 */
const getCollectedGameTrophiesAsync = () =>
  getTrophiesViaColouringAsync().then(({ userLookupDoc }) =>
    expandTrophyBasenames(getCollectedGameTrophies(userLookupDoc))
  );

/**
 * Move collected game trophy cards into their section's <details> element.
 * Sections live under #trophies, and each has a details .avatar-grid target.
 *
 * @param {string[]} collectedTrophies basenames like "85_1.gif"
 */
function moveCollectedTrophies(collectedTrophies) {
  const sections = document.querySelectorAll('#trophies > *');
  const collectedSet = new Set(collectedTrophies);
  const found = new Set();

  sections.forEach((section) => {
    const foundGrid = section.querySelector('details .avatar-grid');
    if (!foundGrid) return;

    const cards = section.querySelectorAll(':scope > .avatar-grid > .avatar-card');
    cards.forEach((card) => {
      const img = card.querySelector('img');
      if (!img) return;
      const name = basename(img.src);
      if (collectedSet.has(name)) {
        card.classList.add('achieved');
        foundGrid.appendChild(card);
        found.add(name);
      }
    });
  });

  collectedTrophies.forEach((name) => {
    if (!found.has(name)) {
      console.log(`Collected trophy not found on page: ${name}`);
    }
  });
}

/**
 * Analyse the HTML select element for a list of avatars the user has collected.
 *
 * @param {Node} node The root node (default: document)
 * @returns {string[]} the list of avatars as an array of basenames
 */
const getCollectedAvatars = (node = document) => {
  // The list of avatars is partitioned into default avatars
  // and collected secret avatars. The option with the text ---
  // (6 dashes) is the inclusive cutoff. All avatars at and below
  // the cutoff are collected secret avatars
  const allAvatars = Array.from(
    node.querySelectorAll(`[name="new_avatar"] option`)
  );
  const i = allAvatars.findIndex((e) => e.textContent.includes("---"));
  return allAvatars.slice(i).map((e) => e.value);
};

/**
 * Analyse the HTML select element for all site themes available to the user.
 *
 * @param {Node} node The root node (default: document)
 * @returns {string[]} all site themes as an array of theme names
 */
const getAllSiteThemes = (node = document) => {
  // Find all options in the site_theme select element
  const themeOptions = Array.from(node.querySelectorAll(`[name="site_theme"] option`));
  if (!themeOptions.length) return [];

  // Return the text content of each option
  return themeOptions
    .map(option => option.textContent.trim())
    .filter(themeName => themeName); // Filter out any empty theme names
};

/**
 * Returns a Promise that resolves to a list of avatars
 * the user has collected.
 *
 * @returns {string[]} list of collected avatars
 */
const getCollectedAvatarsAsync = () =>
  fetch("/neoboards/preferences/")
    .then((res) => res.text())
    .then(textToHTML)
    .then(getCollectedAvatars);

/**
 * Returns a Promise that resolves to all site themes
 * available to the user.
 *
 * @returns {string[]} all site themes as an array of theme names
 */
const getAllSiteThemesAsync = () =>
  fetch("/help/siteprefs")
    .then((res) => res.text())
    .then(textToHTML)
    .then(getAllSiteThemes);

/**
 * Analyse the HTML response to identify logged relics.
 *
 * @param {Node} node The root node (default: document)
 * @returns {string[]} the list of logged relics as an array of relic names
 */
const getLoggedRelics = (node = document) => {
  // Find all relic divs
  const relicDivs = Array.from(
    node.querySelectorAll(`.flex-column.small-gap.center-items`)
  );
  if (!relicDivs.length) return [];

  // Extract the names of relics that don't have the not-redeemed class
  const loggedRelics = relicDivs
    .map(relic => {
      const img = relic.querySelector('img');

      // Check if the image doesn't have the not-redeemed class (meaning it's logged)
      if (img && !img.classList.contains('not-redeemed')) {
        // Use the title attribute of the image which contains the relic name
        if (img.title) {
          return normalizeText(img.title);
        }

        // If title is not available, try to get the name from the span element
        const span = relic.querySelector('span.medfont strong');
        if (span) {
          return normalizeText(span.textContent);
        }
      }

      // If the image has the not-redeemed class or no name could be found, return null
      return null;
    })
    .filter(name => name); // Filter out any null values

  return loggedRelics;
};

/**
 * Analyse the HTML response to identify unlogged relics.
 *
 * @param {Node} node The root node (default: document)
 * @returns {string[]} the list of unlogged relics as an array of relic names
 */
const getUnloggedRelics = (node = document) => {
  // Find all relic divs
  const relicDivs = Array.from(
    node.querySelectorAll(`.flex-column.small-gap.center-items`)
  );
  if (!relicDivs.length) return [];

  // Extract the names of relics that have the not-redeemed class
  const unloggedRelics = relicDivs
    .map(relic => {
      const img = relic.querySelector('img');

      // Check if the image has the not-redeemed class (meaning it's not logged)
      if (img && img.classList.contains('not-redeemed')) {
        // Use the title attribute of the image which contains the relic name
        if (img.title) {
          return normalizeText(img.title);
        }

        // If title is not available, try to get the name from the span element
        const span = relic.querySelector('span.medfont strong');
        if (span) {
          return normalizeText(span.textContent);
        }
      }

      // If the image doesn't have the not-redeemed class or no name could be found, return null
      return null;
    })
    .filter(name => name); // Filter out any null values

  return unloggedRelics;
};

/**
 * Returns a Promise that resolves to a list of logged relics.
 *
 * @returns {string[]} list of logged relics as an array of relic names
 */
const getLoggedRelicsAsync = () =>
  fetch("/space/warehouse/relics/")
    .then((res) => res.text())
    .then(textToHTML)
    .then(getLoggedRelics);

/**
 * Returns a Promise that resolves to a list of unlogged relics.
 *
 * @returns {string[]} list of unlogged relics as an array of relic names
 */
const getUnloggedRelicsAsync = () =>
  fetch("/space/warehouse/relics/")
    .then((res) => res.text())
    .then(textToHTML)
    .then(getUnloggedRelics);

/**
 * For static assets, returns the basename of the asset indicated
 * in the url.
 *
 * ```js
 * basename("https://example.com/foo/bar/baz.gif") == "baz.gif"
 * ```
 *
 * @param {string} url path to the file with slashes
 * @returns {string} the basename
 */
const basename = (url) => url.split("/").slice(-1)[0];



/**
 * Move collected avatar cards into their section's <details> element.
 *
 * The tracker page groups avatars by section. Each section is a <div>
 * directly under the #avatars container. Within each section there are one or
 * more `.avatar-grid` containers followed by a <details> element containing an
 * empty `.avatar-grid`. Collected avatars should be appended to that grid.
 *
 * @param {string[]} collectedAvatars basenames of the user's collected avatars
 */
function moveCollectedAvatars(collectedAvatars) {
  const sections = document.querySelectorAll('#avatars > *');
  // Create a set to track which collected avatars are found on the page
  const foundAvatars = new Set();

  sections.forEach((section) => {
    const foundGrid = section.querySelector('details .avatar-grid');
    if (!foundGrid) return;

    const cards = section.querySelectorAll(':scope > .avatar-grid > .avatar-card');
    cards.forEach((card) => {
      const img = card.querySelector('img');
      if (!img) return; // site theme cards currently lack images

      const avatarName = basename(img.src);
      if (collectedAvatars.includes(avatarName)) {
        card.classList.add('check');
        foundGrid.appendChild(card);
        foundAvatars.add(avatarName);
      }
    });
  });

  // Log any collected avatars that weren't found on the page
  collectedAvatars.forEach(avatar => {
    if (!foundAvatars.has(avatar)) {
      console.log(`Collected avatar not found on page: ${avatar}`);
    }
  });
}

/**
 * Move collected site theme cards into the themes section's <details> element.
 *
 * The site themes section has a <details> element containing an empty `.avatar-grid`.
 * All site themes from the site_theme select element should be appended to that grid.
 *
 * @param {string[]} collectedThemes names of all available site themes
 */
function moveCollectedSiteThemes(collectedThemes) {
  const themesSection = document.querySelector('#themes');
  if (!themesSection) return;

  const foundGrid = themesSection.querySelector('details .avatar-grid');
  if (!foundGrid) return;

  // Create a set to track which collected themes are found on the page
  const foundThemes = new Set();

  const cards = themesSection.querySelectorAll(':scope > .avatar-grid > .avatar-card');
  cards.forEach((card) => {
    const nameElement = card.querySelector('.avatar-name');
    if (!nameElement) return;

    // Get the theme name directly from the card
    const themeName = nameElement.textContent.trim();

    // Check if this theme is in the collected themes list
    if (collectedThemes.includes(themeName)) {
      card.classList.add('done');
      foundGrid.appendChild(card);
      foundThemes.add(themeName);
    } else {
      if (themeName === "Tyrannia" && collectedThemes.includes("Tyrannian Night")) {
        card.classList.add('done');
        foundGrid.appendChild(card);
        foundThemes.add(themeName);
      }
    }
  });

  // Log any collected themes that weren't found on the page
  collectedThemes.forEach(theme => {
    if (!foundThemes.has(theme)) {
      console.log(`Collected site theme not found on page: ${theme}`);
    }
  });
}

/**
 * Move logged relic cards into their section's <details> element and track unlogged relics.
 *
 * The relics section has a <details> element containing an empty `.avatar-grid`.
 * Logged relics should be appended to that grid and marked with the "redeemed" class.
 * This function also tracks which unlogged relics are found on the page.
 *
 * @param {string[]} loggedRelics names of logged relics
 * @param {string[]} unloggedRelics names of unlogged relics
 */
function moveLoggedRelics(loggedRelics, unloggedRelics = []) {
  const relicsSection = document.querySelector('#relics');
  if (!relicsSection) {
    console.log('Could not find #relics section');
    return;
  }

  // Find all relic sections (rdailies, rgames, etc.)
  const sections = relicsSection.querySelectorAll('[id^="r"]');

  // Create a map of section IDs to their details grid elements
  const sectionGrids = new Map();

  // Find the details grid in each section
  sections.forEach(section => {
    const sectionId = section.id;
    const details = section.querySelector('details');
    if (details) {
      const grid = details.querySelector('.avatar-grid');
      if (grid) {
        sectionGrids.set(sectionId, grid);
      }
    }
  });

  if (sectionGrids.size === 0) {
    console.log('Could not find any details .avatar-grid in any relic section');
    return;
  }

  // Create sets to track which relics are found on the page
  const foundLoggedRelics = new Set();
  const foundUnloggedRelics = new Set();

  // Process each section separately
  sections.forEach(section => {
    const sectionId = section.id;
    const detailsGrid = sectionGrids.get(sectionId);

    // Skip sections without a details grid
    if (!detailsGrid) return;

    // Find all relic cards in this section
    const cards = section.querySelectorAll('.avatar-grid > .avatar-card');

    cards.forEach((card) => {
      const nameElement = card.querySelector('.avatar-name');
      if (!nameElement) return;

      // Get the relic name directly from the card and normalize it
      const relicName = normalizeText(nameElement.textContent);

      // Check if this relic is in the logged relics list using direct comparison
      const loggedMatchIndex = loggedRelics.findIndex((relic) =>
        normalizeText(relic) === normalizeText(relicName)
      );

      if (loggedMatchIndex !== -1) {
        const originalRelicName = loggedRelics[loggedMatchIndex];
        card.classList.add('redeemed');
        detailsGrid.appendChild(card);
        foundLoggedRelics.add(originalRelicName);
      }

      // Check if this relic is in the unlogged relics list using direct comparison
      const unloggedMatchIndex = unloggedRelics.findIndex((relic) =>
        normalizeText(relic) === normalizeText(relicName)
      );

      if (unloggedMatchIndex !== -1) {
        const originalRelicName = unloggedRelics[unloggedMatchIndex];
        foundUnloggedRelics.add(originalRelicName);
      }
    });
  });

  // Log any logged relics that weren't found on the page
  loggedRelics.forEach(relic => {
    if (!foundLoggedRelics.has(relic)) {
      console.log(`Logged relic not found on page: ${relic}`);
    }
  });

  // Log any unlogged relics that weren't found on the page
  unloggedRelics.forEach(relic => {
    if (!foundUnloggedRelics.has(relic)) {
      console.log(`Unlogged relic not found on page: ${relic}`);
    }
  });
}

// Fetch avatar, site theme, relic, and game trophy data and move the collected cards
Promise.all([
  getCollectedAvatarsAsync().then(moveCollectedAvatars),
  getAllSiteThemesAsync().then(moveCollectedSiteThemes),
  // Fetch both logged and unlogged relics and pass them to moveLoggedRelics
  Promise.all([
    getLoggedRelicsAsync(),
    getUnloggedRelicsAsync()
  ]).then(([loggedRelics, unloggedRelics]) => {
    moveLoggedRelics(loggedRelics, unloggedRelics);
  }),
  // Fetch game trophies from user lookup and move matching trophy cards
  getCollectedGameTrophiesAsync().then(moveCollectedTrophies)
]);