[GC] Avatar Checklist

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

// ==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)
]);