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