Skip to content

Add tier icons to skin command#19

Open
MajbrittB wants to merge 3 commits intomainfrom
add-tier-icons-to-skin-fields
Open

Add tier icons to skin command#19
MajbrittB wants to merge 3 commits intomainfrom
add-tier-icons-to-skin-fields

Conversation

@MajbrittB
Copy link
Copy Markdown
Collaborator

Added emojis for the tiers in the skin command

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds emoji icons to skin tier displays in the skin command. However, it also includes several unrelated changes that should be in separate PRs.

Changes:

  • Adds tierEmotes object with Discord custom emoji IDs for each skin tier (Select, Deluxe, Premium, Exclusive, Ultra)
  • Introduces new getTier() function that returns tier name with emoji
  • Updates src/embeds/skin.js to use getTier() instead of getTierName()
  • Includes unrelated changes: new team_standings command, Discord.js event name fix, and documentation file

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/utils/tiers.js Adds tierEmotes object and getTier() function to include emojis with tier names
src/embeds/skin.js Updates import and usage from getTierName to getTier to display emojis
src/commands/liquipedia/team_standings.js Completely new command for team standings - unrelated to PR purpose
index.js Updates Discord.js event name from 'ready' to 'clientReady' - unrelated to PR purpose
CLAUDE.md Adds new documentation file - unrelated to PR purpose

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

CLAUDE.md Outdated
Comment on lines +1 to +45
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
npm install # Install dependencies
npm start # Run the bot (node index.js)
```

Set `NODE_ENV=development` to use dev bot token and register commands to a single guild (faster for testing).

## Environment Variables

- `DISCORD_TOKEN` / `DEV_DISCORD_TOKEN` - Bot tokens for production/development
- `CLIENT_ID` / `DEV_CLIENT_ID` - Discord application client IDs
- `GUILD_ID` - Required for dev mode; also used to clear guild commands in production
- `LIQUIPEDIA_API_KEY` - Required for `/player`, `/team`, `/upcoming_matches`, `/upcoming_tournaments` commands

## Architecture

**Discord.js v14 bot using ES modules** (`"type": "module"` in package.json).

### Entry Point
`index.js` - Dynamically loads all commands from `src/commands/` (including subdirectories), registers slash commands with Discord, and handles interactions.

### Project Structure
- `src/commands/` - Slash command handlers. Each file exports `{ data: SlashCommandBuilder, execute: function }`.
- `src/commands/liquipedia/` - Commands that use the Liquipedia API (player, team, matches, tournaments).
- `src/embeds/` - Reusable embed builders (e.g., `createAgentEmbed`, `createSkinEmbed`).
- `src/utils/` - Helpers for API fetching, caching, and data mapping.

### Data Sources
- **Valorant API** (`https://valorant-api.com/v1/`) - Agents, weapons, skins, bundles, maps, events, seasons.
- **Liquipedia API** - Esports data (players, teams, tournaments).

### Global State
`src/utils/colors.js` sets `globalThis.VALORANT_RED` which is used throughout embeds.

### Caching
`src/utils/cache.js` provides a simple in-memory cache with TTL for Liquipedia responses.

### Skin Tier System
`src/utils/tiers.js` maps skin tier UUIDs to names (Select, Deluxe, Premium, Exclusive, Ultra) and prices.
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new documentation file appears unrelated to the stated PR purpose of adding tier icons to the skin command. While documentation is valuable, this should be in a separate PR for better code organization and review.

Copilot uses AI. Check for mistakes.
Comment on lines 29 to 32
console.log('emote', emote);
return `${name} ${emote}`.trim();
}

Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this console.log debugging statement before merging. Debug logs should not be left in production code.

Suggested change
console.log('emote', emote);
return `${name} ${emote}`.trim();
}
return `${name} ${emote}`.trim();
}

Copilot uses AI. Check for mistakes.
"Select": "<:select:1464237592022351932>",
"Deluxe": "<:deluxe:1464237687543435297>",
"Premium": "<:premium:1464237631079583858>",
"Exclusive": "<:exclusive:1464237592022351932>",
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both "Select" and "Exclusive" tiers are using the same emoji ID (1464237592022351932). This appears to be a copy-paste error. Each tier should have its own unique emoji to properly distinguish them visually.

Suggested change
"Exclusive": "<:exclusive:1464237592022351932>",
"Exclusive": "<:exclusive:1464237799999999999>",

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +348
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import fetch from 'node-fetch';

import { getCache, setCache } from '../../utils/cache.js';

// /team_standings #{team-name}
//
// Look up a Valorant team's wins and losses in their current or most recent tournament

export default {
data: new SlashCommandBuilder()
.setName('team_standings')
.setDescription('Look up a team\'s wins and losses in their current or most recent tournament')
.addStringOption(option =>
option.setName('team')
.setDescription('The name of the team')
.setRequired(true)
),

async execute(interaction) {
await interaction.deferReply();

const teamName = interaction.options.getString('team');

// Check cache first
const cacheKey = `team-standings-${teamName.toLowerCase()}`;
const cachedData = getCache(cacheKey);
if (cachedData) {
await interaction.editReply(cachedData);
return;
}

try {
// First, find the team to get the correct pagename
const team = await fetchTeam(teamName);
if (!team) {
await interaction.editReply(`Team "${teamName}" not found. Please check the name and try again.`);
return;
}

// Fetch recent matches for this team
const matches = await fetchTeamMatches(team.pagename, team.name);

if (!matches || matches.length === 0) {
await interaction.editReply(`No recent tournament matches found for "${team.name}".`);
return;
}

// Group matches by tournament and find the most recent one
const tournamentData = groupMatchesByTournament(matches, team.pagename, team.name);

if (!tournamentData) {
await interaction.editReply(`No tournament data found for "${team.name}".`);
return;
}

const { tournament, wins, losses, isOngoing, matches: tournamentMatches } = tournamentData;
const teamLogo = team.logourl || team.logodarkurl || team.textlesslogourl || '';

// Build the embed
const embed = new EmbedBuilder()
.setTitle(`${team.name} - Tournament Standings`)
.setColor(globalThis.VALORANT_RED)
.setDescription(buildDescription(tournament, isOngoing))
.setFooter({ text: 'Data source: Liquipedia', iconURL: 'https://liquipedia.net/commons/images/2/2c/Liquipedia_logo.png' });

// Add record field
const record = `**${wins}** - **${losses}**`;
const winRate = wins + losses > 0 ? ((wins / (wins + losses)) * 100).toFixed(1) : '0.0';

embed.addFields(
{ name: 'Record (W-L)', value: record, inline: true },
{ name: 'Win Rate', value: `${winRate}%`, inline: true },
{ name: 'Matches Played', value: `${wins + losses}`, inline: true }
);

// Add recent match results (up to 5)
const recentResults = buildRecentResults(tournamentMatches, team.pagename, team.name);
if (recentResults) {
embed.addFields({ name: 'Recent Results', value: recentResults, inline: false });
}

if (teamLogo) {
embed.setThumbnail(teamLogo);
}

const replyPayload = { embeds: [embed] };

// Cache for 15 minutes (standings change more frequently)
setCache(cacheKey, replyPayload, 1000 * 60 * 15);

await interaction.editReply(replyPayload);
} catch (error) {
console.error(error);
await interaction.editReply('There was an error while fetching team standings. Please try again later.');
}
}
}

// Fetch team by name
async function fetchTeam(teamName) {
const response = await fetch(`https://api.liquipedia.net/api/v3/team?wiki=valorant&conditions=%5B%5Bname%3A%3A${encodeURIComponent(teamName)}%5D%5D`, {
headers: {
'accept': 'application/json',
'authorization': `Apikey ${process.env.LIQUIPEDIA_API_KEY}`
}
});
const data = await response.json();

if (!data || !data.result || data.result.length === 0) {
return null;
}

return data.result[0];
}

// Fetch recent matches for a team (last 3 months)
async function fetchTeamMatches(teamPagename, teamName) {
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const dateStr = threeMonthsAgo.toISOString().split('T')[0];

// Fetch only FINISHED matches - these have resolved team names
// Unfinished matches often have placeholders like "#6 Seed"
const conditions = encodeURIComponent(`[[date::>${dateStr}]] AND [[finished::1]]`);

const response = await fetch(`https://api.liquipedia.net/api/v3/match?wiki=valorant&conditions=${conditions}&limit=500&order=date%20DESC`, {
headers: {
'accept': 'application/json',
'authorization': `Apikey ${process.env.LIQUIPEDIA_API_KEY}`
}
});
const data = await response.json();

if (!data || !data.result) {
return null;
}

// Filter matches to only include those with the team (exact match)
const teamMatches = data.result.filter(match => {
if (!match.match2opponents || match.match2opponents.length < 2) return false;

const team1Name = match.match2opponents[0]?.name?.toLowerCase() || '';
const team2Name = match.match2opponents[1]?.name?.toLowerCase() || '';
const searchName = teamName.toLowerCase();
const searchPagename = teamPagename.toLowerCase();

// Use exact matching to avoid false positives
return team1Name === searchName || team2Name === searchName ||
team1Name === searchPagename || team2Name === searchPagename;
});

return teamMatches;
}

// Group matches by tournament and determine wins/losses
function groupMatchesByTournament(matches, teamPagename, teamName) {
if (!matches || matches.length === 0) return null;

// Helper to check if a team name matches
const isTeamMatch = (name) => {
if (!name) return false;
const lowerName = name.toLowerCase();
return lowerName === teamPagename.toLowerCase() || lowerName === teamName.toLowerCase();
};

// Group matches by tournament
const tournaments = {};
const now = new Date();

for (const match of matches) {
// Skip matches without valid opponents
if (!match.match2opponents || match.match2opponents.length < 2) continue;

// Only include matches where the team participated
const team1Name = match.match2opponents[0]?.name || '';
const team2Name = match.match2opponents[1]?.name || '';
if (!isTeamMatch(team1Name) && !isTeamMatch(team2Name)) continue;

const tournamentName = match.tournament || match.pagename || 'Unknown Tournament';

if (!tournaments[tournamentName]) {
tournaments[tournamentName] = {
name: tournamentName,
matches: [],
latestDate: null,
hasUpcoming: false
};
}

const matchDate = match.date ? new Date(match.date) : null;

// Track if tournament has upcoming matches
if (matchDate && matchDate > now) {
tournaments[tournamentName].hasUpcoming = true;
}

// Track the latest match date for this tournament
if (matchDate && (!tournaments[tournamentName].latestDate || matchDate > tournaments[tournamentName].latestDate)) {
tournaments[tournamentName].latestDate = matchDate;
}

tournaments[tournamentName].matches.push(match);
}

// Find the most recent tournament
let selectedTournament = null;

for (const data of Object.values(tournaments)) {
if (data.matches.length > 0) {
if (!selectedTournament || data.latestDate > selectedTournament.latestDate) {
selectedTournament = data;
}
}
}

if (!selectedTournament) return null;

// Determine if tournament is "ongoing" - if last match was within the past 14 days
const twoWeeksAgo = new Date();
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
const isOngoing = selectedTournament.latestDate && selectedTournament.latestDate > twoWeeksAgo;

// Calculate wins and losses
let wins = 0;
let losses = 0;

for (const match of selectedTournament.matches) {
const team1 = match.match2opponents[0];
const team2 = match.match2opponents[1];

const isTeam1 = isTeamMatch(team1?.name);
const isTeam2 = isTeamMatch(team2?.name);

if (!isTeam1 && !isTeam2) continue;

// Check if match is finished
const isFinished = match.finished === '1' || match.finished === 1 || match.winner;

if (!isFinished) continue;

// Determine winner based on match2opponents scores or status
let teamWon = false;

if (match.winner !== undefined && match.winner !== null) {
if (isTeam1 && (match.winner === 1 || match.winner === '1')) teamWon = true;
if (isTeam2 && (match.winner === 2 || match.winner === '2')) teamWon = true;
} else {
// Compare scores from match2opponents
const score1 = parseInt(team1?.score) || 0;
const score2 = parseInt(team2?.score) || 0;

if (isTeam1 && score1 > score2) teamWon = true;
if (isTeam2 && score2 > score1) teamWon = true;
}

if (teamWon) {
wins++;
} else {
losses++;
}
}

return {
tournament: selectedTournament.name,
wins,
losses,
isOngoing,
matches: selectedTournament.matches
};
}

// Build description with tournament info
function buildDescription(tournamentName, isOngoing) {
const cleanName = formatTournamentName(tournamentName);
const tournamentLink = `[${cleanName}](https://liquipedia.net/valorant/${encodeURIComponent(tournamentName)})`;

if (isOngoing) {
return `**Current Tournament:** ${tournamentLink}`;
} else {
return `**No ongoing tournament**\nShowing results from: ${tournamentLink}`;
}
}

// Format tournament name for display
function formatTournamentName(pagename) {
if (!pagename) return 'Unknown Tournament';

// Replace underscores with spaces and clean up
return pagename
.replace(/_/g, ' ')
.replace(/\//g, ' - ');
}

// Build recent results string
function buildRecentResults(matches, teamPagename, teamName) {
if (!matches || matches.length === 0) return null;

// Helper to check if a team name matches
const isTeamMatch = (name) => {
if (!name) return false;
const lowerName = name.toLowerCase();
return lowerName === teamPagename.toLowerCase() || lowerName === teamName.toLowerCase();
};

// Sort by date descending and take up to 5
const sortedMatches = [...matches]
.filter(m => {
if (!m.match2opponents || m.match2opponents.length < 2) return false;
return m.finished === '1' || m.finished === 1 || m.winner;
})
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 5);

if (sortedMatches.length === 0) return null;

const results = sortedMatches.map(match => {
const team1 = match.match2opponents[0];
const team2 = match.match2opponents[1];

const isTeam1 = isTeamMatch(team1?.name);

const opponentName = isTeam1 ? (team2?.name || 'Unknown') : (team1?.name || 'Unknown');
const teamScore = isTeam1 ? (team1?.score ?? '?') : (team2?.score ?? '?');
const opponentScore = isTeam1 ? (team2?.score ?? '?') : (team1?.score ?? '?');

// Determine result emoji
let resultEmoji = '🔸'; // Draw/unknown
if (match.winner !== undefined && match.winner !== null) {
if ((isTeam1 && (match.winner === 1 || match.winner === '1')) ||
(!isTeam1 && (match.winner === 2 || match.winner === '2'))) {
resultEmoji = '✅';
} else {
resultEmoji = '❌';
}
} else {
// Fallback to score comparison
const score1 = parseInt(teamScore) || 0;
const score2 = parseInt(opponentScore) || 0;
if (score1 > score2) resultEmoji = '✅';
else if (score2 > score1) resultEmoji = '❌';
}

return `${resultEmoji} vs ${opponentName}: **${teamScore}** - ${opponentScore}`;
});

return results.join('\n');
}
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire file appears unrelated to the stated PR purpose of adding tier icons to the skin command. This introduces a new team_standings command for Liquipedia data, which should be in a separate PR for better code review and version control practices.

Copilot uses AI. Check for mistakes.
index.js Outdated
----------------------------------------------------- */

client.once('ready', () => {
client.once('clientReady', () => {
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change from 'ready' to 'clientReady' appears unrelated to the stated PR purpose of adding tier icons to the skin command. While 'clientReady' is the correct event name in Discord.js v14, this change should be in a separate PR for better code organization and review.

Copilot uses AI. Check for mistakes.
@RasmusDWN RasmusDWN force-pushed the add-tier-icons-to-skin-fields branch from ce2357d to 57dd180 Compare February 3, 2026 18:13
@RasmusDWN
Copy link
Copy Markdown
Owner

Åbenbart er det ikke nok at hoste Emojis på https://discord.com/developers/applications/1443951006848122992/emojis, de emojis skal også være tilgængelige på Discord Serveren som botten er på (Heraf Pley serveren). Så det skulle gerne virke med tier emojis på skins for nu, men vi skal have agent emojis på serveren først før vi bør arbejde videre med dem.

@RasmusDWN RasmusDWN force-pushed the add-tier-icons-to-skin-fields branch from 57dd180 to c25d839 Compare February 3, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants