commit 213b0d9ad486675afd3e1deab52e728fd009f1e8 Author: jitterbug Date: Sun Jun 28 10:15:43 2026 -0500 Upload files to "/" diff --git a/add-icons.js b/add-icons.js new file mode 100644 index 0000000..012397f --- /dev/null +++ b/add-icons.js @@ -0,0 +1,249 @@ +// ============================================= +// DISCORD ROLE EMOJI ADDER + ROLE ICON SETTER +// ============================================= +// !addicons — fetches skin icons and uploads as server emojis +// !setroleicons — applies uploaded emojis as role icons +// !removeoldemojis — removes all skin emojis so you can re-add fresh +// +// Run with: node "add-icons.js" +// ============================================= + +require('dotenv').config(); +const { Client, GatewayIntentBits, PermissionsBitField } = require('discord.js'); +const sharp = require('sharp'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent + ] +}); + +// Direct cosmetic IDs — all verified to return valid icons from fortnite-api.com +// overrideUrl = use a specific variant image instead of the default icon +const roleIconMap = [ + { name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' }, + { name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' }, + { name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' }, + { name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' }, + { name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' }, + { name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' }, + { name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' }, + { name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' }, + { name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' }, + { name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' }, + { name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' }, + { name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' }, + { name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' }, + { name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' }, + { name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' }, + { name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' }, + { name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' }, + { name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' }, + { name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' }, + { name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' }, + { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, + { name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' }, + { name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' }, + { name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' }, + { name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' }, + { name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' }, + { name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' }, + { name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' }, + { name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' }, + { name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' }, +]; + +// ── fetch with timeout so nothing ever hangs ────────────────────────────────── +async function fetchWithTimeout(url, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + return await fetch(url, { signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } +} + +// ── get icon URL from fortnite-api.com by direct ID ─────────────────────────── +async function fetchIconUrl(cosmeticId) { + const url = `https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`; + const res = await fetchWithTimeout(url); + if (!res.ok) throw new Error(`API returned ${res.status} for ${cosmeticId}`); + const json = await res.json(); + if (!json.data?.images?.icon) throw new Error(`No icon in response for ${cosmeticId}`); + return json.data.images.icon; +} + +// ── download image and upscale to 256x256 for crisp Discord emoji ───────────── +async function downloadAndProcess(imageUrl) { + const res = await fetchWithTimeout(imageUrl, 15000); + if (!res.ok) throw new Error(`Image download failed: ${res.status}`); + const raw = Buffer.from(await res.arrayBuffer()); + // Upscale to 256x256 with Lanczos resampling + sharpening + return await sharp(raw) + .resize(256, 256, { fit: 'cover', position: 'centre', kernel: 'lanczos3' }) + .sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + +// ── create emoji with automatic rate-limit retry ────────────────────────────── +async function createEmojiSafe(guild, buffer, name, reason) { + for (let attempt = 1; attempt <= 6; attempt++) { + try { + return await guild.emojis.create({ attachment: buffer, name, reason }); + } catch (err) { + const isRateLimit = err.status === 429 || err.httpStatus === 429 || + (err.message || '').toLowerCase().includes('rate limit'); + if (isRateLimit) { + const wait = ((err.retryAfter || 5) * 1000) + 1000; + console.warn(` ⏳ Rate limited on "${name}", waiting ${wait}ms (attempt ${attempt}/6)...`); + await new Promise(r => setTimeout(r, wait)); + } else { + throw err; + } + } + } + throw new Error(`Gave up creating emoji "${name}" after 6 attempts`); +} + +// ───────────────────────────────────────────────────────────────────────────── +client.once('ready', () => { + console.log(`✅ Logged in as ${client.user.tag}`); + console.log('Commands: !addicons | !setroleicons | !removeoldemojis'); +}); + +// ── !removeoldemojis ────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!removeoldemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply('Removing old skin emojis...'); + const names = new Set(roleIconMap.map(e => e.emoji.toLowerCase())); + let removed = 0; + + // Fetch fresh emoji list + await message.guild.emojis.fetch(); + + for (const emoji of message.guild.emojis.cache.values()) { + if (!names.has(emoji.name.toLowerCase())) continue; + try { + await emoji.delete('Replacing with corrected icons'); + console.log(` 🗑 Deleted: ${emoji.name}`); + removed++; + await new Promise(r => setTimeout(r, 400)); + } catch (err) { + console.error(` ❌ Failed to delete ${emoji.name}: ${err.message}`); + } + } + + message.channel.send(`Removed **${removed}** emojis. Now run \`!addicons\`.`); +}); + +// ── !addicons ───────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!addicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply(`Starting — uploading ${roleIconMap.length} skin icons. This takes a few minutes, watch the terminal for progress.`); + + // Always fetch a fresh copy of existing emojis + await message.guild.emojis.fetch(); + const existingNames = new Set( + message.guild.emojis.cache.map(e => e.name.toLowerCase()) + ); + + let added = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + if (existingNames.has(entry.emoji.toLowerCase())) { + console.log(` ⏭ Already exists: ${entry.emoji}`); + skipped++; + continue; + } + + process.stdout.write(` 📥 ${entry.name} — fetching... `); + try { + const iconUrl = entry.overrideUrl || await fetchIconUrl(entry.id); + process.stdout.write(`downloading... `); + const buffer = await downloadAndProcess(iconUrl); + process.stdout.write(`uploading... `); + await createEmojiSafe(message.guild, buffer, entry.emoji, `Skin icon: ${entry.name}`); + console.log(`✅`); + added++; + } catch (err) { + console.log(`❌ ${err.message}`); + failed++; + } + + // 2s gap between uploads to stay under Discord rate limits + await new Promise(r => setTimeout(r, 2000)); + } + + message.channel.send( + `Done! ✅ Added **${added}** emojis. ` + + `⏭ Skipped **${skipped}** (already existed). ` + + `❌ **${failed}** failed.` + ); +}); + +// ── !setroleicons ───────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setroleicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply('Applying emojis to roles...'); + + // Refresh both caches + await message.guild.emojis.fetch(); + await message.guild.roles.fetch(); + + let updated = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + const role = message.guild.roles.cache.find( + r => r.name.toLowerCase() === entry.name.toLowerCase() + ); + if (!role) { + console.log(` ⏭ Role not found: ${entry.name}`); + skipped++; + continue; + } + + const emoji = message.guild.emojis.cache.find( + e => e.name.toLowerCase() === entry.emoji.toLowerCase() + ); + if (!emoji) { + console.log(` ⚠ Emoji not found for: ${entry.name} — run !addicons first`); + skipped++; + continue; + } + + try { + await role.setIcon(emoji); + console.log(` ✅ ${entry.name} → :${entry.emoji}:`); + updated++; + } catch (err) { + console.error(` ❌ Failed "${entry.name}": ${err.message}`); + failed++; + } + + await new Promise(r => setTimeout(r, 500)); + } + + message.channel.send( + `Done! ✅ Set icons on **${updated}** roles. ` + + `⏭ Skipped **${skipped}**. ❌ **${failed}** failed.` + ); +}); + +client.login(process.env.TOKEN); diff --git a/bot.backup-codex-20260628-015851.js b/bot.backup-codex-20260628-015851.js new file mode 100644 index 0000000..862622b --- /dev/null +++ b/bot.backup-codex-20260628-015851.js @@ -0,0 +1,1819 @@ +// ============================================= +// FORTNITE DISCORD BOT — ALL-IN-ONE +// ============================================= +// Commands: +// !setuproles — create all roles +// !addicons — upload skin icons as server emojis +// !setroleicons — apply emojis to roles +// !removeoldemojis — delete old skin emojis +// !verify — verify Fortnite account & auto-assign roles +// +// Run with: node bot.js +// ============================================= + +require('dotenv').config(); +const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder, REST, Routes, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js'); +const sharp = require('sharp'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: ['CHANNEL'], +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ROLES TO CREATE +// ───────────────────────────────────────────────────────────────────────────── +const roles = [ + { name: 'Fully Golden Peely', color: 0xFFD700 }, + { name: 'OG Aerial Assault Trooper', color: 0x8A8A8A }, + { name: 'OG Ghoul Trooper', color: 0xA89DB8 }, + { name: 'OG Renegade Raider', color: 0x7B2D2D }, + { name: 'OG Skull Trooper', color: 0x9B59B6 }, + { name: 'Barricade', color: 0x9E9E9E }, + { name: 'Double Helix', color: 0xD0443C }, + { name: 'Honor Guard', color: 0x4A90D9 }, + { name: 'Wonder', color: 0x2ECC71 }, + { name: 'Surf Strider', color: 0x1ABC9C }, + { name: 'Stealth Reflex', color: 0xE8927C }, + { name: 'Dark Vertex', color: 0x8E44AD }, + { name: 'Royale Bomber', color: 0xE67E22 }, + { name: 'Wildcat', color: 0xE8832A }, + { name: 'Clyde', color: 0xE8832A }, + { name: 'Eon', color: 0x27AE60 }, + { name: 'Huntmaster Saber', color: 0xD4AC0D }, + { name: 'Dark Skully', color: 0x7D6B8A }, + { name: 'Galaxy', color: 0x5B7DD8 }, + { name: 'Ikonik', color: 0xD0443C }, + { name: 'Black Knight', color: 0xCCCCCC }, + { name: 'Glow', color: 0xBB8FCC }, + { name: 'Rogue Spider Knight', color: 0x2ECC71 }, + { name: 'Thrilldiver', color: 0x4A90D9 }, + { name: 'Freediver', color: 0x9E9E9E }, + { name: 'Neo Versa', color: 0x9B59B6 }, + { name: 'Cobalt Snowfoot', color: 0x1ABC9C }, + { name: 'Florin', color: 0x8E44AD }, + { name: "What's Wrong, Toona Fish?", color: 0xCCCCCC }, + { name: 'Cameo Needs Loot', color: 0x2ECC71 }, + { name: 'Victory!', color: 0x9B59B6 }, + { name: 'Collegiate items', color: 0x2ECC71 }, + { name: 'StreamElements', color: 0xF1C40F }, + { name: 'Llamas', color: 0xFF69B4 }, + { name: 'Repaz', color: 0xD4AC0D }, + { name: 'lollek', color: 0xFFFFFF }, + { name: 'Celebration Heart', color: 0x9B59B6 }, + { name: 'Funflower', color: 0xD4C227 }, + { name: 'Level 100', color: 0xBB44EE }, + { name: 'Alpha Banner', color: 0x777777 }, + { name: 'YouTube Banner', color: 0x777777 }, + { name: 'STW Founders', color: 0xE67E22 }, + { name: 'Ultimate STW Founders', color: 0x888888 }, + { name: 'Rare Usernames', color: 0xF1C40F }, + { name: 'Skin Collector', color: 0x888888 }, + { name: 'PS+ Pack Collector', color: 0x3498DB }, + { name: 'Spray Collector', color: 0xCCCCCC }, + { name: 'Rare Banners', color: 0x888888 }, + { name: 'Emote Collector', color: 0xCCCCCC }, + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", color: 0x7B2D2D }, + { name: 'Axe of Champions', color: 0xF1C40F }, + { name: 'Blade of Champions', color: 0xF1C40F }, + { name: 'Axecalibur', color: 0xCCCCCC }, + { name: 'EVA', color: 0x4A90D9 }, + { name: 'AC/DC', color: 0xF1C40F }, + { name: 'Resonator', color: 0x27AE60 }, + { name: 'Pinpoint', color: 0xD0443C }, + { name: 'Stellar Axe', color: 0x5B7DD8 }, + { name: 'Stealth Angular Axe', color: 0x9B59B6 }, + { name: 'Dark Razor', color: 0x7D6B8A }, + { name: 'Electri-claw', color: 0xE8832A }, + { name: 'Dark Splitter', color: 0x8E44AD }, + { name: 'Merry Mint Axe', color: 0x2ECC71 }, + { name: 'Indigo Inverter', color: 0x1ABC9C }, + { name: 'Rose Glow', color: 0xFF69B4 }, + { name: 'Controller', color: 0x3498DB }, + { name: 'Pneumatic Twin', color: 0x4A90D9 }, + { name: 'Perfect Point', color: 0x9E9E9E }, + { name: 'Petrol Pump', color: 0xE67E22 }, + { name: 'Future Relic Scythe', color: 0x8E44AD }, + // --- Exclusive Emoticons --- + { name: 'Skull Trooper Emoticon', color: 0x9B59B6 }, + { name: 'Maevys Emoticon', color: 0xD0443C }, + { name: 'ZetFar Emoticon', color: 0x4A90D9 }, + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', color: 0x3498DB }, // PS Plus Pack 1 + { name: 'Blue Striker', color: 0x3498DB }, // PS Plus Pack 2 + { name: 'Prodigy', color: 0x3498DB }, // PS Plus Pack 4 + { name: 'Carbon Commando', color: 0x3498DB }, // PS Plus Pack 5 + { name: 'Trilogy', color: 0x3498DB }, // PS Plus Pack 8 + { name: 'Point Patroller', color: 0x3498DB }, // PS Plus Pack 9 + { name: 'Cloud Striker', color: 0x3498DB }, // PS Plus Pack 12 + { name: 'Fixer', color: 0x3498DB }, // PS Plus Pack 14 + { name: 'Blizzard Bomber', color: 0x3498DB }, // PS Plus Pack 18 + { name: 'Jun-Hwan', color: 0x3498DB }, // Blue Phoenix Pack + { name: 'Lina Scorch', color: 0x3498DB }, // Blazin Wheels Pack + { name: 'Indigo Kuno', color: 0x9B59B6 }, // PS Generations Cup + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Havoc', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Trailblazer', color: 0x9146FF }, // Twitch Prime Pack 2 + // --- Intel / PC Exclusive --- + { name: 'Reflex', color: 0x0071C5 }, // Intel bundle + // --- OG Battle Pass (C1S2) --- + { name: 'Sparkle Specialist', color: 0xFF69B4 }, + { name: 'Royale Knight', color: 0xCCCCCC }, + { name: 'Blue Squire', color: 0x4A90D9 }, + // --- OG Battle Pass (C1S3) --- + { name: 'The Reaper', color: 0x888888 }, + { name: 'Elite Agent', color: 0x888888 }, + { name: 'Rogue Agent', color: 0x888888 }, + // --- OG Battle Pass (C1S4) --- + { name: 'Omega', color: 0x8E44AD }, + // --- Chapter 2 Season 2 Battle Pass (golden styles) --- + { name: 'Skye', color: 0xF1C40F }, + { name: 'TNTina', color: 0xF1C40F }, + { name: 'Meowscles', color: 0xF1C40F }, + { name: 'Midas', color: 0xFFD700 }, + // --- Gaming Legends / Collab Exclusives --- + { name: 'Travis Scott', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Astro Jack', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Kratos', color: 0xCCCCCC }, // PlayStation collab + { name: 'Master Chief', color: 0x4A90D9 }, // Xbox collab + // --- World Cup / Tournament --- + { name: 'World Warrior', color: 0xF1C40F }, // Fortnite World Cup 2019 + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', color: 0xFF69B4 }, + // --- Save the World Founders --- + { name: 'Rose Team Leader', color: 0xE67E22 }, + { name: 'Warpaint', color: 0xE67E22 }, + // --- OT Banners --- + { name: 'OT1 Banner', color: 0xFFD700 }, + { name: 'OT2 Banner', color: 0xFFD700 }, + { name: 'OT3 Banner', color: 0xFFD700 }, + { name: 'OT4 Banner', color: 0xFFD700 }, + { name: 'OT5 Banner', color: 0xFFD700 }, + { name: 'OT6 Banner', color: 0xFFD700 }, + { name: 'OT7 Banner', color: 0xFFD700 }, + { name: 'OT8 Banner', color: 0xFFD700 }, + { name: 'OT9 Banner', color: 0xFFD700 }, + { name: 'OT10 Banner', color: 0xFFD700 }, + { name: 'OT11 Banner', color: 0xFFD700 }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// EMOJI / ICON MAP +// ───────────────────────────────────────────────────────────────────────────── +const roleIconMap = [ + { name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' }, + { name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' }, + { name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' }, + { name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' }, + { name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' }, + { name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' }, + { name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' }, + { name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' }, + { name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' }, + { name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' }, + { name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' }, + { name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' }, + { name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' }, + { name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' }, + { name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' }, + { name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' }, + { name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' }, + { name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' }, + { name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' }, + { name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' }, + // { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, // temporarily disabled — upload stalls + { name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' }, + { name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' }, + { name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' }, + { name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' }, + { name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' }, + { name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' }, + { name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' }, + { name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' }, + { name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' }, + + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", id: 'Pickaxe_Lockjaw', emoji: 'RaidersRevenge' }, + { name: 'Axe of Champions', id: 'Pickaxe_ID_376_FNCS', emoji: 'AxeOfChampions' }, + { name: 'Blade of Champions', id: 'Pickaxe_FNCS_S32', emoji: 'BladeOfChampions' }, + { name: 'Axecalibur', id: 'Pickaxe_ID_011_Medieval', emoji: 'Axecalibur' }, + { name: 'EVA', id: 'Pickaxe_ID_028_Space', emoji: 'EVA' }, + { name: 'AC/DC', id: 'Pickaxe_ID_013_Teslacoil', emoji: 'ACDC' }, + { name: 'Resonator', id: 'Pickaxe_ID_077_CarbideWhite', emoji: 'Resonator' }, + { name: 'Pinpoint', id: 'Pickaxe_ID_099_ModernMilitaryRed', emoji: 'Pinpoint' }, + { name: 'Stellar Axe', id: 'Pickaxe_ID_116_Celestial', emoji: 'StellarAxe' }, + { name: 'Stealth Angular Axe', id: 'Pickaxe_ID_189_StreetOpsStealth', emoji: 'StealthAngularAxe' }, + { name: 'Dark Razor', id: 'Pickaxe_ID_178_SpeedyMidnight', emoji: 'DarkRazor' }, + { name: 'Electri-claw', id: 'Pickaxe_ID_398_WildCatFemale', emoji: 'Electriclaw' }, + { name: 'Dark Splitter', id: 'Pickaxe_ID_461_SkullBriteCube', emoji: 'DarkSplitter' }, + { name: 'Merry Mint Axe', id: 'Pickaxe_ID_294_CandyCane', emoji: 'MerryMintAxe' }, + { name: 'Indigo Inverter', id: 'Pickaxe_SnowNinjaDark', emoji: 'IndigoInverter' }, + { name: 'Rose Glow', id: 'Pickaxe_ID_153_RoseLeader', emoji: 'RoseGlow' }, + { name: 'Controller', id: 'Pickaxe_ID_088_PSBurnout', emoji: 'Controller' }, + { name: 'Pneumatic Twin', id: 'Pickaxe_ID_256_TechOpsBlue', emoji: 'PneumaticTwin' }, + { name: 'Perfect Point', id: 'Pickaxe_ID_464_LongShortsMale', emoji: 'PerfectPoint' }, + { name: 'Petrol Pump', id: 'Pickaxe_RetroWheels', emoji: 'PetrolPump' }, + { name: 'Future Relic Scythe', id: 'Pickaxe_SnowSoldierFashion', emoji: 'FutureRelicScythe' }, + + // --- OT Banners (OT1–OT11) --- + { name: 'OT1 Banner', id: 'OT1Banner', emoji: 'OT1Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot1banner/icon.png' }, + { name: 'OT2 Banner', id: 'OT2Banner', emoji: 'OT2Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot2banner/icon.png' }, + { name: 'OT3 Banner', id: 'OT3Banner', emoji: 'OT3Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot3banner/icon.png' }, + { name: 'OT4 Banner', id: 'OT4Banner', emoji: 'OT4Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot4banner/icon.png' }, + { name: 'OT5 Banner', id: 'OT5Banner', emoji: 'OT5Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot5banner/icon.png' }, + { name: 'OT6 Banner', id: 'OT6Banner', emoji: 'OT6Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot6banner/icon.png' }, + { name: 'OT7 Banner', id: 'OT7Banner', emoji: 'OT7Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot7banner/icon.png' }, + { name: 'OT8 Banner', id: 'OT8Banner', emoji: 'OT8Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot8banner/icon.png' }, + { name: 'OT9 Banner', id: 'OT9Banner', emoji: 'OT9Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot9banner/icon.png' }, + { name: 'OT10 Banner', id: 'OT10Banner', emoji: 'OT10Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot10banner/icon.png' }, + { name: 'OT11 Banner', id: 'OT11Banner', emoji: 'OT11Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot11banner/icon.png' }, + + // --- Exclusive Emoticons (in-game emoji cosmetics) --- + { name: 'Skull Trooper Emoticon', id: 'Emoji_Skulltrooper', emoji: 'EmoticonSkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_skulltrooper/smallicon.png' }, + { name: 'Maevys Emoticon', id: 'Emoji_S22_Showdown_Mae', emoji: 'EmoticonMaevys', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_mae/smallicon.png' }, + { name: 'ZetFar Emoticon', id: 'Emoji_S22_Showdown_Zet', emoji: 'EmoticonZetFar', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_zet/smallicon.png' }, + + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', id: 'CID_052_Athena_Commando_F_PSBlue', emoji: 'BlueTeamLeader' }, + { name: 'Blue Striker', id: 'CID_138_Athena_Commando_M_PSBurnout', emoji: 'BlueStriker' }, + { name: 'Prodigy', id: 'CID_296_Athena_Commando_M_Math', emoji: 'Prodigy' }, + { name: 'Carbon Commando', id: 'CID_360_Athena_Commando_M_TechOpsBlue', emoji: 'CarbonCommando' }, + { name: 'Trilogy', id: 'CID_657_Athena_Commando_F_TechOpsBlue', emoji: 'Trilogy' }, + { name: 'Point Patroller', id: 'CID_711_Athena_Commando_M_LongShorts', emoji: 'PointPatroller' }, + { name: 'Cloud Striker', id: 'CID_926_Athena_Commando_F_StreetFashionDiamond', emoji: 'CloudStriker' }, + { name: 'Fixer', id: 'CID_A_101_Athena_Commando_M_TacticalWoodlandBlue', emoji: 'Fixer' }, + { name: 'Blizzard Bomber', id: 'CID_A_421_Athena_Commando_F_BlizzardBomber', emoji: 'BlizzardBomber' }, + { name: 'Jun-Hwan', id: 'Character_BlueJet', emoji: 'JunHwan' }, + { name: 'Lina Scorch', id: 'Character_RetroWheels', emoji: 'LinaScorch' }, + { name: 'Indigo Kuno', id: 'CID_660_Athena_Commando_F_BandageNinjaBlue', emoji: 'IndigoKuno' }, + + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', id: 'CID_085_Athena_Commando_M_Twitch', emoji: 'SubCommander' }, + { name: 'Havoc', id: 'CID_089_Athena_Commando_M_RetroGrey', emoji: 'Havoc' }, + { name: 'Trailblazer', id: 'CID_114_Athena_Commando_F_TacticalWoodland', emoji: 'Trailblazer' }, + + // --- Intel / PC Exclusive --- + { name: 'Reflex', id: 'CID_259_Athena_Commando_M_StreetOps', emoji: 'Reflex' }, + + // --- OG Battle Pass --- + { name: 'Sparkle Specialist', id: 'CID_039_Athena_Commando_F_Disco', emoji: 'SparkleSpecialist' }, + { name: 'Royale Knight', id: 'CID_033_Athena_Commando_F_Medieval', emoji: 'RoyaleKnight' }, + { name: 'Blue Squire', id: 'CID_032_Athena_Commando_M_Medieval', emoji: 'BlueSquire' }, + { name: 'The Reaper', id: 'CID_084_Athena_Commando_M_Assassin', emoji: 'TheReaper' }, + { name: 'Elite Agent', id: 'CID_083_Athena_Commando_F_Tactical', emoji: 'EliteAgent' }, + { name: 'Rogue Agent', id: 'CID_090_Athena_Commando_M_Tactical', emoji: 'RogueAgent' }, + { name: 'Omega', id: 'CID_116_Athena_Commando_M_CarbideBlack', emoji: 'Omega' }, + + // --- Ch2S2 Battle Pass gold styles --- + { name: 'Skye', id: 'CID_690_Athena_Commando_F_Photographer', emoji: 'Skye' }, + { name: 'TNTina', id: 'CID_691_Athena_Commando_F_TNTina', emoji: 'TNTina' }, + { name: 'Meowscles', id: 'CID_693_Athena_Commando_M_BuffCat', emoji: 'Meowscles' }, + { name: 'Midas', id: 'CID_694_Athena_Commando_M_CatBurglar', emoji: 'Midas' }, + + // --- Gaming Legends / Collab --- + { name: 'Travis Scott', id: 'CID_703_Athena_Commando_M_Cyclone', emoji: 'TravisScott' }, + { name: 'Astro Jack', id: 'CID_761_Athena_Commando_M_CycloneSpace', emoji: 'AstroJack' }, + { name: 'Kratos', id: 'CID_964_Athena_Commando_M_Historian_869BC', emoji: 'Kratos' }, + { name: 'Master Chief', id: 'CID_971_Athena_Commando_M_Jupiter_S0Z6M', emoji: 'MasterChief' }, + + // --- World Cup / Tournament --- + { name: 'World Warrior', id: 'CID_478_Athena_Commando_F_WorldCup', emoji: 'WorldWarrior' }, + + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', id: 'Character_TubaGummi', emoji: 'ColorSplashJellie' }, + + // --- Save the World Founders --- + { name: 'Rose Team Leader', id: 'CID_096_Athena_Commando_F_Founder', emoji: 'RoseTeamLeader' }, + { name: 'Warpaint', id: 'CID_095_Athena_Commando_M_Founder', emoji: 'Warpaint' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// VERIFY ROLE REQUIREMENTS +// ───────────────────────────────────────────────────────────────────────────── +const ROLE_REQUIREMENTS = [ + // OG Season 1 skins (style required) + { role: 'OG Renegade Raider', id: 'cid_028_athena_commando_f', styleTag: 'Mat3' }, + { role: 'OG Aerial Assault Trooper', id: 'cid_017_athena_commando_m', styleTag: 'Stage2' }, + { role: 'OG Ghoul Trooper', id: 'cid_029_athena_commando_f_halloween', styleTag: 'Mat3' }, + { role: 'OG Skull Trooper', id: 'cid_030_athena_commando_m_halloween', styleTag: 'Mat1' }, + // OG Battle Pass skins + { role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' }, + { role: 'Blue Squire', id: 'cid_032_athena_commando_m_medieval' }, + { role: 'Royale Knight', id: 'cid_033_athena_commando_f_medieval' }, + { role: 'Sparkle Specialist', id: 'cid_039_athena_commando_f_disco' }, + { role: 'The Reaper', id: 'cid_084_athena_commando_m_assassin' }, + { role: 'Elite Agent', id: 'cid_083_athena_commando_f_tactical' }, + { role: 'Omega', id: 'cid_116_athena_commando_m_carbideblack', styleTag: 'Stage5' }, + { role: 'Carbide', id: 'cid_115_athena_commando_m_carbideblue', styleTag: 'Stage5' }, + // Samsung bundles + { role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' }, + { role: 'Stellar Axe', id: 'pickaxe_id_116_celestial' }, + { role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' }, + { role: 'Scenario Emote', id: 'eid_kpopdance03' }, + { role: 'Glow', id: 'cid_479_athena_commando_f_davinci' }, + { role: 'Levitate Emote', id: 'eid_davinci' }, + // Device / Platform exclusives + { role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' }, + { role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' }, + { role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' }, + { role: 'Resonator', id: 'pickaxe_id_077_carbidewhite' }, + { role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' }, + { role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' }, + { role: 'Pinpoint', id: 'pickaxe_id_099_modernmilitaryred' }, + { role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' }, + { role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' }, + { role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' }, + { role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' }, + { role: 'Reflex', id: 'cid_259_athena_commando_m_streetops' }, + { role: 'Angular Axe', id: 'pickaxe_id_118_streetops' }, + { role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' }, + { role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' }, + { role: 'Huntmaster Saber', id: 'character_masterkeyorder' }, + // PlayStation Plus exclusives + { role: 'Blue Team Leader', id: 'cid_052_athena_commando_f_psblue' }, + { role: 'Blue Striker', id: 'cid_138_athena_commando_m_psburnout' }, + { role: 'Carbon Commando', id: 'cid_360_athena_commando_m_techopsblue' }, + { role: 'Prodigy', id: 'cid_296_athena_commando_m_math' }, + { role: 'Cloud Striker', id: 'cid_926_athena_commando_f_streetfashiondiamond' }, + { role: 'Fixer', id: 'cid_a_101_athena_commando_m_tacticalwoodlandblue' }, + { role: 'Point Patroller', id: 'cid_711_athena_commando_m_longshorts' }, + // Twitch Prime exclusives + { role: 'Sub Commander', id: 'cid_085_athena_commando_m_twitch' }, + { role: 'Havoc', id: 'cid_089_athena_commando_m_retrogrey' }, + { role: 'Trailblazer', id: 'cid_114_athena_commando_f_tacticalwoodland' }, + // Exclusive Pickaxes + { role: "Raider's Revenge", id: 'pickaxe_lockjaw' }, + { role: 'AC/DC', id: 'pickaxe_id_013_teslacoil' }, + { role: 'Merry Mint Axe', id: 'pickaxe_id_294_candycane' }, + { role: 'Tenderizer', id: 'pickaxe_id_044_tacticalurbanhammer' }, + { role: 'Axe of Champions', id: 'pickaxe_id_376_fncs' }, + { role: 'Blade of Champions', id: 'pickaxe_fncs_s32' }, + // OG / Exclusive Emotes + { role: 'Fresh Emote', id: 'eid_fresh' }, + { role: 'Rambunctious Emote', id: 'eid_zippy' }, + { role: 'Tidy Emote', id: 'eid_tidy' }, + { role: 'Floss Emote', id: 'eid_floss' }, + { role: 'Take the L Emote', id: 'eid_takethelathena' }, + { role: 'Orange Justice Emote', id: 'eid_goodvibes' }, + { role: 'Ride the Pony Emote', id: 'eid_ridethepony_athena' }, + { role: 'Pony Up Emote', id: 'eid_ridetheponymultiple' }, + { role: 'Evasive Maneuvers Emote', id: 'eid_temple' }, + // Exclusive Emoticons + { role: 'Cameo Needs Loot', id: 'emoji_s20_needloot' }, + // Founder exclusives + { role: 'Rose Team Leader', id: 'cid_096_athena_commando_f_founder' }, + { role: 'Warpaint', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Fully Golden Peely', id: 'cid_701_athena_commando_m_bananaagent', styleTag: 'Stage4' }, + // Samsung Galaxy bundle items + { role: 'Galactic Disc', id: 'bid_138_celestial' }, + { role: 'Discovery Glider', id: 'glider_id_090_celestial' }, + // IKONIK bundle - Scenario already added above + // Glow bundle - Levitate already added above + // EON bundle + { role: 'Aurora Glider', id: 'glider_id_056_carbidewhite' }, + // Dark Vertex bundle + { role: 'Dark Deflector', id: 'bid_234_speedymidnight' }, + { role: 'Razor Edge', id: 'pickaxe_id_065_speedyred' }, + { role: 'Dark Forerunner Glider', id: 'glider_id_131_speedymidnight' }, + // Double Helix bundle + { role: 'Telemetry', id: 'bid_114_modernmilitaryred' }, + { role: 'Rotor Glider', id: 'glider_id_075_modernmilitaryred' }, + // Neo Versa bundle + { role: 'Neo Phrenzy', id: 'bid_288_cyberscavengerfemaleblue' }, + // Royale Bomber bundle + { role: 'Royale Air Glider', id: 'glider_id_105_snowboard' }, + // Reflex bundle + { role: 'Response Unit', id: 'bid_140_streetopsmale' }, + { role: 'Pivot Glider', id: 'glider_id_092_streetops' }, + // Wildcat bundle + { role: 'Sleek Strike', id: 'bid_521_wildcat' }, + // Rogue Spider Knight bundle + { role: 'Time Keeper', id: 'bid_395_phantom' }, + // Stealth Reflex bundle + { role: 'Stealth Response Unit', id: 'bid_249_streetopsstealth' }, + // Aerial Assault One glider (S1 exclusive) + { role: 'Aerial Assault One', id: 'glider_id_001' }, + // Extra exclusive pickaxes from master list + { role: 'Trusty No. 2', id: 'pickaxe_id_029_assassin' }, + { role: 'Party Animal', id: 'pickaxe_id_020_keg' }, + { role: 'Crowbar', id: 'pickaxe_id_092_bling' }, + { role: 'Instigator', id: 'pickaxe_id_039_tacticalblack' }, + // Missing emotes from master list + { role: "Widow's Pirouette Emote", id: 'eid_ashtonboardwalk' }, + { role: 'Hot Marat Emote', id: 'eid_wir' }, + { role: 'Kiss the Cup Emote', id: 'eid_trophycelebration' }, + // Missing skins from master list + { role: 'Gildedguy', id: 'cid_a_141_athena_commando_m_dojo' }, + { role: 'Tart Tycoon', id: 'cid_851_athena_commando_m_bittenhead' }, + // Gliders + { role: 'Mako Glider', id: 'glider_warthog' }, + // Rare / STW / Creator banners (checked via homebase_banners in common_core) + { role: 'Alpha Tester Banner', bannerId: 'OtherBanner51' }, + { role: 'World Cup Banner', bannerId: 'BRWorldCup2019' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner1' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner2' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner3' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner4' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner5' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner6' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner7' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner8' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner9' }, + // STW Founder banners + { role: 'STW Founder Banner', bannerId: 'FounderTier5Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier4Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier3Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier2Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier1Banner1' }, + // STW Endurance banners + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater04' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater03' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater02' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater01' }, + // STW Survival / Wargames banners + { role: 'STW Event Banner', bannerId: 'SurvivalBannerStorm2018Hard' }, + { role: 'STW Event Banner', bannerId: 'SurvivalBannerHolidayHard' }, + { role: 'STW Event Banner', bannerId: 'WargamesTprifts' }, + { role: 'STW Event Banner', bannerId: 'WargamesTiki' }, + { role: 'STW Event Banner', bannerId: 'WargamesPods' }, + // STW Achievement banners + { role: 'STW Achievement Banner', bannerId: 'AchievementUnspeakableHorrors' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementWorldExplorer' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGuardianAngel' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementPlaysWellWithOthers' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementTalentedBuilder' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGoGnome' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementLootLegend' }, + // FNCS / Skirmish / World Cup banners + { role: 'FNCS Banner', bannerId: 'BRS23_FNCS_Drops' }, + { role: 'FNCS Banner', bannerId: 'S11FNCS' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishBushBandits' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishDustyDogs' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishFortKnights' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishLuckyLlamas' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishRiftRaiders' }, + // Season Level 100 banners + { role: 'Season Level 100 Banner', bannerId: 'BRS10Level100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS9Lvl100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS8Level100' }, + // Creator / Influencer banners 10-58 + { role: 'Creator Banner', bannerId: 'InfluencerBanner10' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner11' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner12' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner13' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner14' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner15' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner16' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner17' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner18' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner19' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner20' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner21' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner22' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner23' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner24' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner25' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner26' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner27' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner28' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner29' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner30' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner31' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner32' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner33' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner34' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner35' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner36' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner37' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner38' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner39' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner40' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner41' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner42' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner43' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner44' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner45' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner46' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner47' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner48' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner49' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner50' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner51' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner52' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner53' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner54' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner55' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner56' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner57' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner58' }, + // OT Banners + { role: 'OT1 Banner', bannerId: 'OT1Banner' }, + { role: 'OT2 Banner', bannerId: 'OT2Banner' }, + { role: 'OT3 Banner', bannerId: 'OT3Banner' }, + { role: 'OT4 Banner', bannerId: 'OT4Banner' }, + { role: 'OT5 Banner', bannerId: 'OT5Banner' }, + { role: 'OT6 Banner', bannerId: 'OT6Banner' }, + { role: 'OT7 Banner', bannerId: 'OT7Banner' }, + { role: 'OT8 Banner', bannerId: 'OT8Banner' }, + { role: 'OT9 Banner', bannerId: 'OT9Banner' }, + { role: 'OT10 Banner', bannerId: 'OT10Banner' }, + { role: 'OT11 Banner', bannerId: 'OT11Banner' }, + // STW Founders + { role: 'STW Founders', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Ultimate STW Founders', id: 'tiertoken_collectionbook_ultimate', stw: true }, +] + +// ───────────────────────────────────────────────────────────────────────────── +// EPIC OAUTH CONSTANTS +// ───────────────────────────────────────────────────────────────────────────── +const SWITCH_TOKEN = 'OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDAxYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3'; +const IOS_TOKEN = 'M2Y2OWU1NmM3NjQ5NDkyYzhjYzI5ZjFhZjA4YThhMTI6YjUxZWU5Y2IxMjIzNGY1MGE2OWVmYTY3ZWY1MzgxMmU='; +const EPIC_OAUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; +const EPIC_OAUTH_PROD03 = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token'; +const EPIC_EXCHANGE = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange'; +const EPIC_DEVICE_AUTH = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/deviceAuthorization'; + +const pending = new Map(); +const verifiedUsers = new Map(); // userId -> { guildId, assignedRoles: string[], epicName: string } +const LOG_CHANNEL_ID = '1517695849952706642'; + +// ───────────────────────────────────────────────────────────────────────────── +// SHARED HELPERS +// ───────────────────────────────────────────────────────────────────────────── +async function fetchWithTimeout(url, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { return await fetch(url, { signal: ctrl.signal }); } + finally { clearTimeout(timer); } +} + +// ─── get icon URL — build directly from known CDN pattern, no API roundtrip ── +async function fetchIconUrl(cosmeticId) { + // Direct CDN URL — fortnite-api.com images are always at this pattern + const url = `https://fortnite-api.com/images/cosmetics/br/${cosmeticId.toLowerCase()}/icon.png`; + const res = await fetchWithTimeout(url, 8000); + if (res.ok) return url; + // Fallback to API lookup if direct URL 404s + const apiRes = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`, 8000); + if (!apiRes.ok) throw new Error(`API ${apiRes.status} for ${cosmeticId}`); + const json = await apiRes.json(); + if (!json.data?.images?.icon) throw new Error(`No icon for ${cosmeticId}`); + return json.data.images.icon; +} + +async function downloadAndProcess(imageUrl) { + const res = await fetchWithTimeout(imageUrl, 15000); + if (!res.ok) throw new Error(`Download failed ${res.status}`); + const raw = Buffer.from(await res.arrayBuffer()); + return sharp(raw) + .resize(128, 128, { fit: 'cover', position: 'centre', kernel: 'lanczos3' }) + .sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + +async function createEmojiSafe(guild, buffer, name, reason) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { + return await guild.emojis.create({ attachment: buffer, name, reason }); + } catch (err) { + const isRL = err.status === 429 || err.httpStatus === 429 || + (err.message || '').toLowerCase().includes('rate limit'); + if (isRL) { + const wait = ((err.retryAfter || 5) * 1000) + 1000; + console.warn(` ⏳ Rate limited "${name}", waiting ${Math.ceil(wait/1000)}s...`); + await new Promise(r => setTimeout(r, wait)); + } else { + throw err; + } + } + } + throw new Error(`Gave up on emoji "${name}"`); +} + +// ─── Epic helpers ───────────────────────────────────────────────────────────── +async function getSwitchAccessToken() { + const res = await fetch(EPIC_OAUTH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `basic ${SWITCH_TOKEN}` }, + body: 'grant_type=client_credentials', + }); + return (await res.json()).access_token; +} + +async function createDeviceCode(token) { + const res = await fetch(EPIC_DEVICE_AUTH, { + method: 'POST', + headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return res.json(); +} + +async function waitForLogin(deviceCode, maxMs = 300000) { + const start = Date.now(); + while (Date.now() - start < maxMs) { + await new Promise(r => setTimeout(r, 10000)); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${SWITCH_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=device_code&device_code=${deviceCode}`, + }); + const data = await res.json(); + if (res.status === 200 && data.access_token) return data; + const err = data.errorCode || ''; + if (err.includes('not_found') || err.includes('expired')) return null; + } + return null; +} + +async function exchangeForIosToken(accessToken) { + const exRes = await fetch(EPIC_EXCHANGE, { headers: { 'Authorization': `bearer ${accessToken}` } }); + const exData = await exRes.json(); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${IOS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=exchange_code&exchange_code=${exData.code}`, + }); + return res.json(); +} + +async function getProfile(accountId, accessToken, profileId) { + const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=${profileId}&rvn=-1`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Authorization': `bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + return res.json(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SKIN CHECKER IMAGE RENDERER (RiftCheckerBot style) +// ───────────────────────────────────────────────────────────────────────────── +const { createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'); +const path = require('path'); + +// Register Fortnite font (Burbank Big Condensed Black) + Tahoma +GlobalFonts.registerFromPath( + path.join(__dirname, 'BurbankBigCondensed-Black.ttf'), 'Burbank' +); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahoma.ttf', 'Tahoma'); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahomabd.ttf', 'TahomaB'); + +// Custom background URL — set with !setbg , cleared with !clearbg +let customBgUrl = null; + +const ICON = 96; +const COLS = 12; // wider rows = shorter images +const LABEL = 26; +const CELL_H = ICON + LABEL; +const PAD = 14; +const HDR_H = 72; +const FTR_H = 36; + +const RC = { + common: '#c8c8c8', uncommon: '#60aa3a', rare: '#4990e2', + epic: '#874bd2', legendary: '#d3782e', mythic: '#ffd700', + icon: '#54ceec', marvel: '#e63c3c', dark: '#641464', + frozen: '#94dffc', lava: '#ca4d1d', shadow: '#505050', + gaming: '#1a8e48', slurp: '#00e9b0', +}; + +const OG_STYLE_ICONS = { + 'cid_029_athena_commando_f_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png', + 'cid_030_athena_commando_m_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png', + 'cid_028_athena_commando_f': 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 'cid_701_athena_commando_m_bananaagent': 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png', + 'cid_116_athena_commando_m_carbideblack': 'https://fortnite-api.com/images/cosmetics/br/cid_116_athena_commando_m_carbideblack/variants/parts/stage1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Cache the logo so we only fetch once per session +let _logoBuffer = null; +async function getLogoBuffer() { + if (_logoBuffer) return _logoBuffer; + try { + const res = await fetchWithTimeout( + 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 8000 + ); + if (res.ok) _logoBuffer = Buffer.from(await res.arrayBuffer()); + } catch { _logoBuffer = null; } + return _logoBuffer; +} + +const CACHE_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cache'); +const RARITY_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'styles', 'rift', 'rarity'); +const ICON_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cosmetic_icons'); +const fs = require('fs'); + +// Cosmetics that have special OG variant images in local cache +// key = lowercase cosmetic ID, value = local cache filename +const LOCAL_OG_ICONS = { + 'cid_029_athena_commando_f_halloween': 'pink_ghoul.png', // Pink Ghoul + 'cid_030_athena_commando_m_halloween': 'purple_skull.png', // Purple Skull + 'cid_028_athena_commando_f': 'og_rene.png', // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': 'og_aat.png', // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': 'omega_max.png', // Max Omega + 'cid_701_athena_commando_m_bananaagent': 'gold_peely.png', // Gold Agent Peely +}; + +// Remote OG variant URLs for cosmetics not in local cache +const REMOTE_OG_ICONS = { + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Load a rarity background image from local cache +const rarityImageCache = {}; +async function getRarityImage(rarity) { + const key = (rarity || 'common').toLowerCase(); + if (rarityImageCache[key]) return rarityImageCache[key]; + const filePath = path.join(RARITY_DIR, `${key}.png`); + if (fs.existsSync(filePath)) { + try { + const img = await loadImage(fs.readFileSync(filePath)); + rarityImageCache[key] = img; + return img; + } catch { /* fall through */ } + } + // fallback to common + const fallback = path.join(RARITY_DIR, 'common.png'); + if (fs.existsSync(fallback)) { + const img = await loadImage(fs.readFileSync(fallback)); + rarityImageCache['common'] = img; + return img; + } + return null; +} + +// Get cosmetic icon — local cache first, then remote +async function fetchSkinImageBuffer(cosmeticId, useOgStyle = false) { + const id = cosmeticId.toLowerCase(); + + // 1. Try local OG variant if requested + if (useOgStyle && LOCAL_OG_ICONS[id]) { + const localPath = path.join(CACHE_DIR, LOCAL_OG_ICONS[id]); + if (fs.existsSync(localPath)) return fs.readFileSync(localPath); + } + + // 2. Try remote OG variant if requested + if (useOgStyle && REMOTE_OG_ICONS[id]) { + try { + const res = await fetchWithTimeout(REMOTE_OG_ICONS[id], 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + } + + // 3. Check if this is a banner ID — fetch from banner API + const bannerPrefixes = ['ot','influencerbanner','foundertier','brs','survival','endurance','wargames','achievement','otherbanner','skirmish','worldcup']; + if (bannerPrefixes.some(p => id.startsWith(p))) { + // Try local RiftCheckerBot cache first + try { + const files = fs.readdirSync(CACHE_DIR); + const match = files.find(f => f.toLowerCase() === id + '.png' || f.toLowerCase() === cosmeticId + '.png'); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + // Fetch from banner API + try { + const bMeta = await getBannerMeta(); + const iconUrl = bMeta[id]?.icon; + if (iconUrl) { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } + } catch { /* fall through */ } + return null; + } + + // 4. Try local cache with exact cosmetic ID match + try { + const files = fs.readdirSync(CACHE_DIR); + const baseName = cosmeticId + '.png'; + const match = files.find(f => f.toLowerCase() === baseName.toLowerCase()); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + + // 5. Fortnite API CDN + const iconUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/icon.png`; + try { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + + // 6. Fallback: smallIcon + const smallUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/smallicon.png`; + try { + const res = await fetchWithTimeout(smallUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* give up */ } + + return null; +} + +async function renderLockerImage(items, countLabel, subtitle, submittedBy = null, ogStyleOwned = null) { + if (!items || items.length === 0) return null; + + const LOGO_SIZE = HDR_H - 8; + const rows = Math.ceil(items.length / COLS); + const cols = Math.min(items.length, COLS); + const W = Math.max(cols * ICON + PAD * 2, 400); + const H = HDR_H + rows * CELL_H + PAD + FTR_H; + + const canvas = createCanvas(W, H); + const ctx = canvas.getContext('2d'); + + // ── Background ─────────────────────────────────────────────────────────── + if (customBgUrl) { + try { + const res = await fetchWithTimeout(customBgUrl, 10000); + if (res.ok) { + let rawBuf = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get('content-type') || ''; + const isGif = customBgUrl.toLowerCase().endsWith('.gif') || contentType.includes('gif'); + // For GIFs extract first frame; for everything else just resize + const processed = await sharp(rawBuf, { animated: isGif }) + .resize(W, H, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + ctx.drawImage(await loadImage(processed), 0, 0, W, H); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(0, 0, W, H); + } else { throw new Error('bg fetch failed'); } + } catch { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + } else { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; + for (let c = 0; c <= cols; c++) { + ctx.beginPath(); ctx.moveTo(PAD + c * ICON, HDR_H); ctx.lineTo(PAD + c * ICON, HDR_H + rows * CELL_H); ctx.stroke(); + } + for (let r = 0; r <= rows; r++) { + ctx.beginPath(); ctx.moveTo(PAD, HDR_H + r * CELL_H); ctx.lineTo(PAD + cols * ICON, HDR_H + r * CELL_H); ctx.stroke(); + } + + // ── Logo ───────────────────────────────────────────────────────────────── + const logoBuf = await getLogoBuffer(); + let logoEndX = PAD; + if (logoBuf) { + try { + const logoResized = await sharp(logoBuf).resize(LOGO_SIZE, LOGO_SIZE, { fit: 'cover' }).png().toBuffer(); + const logoImg = await loadImage(logoResized); + const cx = PAD + LOGO_SIZE / 2, cy = 4 + LOGO_SIZE / 2, lr = LOGO_SIZE / 2; + ctx.save(); + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.clip(); + ctx.drawImage(logoImg, PAD, 4, LOGO_SIZE, LOGO_SIZE); + ctx.restore(); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.stroke(); + logoEndX = PAD + LOGO_SIZE + 10; + } catch { logoEndX = PAD; } + } + + // ── Header text ─────────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 26px TahomaB'; + ctx.fillText(countLabel, logoEndX, 40); + ctx.fillStyle = '#aaaaaa'; + ctx.font = '13px Tahoma'; + ctx.fillText(subtitle.toUpperCase(), logoEndX, 60); + + // ── Cosmetic grid ───────────────────────────────────────────────────────── + let idx = 0; + for (const item of items) { + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const x = PAD + col * ICON; + const y = HDR_H + row * CELL_H; + const id = item.id.toLowerCase(); + + // Determine if we should show OG style for this item + const hasOgRequirement = !!OG_STYLE_REQUIREMENTS[id]; + const ownsOgStyle = ogStyleOwned ? ogStyleOwned.has(id) : false; + const useOg = hasOgRequirement && ownsOgStyle; + + // Effective rarity: mythic if OG style owned, otherwise use metaMap rarity + const effectiveRarity = useOg ? 'mythic' : (item.rarity || 'common'); + + // ── Draw rarity background from local cache ─────────────────────────── + const rarityFilePath = path.join(RARITY_DIR, `${effectiveRarity}.png`); + if (fs.existsSync(rarityFilePath)) { + try { + const rarBuf = await sharp(fs.readFileSync(rarityFilePath)) + .resize(ICON, ICON, { fit: 'fill' }).png().toBuffer(); + ctx.drawImage(await loadImage(rarBuf), x, y, ICON, ICON); + } catch { /* skip */ } + } else { + ctx.fillStyle = (RC[effectiveRarity] || RC.common) + '28'; + ctx.fillRect(x, y, ICON, ICON); + } + + // ── Gradient border around the cell ────────────────────────────────── + const borderColor = RC[effectiveRarity] || RC.common; + // Top edge gradient (left to right) + const topGrad = ctx.createLinearGradient(x, y, x + ICON, y); + topGrad.addColorStop(0, borderColor + '00'); + topGrad.addColorStop(0.3, borderColor + 'ff'); + topGrad.addColorStop(0.7, borderColor + 'ff'); + topGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = topGrad; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ICON, y); ctx.stroke(); + + // Bottom edge gradient + const botGrad = ctx.createLinearGradient(x, y + ICON, x + ICON, y + ICON); + botGrad.addColorStop(0, borderColor + '00'); + botGrad.addColorStop(0.3, borderColor + 'ff'); + botGrad.addColorStop(0.7, borderColor + 'ff'); + botGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = botGrad; + ctx.beginPath(); ctx.moveTo(x, y + ICON); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // Left edge gradient (top to bottom) + const leftGrad = ctx.createLinearGradient(x, y, x, y + ICON); + leftGrad.addColorStop(0, borderColor + '00'); + leftGrad.addColorStop(0.3, borderColor + 'ff'); + leftGrad.addColorStop(0.7, borderColor + 'ff'); + leftGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = leftGrad; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + ICON); ctx.stroke(); + + // Right edge gradient + const rightGrad = ctx.createLinearGradient(x + ICON, y, x + ICON, y + ICON); + rightGrad.addColorStop(0, borderColor + '00'); + rightGrad.addColorStop(0.3, borderColor + 'ff'); + rightGrad.addColorStop(0.7, borderColor + 'ff'); + rightGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = rightGrad; + ctx.beginPath(); ctx.moveTo(x + ICON, y); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // ── Draw cosmetic icon ──────────────────────────────────────────────── + const buf = await fetchSkinImageBuffer(item.id, useOg); + if (buf) { + try { + const resized = await sharp(buf).resize(ICON - 6, ICON - 6, { fit: 'cover' }).png().toBuffer(); + ctx.drawImage(await loadImage(resized), x + 3, y + 3, ICON - 6, ICON - 6); + } catch { /* skip */ } + } + + // ── Name label ──────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'center'; + const maxW = ICON - 4; + let name = item.name || ''; + while (name.length > 3 && ctx.measureText(name).width > maxW) name = name.slice(0, -1); + if (name !== item.name) name = name.slice(0, -1) + '..'; + ctx.fillText(name, x + ICON / 2, y + ICON + 17, maxW); + ctx.textAlign = 'left'; + idx++; + } + + // ── Footer ──────────────────────────────────────────────────────────────── + const footerY = H - FTR_H; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, footerY, W, FTR_H); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, footerY); ctx.lineTo(W, footerY); ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px TahomaB'; + ctx.fillText(submittedBy ? `Submitted by: ${submittedBy}` : 'Made by jitterbuxg', PAD, footerY + 14); + ctx.fillStyle = '#ffd700'; + ctx.font = '10px Tahoma'; + ctx.fillText('.gg/collecting', PAD, footerY + 28); + + ctx.fillStyle = '#888888'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'right'; + ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), W - PAD, footerY + 21); + ctx.textAlign = 'left'; + + return canvas.toBuffer('image/png'); +} + +// Banner metadata cache — fetched once from fortnite-api.com/v1/banners +let bannerMetaCache = null; +async function getBannerMeta() { + if (bannerMetaCache) return bannerMetaCache; + try { + const res = await fetchWithTimeout('https://fortnite-api.com/v1/banners', 10000); + if (res.ok) { + const json = await res.json(); + const map = {}; + for (const b of (json.data || [])) { + map[b.id.toLowerCase()] = { + name: b.devName || b.id, + icon: b.images?.icon || b.images?.smallIcon, + rarity: 'common', + }; + } + bannerMetaCache = map; + return map; + } + } catch { /* ignore */ } + return {}; +} + +// Batch fetch cosmetic metadata (name + rarity) for up to 50 IDs at once +async function fetchCosmeticMeta(ids) { + const map = {}; + if (!ids || ids.length === 0) return map; + for (let i = 0; i < ids.length; i += 50) { + const chunk = ids.slice(i, i + 50); + try { + const qs = chunk.map(id => `id=${encodeURIComponent(id)}`).join('&'); + const res = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/search/ids?language=en&${qs}`, 10000); + if (!res.ok) continue; + const json = await res.json(); + for (const item of (json.data || [])) { + map[item.id.toLowerCase()] = { name: item.name, rarity: item.rarity?.value || 'common' }; + } + } catch { /* skip chunk on error */ } + } + return map; +} + +// Cosmetics that have OG style requirements — id -> styleTag +const OG_STYLE_REQUIREMENTS = { + 'cid_029_athena_commando_f_halloween': 'Mat3', // Pink Ghoul + 'cid_030_athena_commando_m_halloween': 'Mat1', // Purple Skull + 'cid_028_athena_commando_f': 'Mat3', // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': 'Stage2', // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': 'Stage5', // Max Omega + 'cid_701_athena_commando_m_bananaagent': 'Stage4', // Gold Agent Peely + 'pickaxe_lockjaw': 'Stage2', // Black & Gold Raider's Revenge +}; + +function buildOwnedMap(athenaData) { + const owned = {}; // id -> Set of owned style tags + const ogStyleOwned = new Set(); + const outfits = []; + const pickaxes = []; + const emotes = []; + const emoticons = []; // AthenaEmoji type (in-game emoticons, NOT emotes) + const backblings = []; + const gliders = []; + const sprays = []; + const items = athenaData?.profileChanges?.[0]?.profile?.items || {}; + for (const item of Object.values(items)) { + const tpl = (item.templateId || '').toLowerCase(); + if (!tpl.startsWith('athena') && !tpl.startsWith('character')) continue; + const id = tpl.split(':')[1] || ''; + if (!owned[id]) owned[id] = new Set(); + for (const v of (item.attributes?.variants || [])) + for (const tag of (v.owned || [])) owned[id].add(tag); + + if (tpl.startsWith('athenacharacter:') || tpl.startsWith('character:')) outfits.push(id); + else if (tpl.startsWith('athenapickaxe:')) pickaxes.push(id); + else if (tpl.startsWith('athenadance:')) emotes.push(id); + else if (tpl.startsWith('athenaemoji:')) emoticons.push(id); + else if (tpl.startsWith('athenabackpack:')) backblings.push(id); + else if (tpl.startsWith('athenaglider:')) gliders.push(id); + else if (tpl.startsWith('athenasprays:') || tpl.startsWith('athenaspray:')) sprays.push(id); + } + // Populate ogStyleOwned after all variants are collected + for (const id of Object.keys(owned)) { + if (OG_STYLE_REQUIREMENTS[id] && owned[id].has(OG_STYLE_REQUIREMENTS[id])) { + ogStyleOwned.add(id); + } + } + return { owned, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays }; +} + +function getRolesForLocker(ownedMap, ccItems) { + const earned = []; + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) { + const has = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (has) earned.push(req.role); + continue; + } + if (req.stw) { + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + if (ccIds.some(cc => cc.includes(req.id.toLowerCase()))) earned.push(req.role); + continue; + } + const id = req.id.toLowerCase(); + if (!ownedMap[id]) continue; + if (req.styleTag) { if (ownedMap[id].has(req.styleTag)) earned.push(req.role); } + else earned.push(req.role); + } + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + const hasFounder = ccIds.some(cc => cc.includes('founder')); + const hasUltimate = ccIds.some(cc => cc.includes('tiertoken_collectionbook_ultimate')); + if (hasUltimate) { earned.push('Ultimate STW Founders'); earned.push('STW Founders'); } + else if (hasFounder) earned.push('STW Founders'); + return [...new Set(earned)]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOT READY +// ───────────────────────────────────────────────────────────────────────────── +client.once('ready', async () => { + console.log(`✅ Logged in as ${client.user.tag}`); + console.log('Commands: !setuproles | !addicons | !skipemoji | !stopemojis | !setroleicons | !removeoldemojis | !verify'); + + // Set bot avatar to Renegade Raider Black & Gold style + try { + const imgRes = await fetch('https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png'); + const imgBuf = Buffer.from(await imgRes.arrayBuffer()); + await client.user.setAvatar(imgBuf); + console.log('✅ Bot avatar set to Renegade Raider Black & Gold.'); + } catch (err) { + console.warn('Could not set avatar (may already be set or rate limited):', err.message); + } + + // Register /commands slash command to guild for instant availability + const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const slashCommands = [ + new SlashCommandBuilder() + .setName('commands') + .setDescription('List all bot commands') + .toJSON(), + ]; + try { + await rest.put(Routes.applicationGuildCommands(client.user.id, process.env.EMOJI_GUILD_ID), { body: slashCommands }); + console.log('✅ Slash commands registered to guild.'); + } catch (err) { + console.error('Failed to register slash commands:', err.message); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /commands slash command +// ───────────────────────────────────────────────────────────────────────────── +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand() || interaction.commandName !== 'commands') return; + + const embed = new EmbedBuilder() + .setColor(0x1a1a2e) + .setAuthor({ + name: 'jitterbuxg', + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Bot Commands') + .setThumbnail('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .addFields( + { + name: 'Admin Setup', + value: [ + '`!setuproles` — Create all Fortnite roles', + '`!addicons` — Upload skin icons as server emojis', + '`!setroleicons` — Apply emojis as role icons', + '`!removeoldemojis` — Wipe all emojis for a fresh upload', + '`!setbg ` — Set custom background (supports GIFs)', + '`!clearbg` — Reset to default background', + ].join('\n'), + inline: false, + }, + { + name: 'During !addicons', + value: '`!skipemoji` — Skip current · `!stopemojis` — Cancel session', + inline: false, + }, + { + name: 'Verification', + value: '`!verify` — Link your Epic account and auto-assign roles based on your locker', + inline: false, + }, + { + name: 'What !verify checks', + value: [ + '**OG Skins** — Renegade Raider (B&G), Ghoul Trooper (Pink), Skull Trooper (Purple), + more', + '**Exclusives** — Galaxy, Ikonik, Glow, Honor Guard, Double Helix, Wildcat, + more', + '**Pickaxes** — Raider\'s Revenge (B&G), Axe of Champions, AC/DC, + more', + '**Emotes** — Fresh, Floss, Take the L, Orange Justice, + more', + '**Banners** — OT1–OT11, Creator Banners, STW Founders, + more', + ].join('\n'), + inline: false, + } + ) + .setImage('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .setFooter({ text: 'Made by jitterbuxg • .gg/collecting' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: false }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setbg / !clearbg — custom background for skin checker images +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + if (message.content.startsWith('!setbg ')) { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + const url = message.content.slice(7).trim(); + if (!url.startsWith('http')) return message.reply('Please provide a valid image URL.'); + customBgUrl = url; + return message.reply(`Background set. All future skin checker images will use this background.`); + } + if (message.content === '!clearbg') { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + customBgUrl = null; + return message.reply('Background cleared. Back to default dark gradient.'); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setuproles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setuproles') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply(`Creating up to ${roles.length} roles...`); + let created = 0, skipped = 0; + const existing = message.guild.roles.cache.map(r => r.name.toLowerCase()); + + for (const role of roles) { + if (existing.includes(role.name.toLowerCase())) { skipped++; continue; } + try { + await message.guild.roles.create({ name: role.name, color: role.color, reason: 'Bulk role setup' }); + console.log(`Created: ${role.name}`); + created++; + await new Promise(r => setTimeout(r, 300)); + } catch (err) { + console.error(`Failed "${role.name}": ${err.message}`); + } + } + message.channel.send(`Done! Created **${created}** roles, skipped **${skipped}** existing.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !removeoldemojis +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!removeoldemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Removing ALL emojis from **${emojiGuild.name}**...`); + console.log(`🎯 Clearing all emojis from: ${emojiGuild.name} (${emojiGuild.id})`); + await emojiGuild.emojis.fetch(); + + const allEmojis = [...emojiGuild.emojis.cache.values()]; + console.log(`📋 Found ${allEmojis.length} emojis to delete`); + let removed = 0; + + for (const emoji of allEmojis) { + try { + await emoji.delete('Clearing all emojis for fresh upload'); + console.log(`🗑 Deleted: ${emoji.name}`); + removed++; + await new Promise(r => setTimeout(r, 400)); + } catch (err) { + console.error(`Failed to delete ${emoji.name}: ${err.message}`); + } + } + message.channel.send(`Removed **${removed}** emojis from **${emojiGuild.name}**. Run !addicons to re-upload.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !addicons + !skipemoji + !stopemojis +// ───────────────────────────────────────────────────────────────────────────── + +// Shared state for the active addicons session +let emojiSession = null; // { resolve, name, channel } + +// !skipemoji — skip the currently stuck emoji +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!skipemoji') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply(`Skipping **${emojiSession.name}**...`); + emojiSession.resolve('skip'); +}); + +// !stopemojis — stop the whole session +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!stopemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply('Stopping emoji upload session.'); + emojiSession.resolve('stop'); +}); + +// Lock to prevent !addicons running multiple times simultaneously +let addIconsRunning = false; + +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!addicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + if (addIconsRunning) + return message.reply('An emoji upload is already in progress. Wait for it to finish or use `!stopemojis` to cancel.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Uploading ${roleIconMap.length} skin icons to **${emojiGuild.name}**.\nType \`!skipemoji\` to skip a stuck one, or \`!stopemojis\` to cancel.`); + await emojiGuild.emojis.fetch(); + const existing = new Set(emojiGuild.emojis.cache.map(e => e.name.toLowerCase())); + let added = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + if (existing.has(entry.emoji.toLowerCase())) { + console.log(`⏭ Exists: ${entry.emoji}`); + skipped++; + continue; + } + + // Set up skip/stop listener for this entry + let sessionResolve; + const skipPromise = new Promise(res => { sessionResolve = res; }); + emojiSession = { resolve: sessionResolve, name: entry.name, channel: message.channel }; + + process.stdout.write(`📥 ${entry.name} — fetching... `); + try { + const iconUrl = entry.overrideUrl || entry.bannerUrl || await fetchIconUrl(entry.id); + process.stdout.write(`processing... `); + const buffer = await downloadAndProcess(iconUrl); + process.stdout.write(`uploading... `); + + // Race the upload against a skip/stop signal + const result = await Promise.race([ + createEmojiSafe(emojiGuild, buffer, entry.emoji, `Skin icon: ${entry.name}`) + .then(() => 'done'), + skipPromise, + ]); + + if (result === 'stop') { + console.log('🛑 Stopped by user.'); + emojiSession = null; + return message.channel.send(`Stopped. Added **${added}**, skipped **${skipped}**, failed **${failed}**.`); + } + if (result === 'skip') { + console.log(`⏭ Skipped: ${entry.name}`); + skipped++; + emojiSession = null; + continue; + } + + console.log(`✅`); + added++; + } catch (err) { + console.log(`❌ ${err.message}`); + failed++; + } + + emojiSession = null; + await new Promise(r => setTimeout(r, 2000)); + } + + emojiSession = null; + message.channel.send(`Done. Added **${added}** emojis to **${emojiGuild.name}**. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setroleicons +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setroleicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply('Applying emojis to roles...'); + await emojiGuild.emojis.fetch(); + await message.guild.roles.fetch(); + let updated = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + const role = message.guild.roles.cache.find(r => r.name.toLowerCase() === entry.name.toLowerCase()); + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === entry.emoji.toLowerCase()); + if (!role) { console.log(`⏭ Role not found: ${entry.name}`); skipped++; continue; } + if (!emoji) { console.log(`⚠ Emoji missing: ${entry.emoji} — run !addicons first`); skipped++; continue; } + try { + await role.setIcon(emoji); + console.log(`✅ ${entry.name} → :${entry.emoji}:`); + updated++; + } catch (err) { + console.error(`❌ "${entry.name}": ${err.message}`); + failed++; + } + await new Promise(r => setTimeout(r, 500)); + } + message.channel.send(`Done. Applied icons to **${updated}** roles. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !unverify — remove verification and strip roles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!unverify') return; + + const userId = message.author.id; + + if (!verifiedUsers.has(userId)) + return message.reply('You are not currently verified. Use `!verify` to link your account.'); + + const prev = verifiedUsers.get(userId); + const guild = client.guilds.cache.get(prev.guildId) || message.guild; + + if (guild) { + try { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) { + for (const roleName of prev.assignedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (role && member.roles.cache.has(role.id)) { + await member.roles.remove(role, 'User unverified').catch(() => null); + } + } + } + } catch (err) { + console.error('Unverify role removal error:', err.message); + } + } + + verifiedUsers.delete(userId); + message.reply(`Your verification as **${prev.epicName}** has been removed and your roles have been stripped. You can now use \`!verify\` to link a different account.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !verify +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!verify') return; + + const userId = message.author.id; + + // Support DMs — if in DM, we need to find the guild separately + let guild = message.guild; + if (!guild) { + // Try to find the main guild from EMOJI_GUILD_ID + guild = client.guilds.cache.get(process.env.EMOJI_GUILD_ID) || client.guilds.cache.first(); + if (!guild) return message.reply('Could not find the server. Please use !verify in the server.'); + } + + // Block if already verified + if (verifiedUsers.has(userId)) { + const prev = verifiedUsers.get(userId); + return message.reply(`You are already verified as **${prev.epicName}**. Use \`!unverify\` to unlink your account and verify a different one.`); + } + + if (pending.has(userId)) + return message.reply('You already have a verification in progress. Check your DMs.'); + + await message.reply('A login link has been sent to your DMs.'); + + try { + const switchToken = await getSwitchAccessToken(); + const deviceData = await createDeviceCode(switchToken); + if (!deviceData.user_code || !deviceData.device_code) + return message.author.send('Failed to generate a login link. Try again later.'); + + const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('Fortnite Account Verification') + .setDescription( + `**[Click here to verify →](${loginUrl})**\n\n` + + `Code: \`${deviceData.user_code}\`\n\n` + + `Expires in **5 minutes**. The bot will check your locker and assign your roles automatically.` + ) + .setFooter({ text: 'Read-only — the bot only checks your cosmetics.' }); + + await message.author.send({ embeds: [embed] }); + pending.set(userId, { guildId: guild.id, channelId: message.channel.id }); + + const tokenData = await waitForLogin(deviceData.device_code); + pending.delete(userId); + + if (!tokenData) + return message.author.send('Login expired. Use !verify to try again.'); + + const iosData = await exchangeForIosToken(tokenData.access_token); + if (!iosData.access_token) + return message.author.send('Authentication failed. Please try again.'); + + const { account_id: accountId, displayName = 'Unknown', access_token: accessToken } = iosData; + await message.author.send(`Logged in as **${displayName}**. Fetching locker...`); + + const [athenaData, commonCoreData] = await Promise.all([ + getProfile(accountId, accessToken, 'athena'), + getProfile(accountId, accessToken, 'common_core'), + ]); + + if (!athenaData?.profileChanges) + return message.author.send('Failed to read your locker. Please try again.'); + + const { owned: ownedMap, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays } = buildOwnedMap(athenaData); + const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {}; + const earnedRoles = getRolesForLocker(ownedMap, ccItems); + + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) + return message.author.send('Could not find you in the server. Make sure you are still a member.'); + + const assigned = [], notFound = []; + for (const roleName of earnedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (!role) { notFound.push(roleName); continue; } + if (!member.roles.cache.has(role.id)) + await member.roles.add(role, 'Fortnite locker verification').catch(() => null); + assigned.push(roleName); + } + + // Store verified state so they can't re-verify without !unverify + verifiedUsers.set(userId, { guildId: guild.id, assignedRoles: assigned, epicName: displayName }); + + const verifyEmbed = new EmbedBuilder() + .setColor(0x2B2D31) + .setTitle(`${displayName}'s Verification`) + .setDescription( + assigned.length === 0 + ? 'No exclusive roles found.' + : assigned.map(r => `— ${r}`).join('\n') + ) + .addFields( + { name: 'Total Outfits', value: String(outfits.length), inline: true }, + { name: 'Total Pickaxes', value: String(pickaxes.length), inline: true }, + ) + .setFooter({ text: 'Made by jitterbuxg' }); + + await message.author.send({ embeds: [verifyEmbed] }); + + // Send verification log to designated channel + try { + const logChannel = await client.channels.fetch(LOG_CHANNEL_ID).catch(() => null); + if (logChannel) { + // Fetch server emojis to use next to role names + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + await emojiGuild?.emojis.fetch().catch(() => null); + + const getServerEmoji = (roleName) => { + if (!emojiGuild) return ''; + // Map role names to emoji names we uploaded + const emojiNameMap = { + 'Galaxy': 'Galaxy', 'Ikonik': 'Ikonik', 'Glow': 'Glow', + 'OG Renegade Raider': 'RenegadeRaider', 'OG Ghoul Trooper': 'GhoulTrooper', + 'OG Skull Trooper': 'SkullTrooper', 'OG Aerial Assault Trooper': 'AerialAssaultTrooper', + 'Black Knight': 'BlackKnight', 'Double Helix': 'DoubleHelix', + 'Honor Guard': 'HonorGuard', 'Wonder': 'Wonder', 'Eon': 'Eon', + 'Dark Vertex': 'DarkVertex', 'Neo Versa': 'NeoVersa', + 'Royale Bomber': 'RoyaleBomber', 'Wildcat': 'Wildcat', + 'Stealth Reflex': 'StealthReflex', 'Reflex': 'Reflex', + "Raider's Revenge": 'RaidersRevenge', 'Stellar Axe': 'StellarAxe', + 'AC/DC': 'ACDC', 'Merry Mint Axe': 'MerryMintAxe', + 'Axe of Champions': 'AxeOfChampions', 'Blade of Champions': 'BladeOfChampions', + 'Fully Golden Peely': 'FullyGoldenPeely', + }; + const emojiName = emojiNameMap[roleName]; + if (!emojiName) return ''; + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === emojiName.toLowerCase()); + return emoji ? `${emoji} ` : ''; + }; + + const logEmbed = new EmbedBuilder() + .setColor(assigned.length > 0 ? 0xFFD700 : 0x444444) + .setAuthor({ + name: displayName, + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Account Verified') + .setDescription( + assigned.length === 0 + ? 'No exclusive cosmetics found on this account.' + : assigned.map(r => `${getServerEmoji(r)}${r}`).join('\n') + ) + .addFields( + { name: 'Discord', value: message.author.username, inline: true }, + { name: 'Epic Account', value: displayName, inline: true }, + { name: 'Roles Given', value: String(assigned.length), inline: true }, + ) + .setFooter({ text: '.gg/collecting • Made by jitterbuxg' }) + .setTimestamp(); + await logChannel.send({ embeds: [logEmbed] }); + } + } catch (err) { + console.error('Log channel error:', err.message); + } + + // ── 1. Fetch metadata for all owned cosmetics ───────────────────────── + const allIds = [...outfits, ...pickaxes]; + const metaMap = await fetchCosmeticMeta(allIds); + + // Override rarity to mythic for known exclusives + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) { + const id = req.id.toLowerCase(); + if (ownedMap[id]) { + const qualifies = req.styleTag ? ownedMap[id].has(req.styleTag) : true; + if (qualifies && metaMap[id]) metaMap[id].rarity = 'mythic'; + } + } + } + + // ── 2. Exclusives rendered image ────────────────────────────────────── + try { + const exclusiveItems = []; + + // Exclusive cosmetics (skins, pickaxes, emotes, emoticons) + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId || req.stw) continue; + const id = req.id.toLowerCase(); + if (!ownedMap[id]) continue; + if (req.styleTag && !ownedMap[id].has(req.styleTag)) continue; + const meta = metaMap[id] || {}; + exclusiveItems.push({ id, name: meta.name || req.role, rarity: 'mythic' }); + } + + // Exclusive & STW banners — full set from master list + const EXCLUSIVE_BANNER_IDS = new Set([ + // OT Banners + 'ot1banner','ot2banner','ot3banner','ot4banner','ot5banner', + 'ot6banner','ot7banner','ot8banner','ot9banner','ot10banner','ot11banner', + // Alpha tester / rare + 'otherbanner51', + // World Cup / FNCS / Skirmish + 'brworldcup2019','brs9worldcup2019','brs23_fncs_drops','s11fncs', + 'brskirmishbushbandits','brskirmishdustydogs','brskirmishfortknights', + 'brskirmishluckylla mas','brskirmishriftraiders', + // Season level 100 + 'brs8level100','brs9lvl100','brs10level100', + // STW Founder banners (all tiers) + ...[1,2,3,4,5].flatMap(tier => + [1,2,3,4,5].map(v => `foundertier${tier}banner${v}`) + ), + // STW Event banners + 'endurancewave30theater01','endurancewave30theater02', + 'endurancewave30theater03','endurancewave30theater04', + 'survivalbannerholidayhard','survivalbannerholidaymed','survivalbannerholidayeasy', + 'survivalbannerstorm2018hard','survivalbannerstorm2018med','survivalbannerstorm2018easy', + 'wargamestprifts','wargamestiki','wargamespods','wargamestornado', + 'wargamesanomaly','wargamesmeteor', + // STW Achievement banners + 'achievementunspeakablehorrors','achievementworldexplorer', + 'achievementguardianangel','achievementplayswellwithothers', + 'achievementtalentedbuilder','achievementgognome','achievementlootlegend', + // Creator / Influencer banners 1-58 + ...[...Array(58)].map((_,i) => `influencerbanner${i+1}`), + ]); + + const bannerMeta = await getBannerMeta(); + for (const [rawId, info] of Object.entries(bannerMeta)) { + const id = rawId.toLowerCase(); + if (!EXCLUSIVE_BANNER_IDS.has(id)) continue; + // Check common_core for ownership + const owned = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(id) + ); + if (!owned) continue; + exclusiveItems.push({ id: rawId, name: info.name || rawId, rarity: 'mythic' }); + if (!metaMap[id]) metaMap[id] = { name: info.name || rawId, rarity: 'mythic' }; + else metaMap[id].rarity = 'mythic'; + } + + if (exclusiveItems.length > 0) { + await message.author.send('Building your exclusive cosmetics image...'); + const exclusiveImg = await renderLockerImage( + exclusiveItems, + `${exclusiveItems.length} (0)`, + 'EXCLUSIVES', + message.author.username, + ogStyleOwned + ); + if (exclusiveImg) { + const top5 = exclusiveItems.slice(0, 5).map(i => i.name).join(' + '); + const suffix = exclusiveItems.length > 5 ? ` + ${exclusiveItems.length - 5} more` : ''; + await message.author.send({ + content: `**${top5}${suffix}** (${outfits.length} skin count)`, + files: [{ attachment: exclusiveImg, name: 'exclusives.png' }], + }); + } + } + } catch (err) { + console.error('Exclusive image error:', err.message); + } + + // ── 3. Full locker — all items, sorted by rarity, one image per category ── + try { + // Rarity sort order (matches RiftCheckerBot) + const RARITY_ORDER = ['mythic','legendary','dark','lava','slurp','starwars','marvel','frozen','gaminglegends','shadow','icon','dc','epic','rare','uncommon','common']; + const sortByRarity = (ids) => [...ids].sort((a, b) => { + const ra = RARITY_ORDER.indexOf(metaMap[a.toLowerCase()]?.rarity || 'common'); + const rb = RARITY_ORDER.indexOf(metaMap[b.toLowerCase()]?.rarity || 'common'); + return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb); + }); + + // Collect owned exclusive banners + const bannerMeta = await getBannerMeta(); + const ownedExclusiveBannerIds = []; + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) continue; + const owned = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (owned) { + const bid = req.bannerId.toLowerCase(); + if (!metaMap[bid]) { + metaMap[bid] = { name: bannerMeta[bid]?.name || req.role, rarity: 'mythic' }; + } else { + metaMap[bid].rarity = 'mythic'; + } + ownedExclusiveBannerIds.push(req.bannerId); + } + } + + // Fetch metadata for all cosmetic categories + const allCategoryIds = [...outfits, ...pickaxes, ...emotes, ...emoticons, ...backblings, ...gliders, ...sprays]; + const allMeta = await fetchCosmeticMeta(allCategoryIds); + for (const [k, v] of Object.entries(allMeta)) metaMap[k] = metaMap[k] || v; + + const categories = [ + { ids: sortByRarity(outfits), label: 'OUTFITS', subtitle: `${outfits.length} outfits` }, + { ids: sortByRarity(pickaxes), label: 'PICKAXES', subtitle: `${pickaxes.length} pickaxes` }, + { ids: sortByRarity(emotes), label: 'EMOTES', subtitle: `${emotes.length} emotes` }, + { ids: sortByRarity(emoticons), label: 'EMOTICONS', subtitle: `${emoticons.length} emoticons` }, + { ids: sortByRarity(backblings), label: 'BACK BLINGS', subtitle: `${backblings.length} back blings` }, + { ids: sortByRarity(gliders), label: 'GLIDERS', subtitle: `${gliders.length} gliders` }, + { ids: sortByRarity(sprays), label: 'SPRAYS', subtitle: `${sprays.length} sprays` }, + ...(ownedExclusiveBannerIds.length > 0 + ? [{ ids: ownedExclusiveBannerIds, label: 'BANNERS', subtitle: `${ownedExclusiveBannerIds.length} banners` }] + : []), + ].filter(c => c.ids.length > 0); + + if (categories.length === 0) { + await message.author.send('No cosmetics found in locker.'); + return; + } + + await message.author.send(`Building your full locker (${categories.length} categories)...`); + + for (const cat of categories) { + const items = cat.ids.map(id => ({ + id, + name: metaMap[id.toLowerCase()]?.name || id, + rarity: metaMap[id.toLowerCase()]?.rarity || 'common', + })); + + const img = await renderLockerImage( + items, + `${items.length} (0)`, + cat.label, + message.author.username, + ogStyleOwned + ); + + if (img) { + await message.author.send({ + content: `**${displayName} — ${cat.subtitle}**`, + files: [{ attachment: img, name: `${cat.label.toLowerCase().replace(/\s/g, '_')}.png` }], + }); + } + + await new Promise(r => setTimeout(r, 500)); + } + + } catch (err) { + console.error('Locker image error:', err.message); + } + + } catch (err) { + pending.delete(userId); + console.error('Verify error:', err); + message.author.send('Unexpected error. Try again.').catch(() => null); + } +}); + +client.login(process.env.TOKEN); diff --git a/bot.backup-codex-20260628-021829.js b/bot.backup-codex-20260628-021829.js new file mode 100644 index 0000000..8f22b09 --- /dev/null +++ b/bot.backup-codex-20260628-021829.js @@ -0,0 +1,1900 @@ +// ============================================= +// FORTNITE DISCORD BOT — ALL-IN-ONE +// ============================================= +// Commands: +// !setuproles — create all roles +// !addicons — upload skin icons as server emojis +// !setroleicons — apply emojis to roles +// !removeoldemojis — delete old skin emojis +// !verify — verify Fortnite account & auto-assign roles +// +// Run with: node bot.js +// ============================================= + +require('dotenv').config(); +const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder, REST, Routes, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js'); +const sharp = require('sharp'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: ['CHANNEL'], +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ROLES TO CREATE +// ───────────────────────────────────────────────────────────────────────────── +const roles = [ + { name: 'Fully Golden Peely', color: 0xFFD700 }, + { name: 'OG Aerial Assault Trooper', color: 0x8A8A8A }, + { name: 'OG Ghoul Trooper', color: 0xA89DB8 }, + { name: 'OG Renegade Raider', color: 0x7B2D2D }, + { name: 'OG Skull Trooper', color: 0x9B59B6 }, + { name: 'Barricade', color: 0x9E9E9E }, + { name: 'Double Helix', color: 0xD0443C }, + { name: 'Honor Guard', color: 0x4A90D9 }, + { name: 'Wonder', color: 0x2ECC71 }, + { name: 'Surf Strider', color: 0x1ABC9C }, + { name: 'Stealth Reflex', color: 0xE8927C }, + { name: 'Dark Vertex', color: 0x8E44AD }, + { name: 'Royale Bomber', color: 0xE67E22 }, + { name: 'Wildcat', color: 0xE8832A }, + { name: 'Clyde', color: 0xE8832A }, + { name: 'Eon', color: 0x27AE60 }, + { name: 'Huntmaster Saber', color: 0xD4AC0D }, + { name: 'Dark Skully', color: 0x7D6B8A }, + { name: 'Galaxy', color: 0x5B7DD8 }, + { name: 'Ikonik', color: 0xD0443C }, + { name: 'Black Knight', color: 0xCCCCCC }, + { name: 'Glow', color: 0xBB8FCC }, + { name: 'Rogue Spider Knight', color: 0x2ECC71 }, + { name: 'Thrilldiver', color: 0x4A90D9 }, + { name: 'Freediver', color: 0x9E9E9E }, + { name: 'Neo Versa', color: 0x9B59B6 }, + { name: 'Cobalt Snowfoot', color: 0x1ABC9C }, + { name: 'Florin', color: 0x8E44AD }, + { name: "What's Wrong, Toona Fish?", color: 0xCCCCCC }, + { name: 'Cameo Needs Loot', color: 0x2ECC71 }, + { name: 'Victory!', color: 0x9B59B6 }, + { name: 'Collegiate items', color: 0x2ECC71 }, + { name: 'StreamElements', color: 0xF1C40F }, + { name: 'Llamas', color: 0xFF69B4 }, + { name: 'Repaz', color: 0xD4AC0D }, + { name: 'lollek', color: 0xFFFFFF }, + { name: 'Celebration Heart', color: 0x9B59B6 }, + { name: 'Funflower', color: 0xD4C227 }, + { name: 'Level 100', color: 0xBB44EE }, + { name: 'Alpha Banner', color: 0x777777 }, + { name: 'YouTube Banner', color: 0x777777 }, + { name: 'STW Founders', color: 0xE67E22 }, + { name: 'Ultimate STW Founders', color: 0x888888 }, + { name: 'Rare Usernames', color: 0xF1C40F }, + { name: 'Skin Collector', color: 0x888888 }, + { name: 'PS+ Pack Collector', color: 0x3498DB }, + { name: 'Spray Collector', color: 0xCCCCCC }, + { name: 'Rare Banners', color: 0x888888 }, + { name: 'Emote Collector', color: 0xCCCCCC }, + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", color: 0x7B2D2D }, + { name: 'Axe of Champions', color: 0xF1C40F }, + { name: 'Blade of Champions', color: 0xF1C40F }, + { name: 'Axecalibur', color: 0xCCCCCC }, + { name: 'EVA', color: 0x4A90D9 }, + { name: 'AC/DC', color: 0xF1C40F }, + { name: 'Resonator', color: 0x27AE60 }, + { name: 'Pinpoint', color: 0xD0443C }, + { name: 'Stellar Axe', color: 0x5B7DD8 }, + { name: 'Stealth Angular Axe', color: 0x9B59B6 }, + { name: 'Dark Razor', color: 0x7D6B8A }, + { name: 'Electri-claw', color: 0xE8832A }, + { name: 'Dark Splitter', color: 0x8E44AD }, + { name: 'Merry Mint Axe', color: 0x2ECC71 }, + { name: 'Indigo Inverter', color: 0x1ABC9C }, + { name: 'Rose Glow', color: 0xFF69B4 }, + { name: 'Controller', color: 0x3498DB }, + { name: 'Pneumatic Twin', color: 0x4A90D9 }, + { name: 'Perfect Point', color: 0x9E9E9E }, + { name: 'Petrol Pump', color: 0xE67E22 }, + { name: 'Future Relic Scythe', color: 0x8E44AD }, + // --- Exclusive Emoticons --- + { name: 'Skull Trooper Emoticon', color: 0x9B59B6 }, + { name: 'Maevys Emoticon', color: 0xD0443C }, + { name: 'ZetFar Emoticon', color: 0x4A90D9 }, + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', color: 0x3498DB }, // PS Plus Pack 1 + { name: 'Blue Striker', color: 0x3498DB }, // PS Plus Pack 2 + { name: 'Prodigy', color: 0x3498DB }, // PS Plus Pack 4 + { name: 'Carbon Commando', color: 0x3498DB }, // PS Plus Pack 5 + { name: 'Trilogy', color: 0x3498DB }, // PS Plus Pack 8 + { name: 'Point Patroller', color: 0x3498DB }, // PS Plus Pack 9 + { name: 'Cloud Striker', color: 0x3498DB }, // PS Plus Pack 12 + { name: 'Fixer', color: 0x3498DB }, // PS Plus Pack 14 + { name: 'Blizzard Bomber', color: 0x3498DB }, // PS Plus Pack 18 + { name: 'Jun-Hwan', color: 0x3498DB }, // Blue Phoenix Pack + { name: 'Lina Scorch', color: 0x3498DB }, // Blazin Wheels Pack + { name: 'Indigo Kuno', color: 0x9B59B6 }, // PS Generations Cup + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Havoc', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Trailblazer', color: 0x9146FF }, // Twitch Prime Pack 2 + // --- Intel / PC Exclusive --- + { name: 'Reflex', color: 0x0071C5 }, // Intel bundle + // --- OG Battle Pass (C1S2) --- + { name: 'Sparkle Specialist', color: 0xFF69B4 }, + { name: 'Royale Knight', color: 0xCCCCCC }, + { name: 'Blue Squire', color: 0x4A90D9 }, + // --- OG Battle Pass (C1S3) --- + { name: 'The Reaper', color: 0x888888 }, + { name: 'Elite Agent', color: 0x888888 }, + { name: 'Rogue Agent', color: 0x888888 }, + // --- OG Battle Pass (C1S4) --- + { name: 'Omega', color: 0x8E44AD }, + // --- Chapter 2 Season 2 Battle Pass (golden styles) --- + { name: 'Skye', color: 0xF1C40F }, + { name: 'TNTina', color: 0xF1C40F }, + { name: 'Meowscles', color: 0xF1C40F }, + { name: 'Midas', color: 0xFFD700 }, + // --- Gaming Legends / Collab Exclusives --- + { name: 'Travis Scott', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Astro Jack', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Kratos', color: 0xCCCCCC }, // PlayStation collab + { name: 'Master Chief', color: 0x4A90D9 }, // Xbox collab + // --- World Cup / Tournament --- + { name: 'World Warrior', color: 0xF1C40F }, // Fortnite World Cup 2019 + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', color: 0xFF69B4 }, + // --- Save the World Founders --- + { name: 'Rose Team Leader', color: 0xE67E22 }, + { name: 'Warpaint', color: 0xE67E22 }, + // --- OT Banners --- + { name: 'OT1 Banner', color: 0xFFD700 }, + { name: 'OT2 Banner', color: 0xFFD700 }, + { name: 'OT3 Banner', color: 0xFFD700 }, + { name: 'OT4 Banner', color: 0xFFD700 }, + { name: 'OT5 Banner', color: 0xFFD700 }, + { name: 'OT6 Banner', color: 0xFFD700 }, + { name: 'OT7 Banner', color: 0xFFD700 }, + { name: 'OT8 Banner', color: 0xFFD700 }, + { name: 'OT9 Banner', color: 0xFFD700 }, + { name: 'OT10 Banner', color: 0xFFD700 }, + { name: 'OT11 Banner', color: 0xFFD700 }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// EMOJI / ICON MAP +// ───────────────────────────────────────────────────────────────────────────── +const roleIconMap = [ + { name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' }, + { name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' }, + { name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' }, + { name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' }, + { name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' }, + { name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' }, + { name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' }, + { name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' }, + { name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' }, + { name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' }, + { name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' }, + { name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' }, + { name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' }, + { name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' }, + { name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' }, + { name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' }, + { name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' }, + { name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' }, + { name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' }, + { name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' }, + // { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, // temporarily disabled — upload stalls + { name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' }, + { name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' }, + { name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' }, + { name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' }, + { name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' }, + { name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' }, + { name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' }, + { name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' }, + { name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' }, + + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", id: 'Pickaxe_Lockjaw', emoji: 'RaidersRevenge' }, + { name: 'Axe of Champions', id: 'Pickaxe_ID_376_FNCS', emoji: 'AxeOfChampions' }, + { name: 'Blade of Champions', id: 'Pickaxe_FNCS_S32', emoji: 'BladeOfChampions' }, + { name: 'Axecalibur', id: 'Pickaxe_ID_011_Medieval', emoji: 'Axecalibur' }, + { name: 'EVA', id: 'Pickaxe_ID_028_Space', emoji: 'EVA' }, + { name: 'AC/DC', id: 'Pickaxe_ID_013_Teslacoil', emoji: 'ACDC' }, + { name: 'Resonator', id: 'Pickaxe_ID_077_CarbideWhite', emoji: 'Resonator' }, + { name: 'Pinpoint', id: 'Pickaxe_ID_099_ModernMilitaryRed', emoji: 'Pinpoint' }, + { name: 'Stellar Axe', id: 'Pickaxe_ID_116_Celestial', emoji: 'StellarAxe' }, + { name: 'Stealth Angular Axe', id: 'Pickaxe_ID_189_StreetOpsStealth', emoji: 'StealthAngularAxe' }, + { name: 'Dark Razor', id: 'Pickaxe_ID_178_SpeedyMidnight', emoji: 'DarkRazor' }, + { name: 'Electri-claw', id: 'Pickaxe_ID_398_WildCatFemale', emoji: 'Electriclaw' }, + { name: 'Dark Splitter', id: 'Pickaxe_ID_461_SkullBriteCube', emoji: 'DarkSplitter' }, + { name: 'Merry Mint Axe', id: 'Pickaxe_ID_294_CandyCane', emoji: 'MerryMintAxe' }, + { name: 'Indigo Inverter', id: 'Pickaxe_SnowNinjaDark', emoji: 'IndigoInverter' }, + { name: 'Rose Glow', id: 'Pickaxe_ID_153_RoseLeader', emoji: 'RoseGlow' }, + { name: 'Controller', id: 'Pickaxe_ID_088_PSBurnout', emoji: 'Controller' }, + { name: 'Pneumatic Twin', id: 'Pickaxe_ID_256_TechOpsBlue', emoji: 'PneumaticTwin' }, + { name: 'Perfect Point', id: 'Pickaxe_ID_464_LongShortsMale', emoji: 'PerfectPoint' }, + { name: 'Petrol Pump', id: 'Pickaxe_RetroWheels', emoji: 'PetrolPump' }, + { name: 'Future Relic Scythe', id: 'Pickaxe_SnowSoldierFashion', emoji: 'FutureRelicScythe' }, + + // --- OT Banners (OT1–OT11) --- + { name: 'OT1 Banner', id: 'OT1Banner', emoji: 'OT1Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot1banner/icon.png' }, + { name: 'OT2 Banner', id: 'OT2Banner', emoji: 'OT2Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot2banner/icon.png' }, + { name: 'OT3 Banner', id: 'OT3Banner', emoji: 'OT3Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot3banner/icon.png' }, + { name: 'OT4 Banner', id: 'OT4Banner', emoji: 'OT4Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot4banner/icon.png' }, + { name: 'OT5 Banner', id: 'OT5Banner', emoji: 'OT5Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot5banner/icon.png' }, + { name: 'OT6 Banner', id: 'OT6Banner', emoji: 'OT6Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot6banner/icon.png' }, + { name: 'OT7 Banner', id: 'OT7Banner', emoji: 'OT7Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot7banner/icon.png' }, + { name: 'OT8 Banner', id: 'OT8Banner', emoji: 'OT8Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot8banner/icon.png' }, + { name: 'OT9 Banner', id: 'OT9Banner', emoji: 'OT9Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot9banner/icon.png' }, + { name: 'OT10 Banner', id: 'OT10Banner', emoji: 'OT10Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot10banner/icon.png' }, + { name: 'OT11 Banner', id: 'OT11Banner', emoji: 'OT11Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot11banner/icon.png' }, + + // --- Exclusive Emoticons (in-game emoji cosmetics) --- + { name: 'Skull Trooper Emoticon', id: 'Emoji_Skulltrooper', emoji: 'EmoticonSkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_skulltrooper/smallicon.png' }, + { name: 'Maevys Emoticon', id: 'Emoji_S22_Showdown_Mae', emoji: 'EmoticonMaevys', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_mae/smallicon.png' }, + { name: 'ZetFar Emoticon', id: 'Emoji_S22_Showdown_Zet', emoji: 'EmoticonZetFar', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_zet/smallicon.png' }, + + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', id: 'CID_052_Athena_Commando_F_PSBlue', emoji: 'BlueTeamLeader' }, + { name: 'Blue Striker', id: 'CID_138_Athena_Commando_M_PSBurnout', emoji: 'BlueStriker' }, + { name: 'Prodigy', id: 'CID_296_Athena_Commando_M_Math', emoji: 'Prodigy' }, + { name: 'Carbon Commando', id: 'CID_360_Athena_Commando_M_TechOpsBlue', emoji: 'CarbonCommando' }, + { name: 'Trilogy', id: 'CID_657_Athena_Commando_F_TechOpsBlue', emoji: 'Trilogy' }, + { name: 'Point Patroller', id: 'CID_711_Athena_Commando_M_LongShorts', emoji: 'PointPatroller' }, + { name: 'Cloud Striker', id: 'CID_926_Athena_Commando_F_StreetFashionDiamond', emoji: 'CloudStriker' }, + { name: 'Fixer', id: 'CID_A_101_Athena_Commando_M_TacticalWoodlandBlue', emoji: 'Fixer' }, + { name: 'Blizzard Bomber', id: 'CID_A_421_Athena_Commando_F_BlizzardBomber', emoji: 'BlizzardBomber' }, + { name: 'Jun-Hwan', id: 'Character_BlueJet', emoji: 'JunHwan' }, + { name: 'Lina Scorch', id: 'Character_RetroWheels', emoji: 'LinaScorch' }, + { name: 'Indigo Kuno', id: 'CID_660_Athena_Commando_F_BandageNinjaBlue', emoji: 'IndigoKuno' }, + + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', id: 'CID_085_Athena_Commando_M_Twitch', emoji: 'SubCommander' }, + { name: 'Havoc', id: 'CID_089_Athena_Commando_M_RetroGrey', emoji: 'Havoc' }, + { name: 'Trailblazer', id: 'CID_114_Athena_Commando_F_TacticalWoodland', emoji: 'Trailblazer' }, + + // --- Intel / PC Exclusive --- + { name: 'Reflex', id: 'CID_259_Athena_Commando_M_StreetOps', emoji: 'Reflex' }, + + // --- OG Battle Pass --- + { name: 'Sparkle Specialist', id: 'CID_039_Athena_Commando_F_Disco', emoji: 'SparkleSpecialist' }, + { name: 'Royale Knight', id: 'CID_033_Athena_Commando_F_Medieval', emoji: 'RoyaleKnight' }, + { name: 'Blue Squire', id: 'CID_032_Athena_Commando_M_Medieval', emoji: 'BlueSquire' }, + { name: 'The Reaper', id: 'CID_084_Athena_Commando_M_Assassin', emoji: 'TheReaper' }, + { name: 'Elite Agent', id: 'CID_083_Athena_Commando_F_Tactical', emoji: 'EliteAgent' }, + { name: 'Rogue Agent', id: 'CID_090_Athena_Commando_M_Tactical', emoji: 'RogueAgent' }, + { name: 'Omega', id: 'CID_116_Athena_Commando_M_CarbideBlack', emoji: 'Omega' }, + + // --- Ch2S2 Battle Pass gold styles --- + { name: 'Skye', id: 'CID_690_Athena_Commando_F_Photographer', emoji: 'Skye' }, + { name: 'TNTina', id: 'CID_691_Athena_Commando_F_TNTina', emoji: 'TNTina' }, + { name: 'Meowscles', id: 'CID_693_Athena_Commando_M_BuffCat', emoji: 'Meowscles' }, + { name: 'Midas', id: 'CID_694_Athena_Commando_M_CatBurglar', emoji: 'Midas' }, + + // --- Gaming Legends / Collab --- + { name: 'Travis Scott', id: 'CID_703_Athena_Commando_M_Cyclone', emoji: 'TravisScott' }, + { name: 'Astro Jack', id: 'CID_761_Athena_Commando_M_CycloneSpace', emoji: 'AstroJack' }, + { name: 'Kratos', id: 'CID_964_Athena_Commando_M_Historian_869BC', emoji: 'Kratos' }, + { name: 'Master Chief', id: 'CID_971_Athena_Commando_M_Jupiter_S0Z6M', emoji: 'MasterChief' }, + + // --- World Cup / Tournament --- + { name: 'World Warrior', id: 'CID_478_Athena_Commando_F_WorldCup', emoji: 'WorldWarrior' }, + + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', id: 'Character_TubaGummi', emoji: 'ColorSplashJellie' }, + + // --- Save the World Founders --- + { name: 'Rose Team Leader', id: 'CID_096_Athena_Commando_F_Founder', emoji: 'RoseTeamLeader' }, + { name: 'Warpaint', id: 'CID_095_Athena_Commando_M_Founder', emoji: 'Warpaint' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// VERIFY ROLE REQUIREMENTS +// ───────────────────────────────────────────────────────────────────────────── +const ROLE_REQUIREMENTS = [ + // OG Season 1 skins (style required) + { role: 'OG Renegade Raider', id: 'cid_028_athena_commando_f', styleTag: 'Mat3' }, + { role: 'OG Aerial Assault Trooper', id: 'cid_017_athena_commando_m', styleTag: 'Stage2' }, + { role: 'OG Ghoul Trooper', id: 'cid_029_athena_commando_f_halloween', styleTag: 'Mat3' }, + { role: 'OG Skull Trooper', id: 'cid_030_athena_commando_m_halloween', styleTag: 'Mat1' }, + // OG Battle Pass skins + { role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' }, + { role: 'Blue Squire', id: 'cid_032_athena_commando_m_medieval' }, + { role: 'Royale Knight', id: 'cid_033_athena_commando_f_medieval' }, + { role: 'Sparkle Specialist', id: 'cid_039_athena_commando_f_disco' }, + { role: 'The Reaper', id: 'cid_084_athena_commando_m_assassin' }, + { role: 'Elite Agent', id: 'cid_083_athena_commando_f_tactical' }, + { role: 'Omega', id: 'cid_116_athena_commando_m_carbideblack', styleTag: 'Stage5' }, + { role: 'Carbide', id: 'cid_115_athena_commando_m_carbideblue', styleTag: 'Stage5' }, + // Samsung bundles + { role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' }, + { role: 'Stellar Axe', id: 'pickaxe_id_116_celestial' }, + { role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' }, + { role: 'Scenario Emote', id: 'eid_kpopdance03' }, + { role: 'Glow', id: 'cid_479_athena_commando_f_davinci' }, + { role: 'Levitate Emote', id: 'eid_davinci' }, + // Device / Platform exclusives + { role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' }, + { role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' }, + { role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' }, + { role: 'Resonator', id: 'pickaxe_id_077_carbidewhite' }, + { role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' }, + { role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' }, + { role: 'Pinpoint', id: 'pickaxe_id_099_modernmilitaryred' }, + { role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' }, + { role: 'Cobalt Snowfoot', id: 'character_snowninjadark' }, + { role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' }, + { role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' }, + { role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' }, + { role: 'Reflex', id: 'cid_259_athena_commando_m_streetops' }, + { role: 'Angular Axe', id: 'pickaxe_id_118_streetops' }, + { role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' }, + { role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' }, + { role: 'Huntmaster Saber', id: 'character_masterkeyorder' }, + // PlayStation Plus exclusives + { role: 'Blue Team Leader', id: 'cid_052_athena_commando_f_psblue' }, + { role: 'Blue Striker', id: 'cid_138_athena_commando_m_psburnout' }, + { role: 'Carbon Commando', id: 'cid_360_athena_commando_m_techopsblue' }, + { role: 'Prodigy', id: 'cid_296_athena_commando_m_math' }, + { role: 'Cloud Striker', id: 'cid_926_athena_commando_f_streetfashiondiamond' }, + { role: 'Fixer', id: 'cid_a_101_athena_commando_m_tacticalwoodlandblue' }, + { role: 'Point Patroller', id: 'cid_711_athena_commando_m_longshorts' }, + // Twitch Prime exclusives + { role: 'Sub Commander', id: 'cid_085_athena_commando_m_twitch' }, + { role: 'Havoc', id: 'cid_089_athena_commando_m_retrogrey' }, + { role: 'Trailblazer', id: 'cid_114_athena_commando_f_tacticalwoodland' }, + // Exclusive Pickaxes + { role: "Raider's Revenge", id: 'pickaxe_lockjaw', styleTag: 'Stage2' }, + { role: 'AC/DC', id: 'pickaxe_id_013_teslacoil' }, + { role: 'Merry Mint Axe', id: 'pickaxe_id_294_candycane' }, + { role: 'Tenderizer', id: 'pickaxe_id_044_tacticalurbanhammer' }, + { role: 'Axe of Champions', id: 'pickaxe_id_376_fncs' }, + { role: 'Blade of Champions', id: 'pickaxe_fncs_s32' }, + // OG / Exclusive Emotes + { role: 'Fresh Emote', id: 'eid_fresh' }, + { role: 'Rambunctious Emote', id: 'eid_zippy' }, + { role: 'Tidy Emote', id: 'eid_tidy' }, + { role: 'Floss Emote', id: 'eid_floss' }, + { role: 'Take the L Emote', id: 'eid_takethelathena' }, + { role: 'Orange Justice Emote', id: 'eid_goodvibes' }, + { role: 'Ride the Pony Emote', id: 'eid_ridethepony_athena' }, + { role: 'Pony Up Emote', id: 'eid_ridetheponymultiple' }, + { role: 'Evasive Maneuvers Emote', id: 'eid_temple' }, + // Exclusive Emoticons + { role: 'Cameo Needs Loot', id: 'emoji_s20_needloot' }, + // Founder exclusives + { role: 'Rose Team Leader', id: 'cid_096_athena_commando_f_founder' }, + { role: 'Warpaint', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Fully Golden Peely', id: 'cid_701_athena_commando_m_bananaagent', styleTag: 'Stage4' }, + // Samsung Galaxy bundle items + { role: 'Galactic Disc', id: 'bid_138_celestial' }, + { role: 'Discovery Glider', id: 'glider_id_090_celestial' }, + // IKONIK bundle - Scenario already added above + // Glow bundle - Levitate already added above + // EON bundle + { role: 'Aurora Glider', id: 'glider_id_056_carbidewhite' }, + // Dark Vertex bundle + { role: 'Dark Deflector', id: 'bid_234_speedymidnight' }, + { role: 'Razor Edge', id: 'pickaxe_id_065_speedyred' }, + { role: 'Dark Forerunner Glider', id: 'glider_id_131_speedymidnight' }, + // Double Helix bundle + { role: 'Telemetry', id: 'bid_114_modernmilitaryred' }, + { role: 'Rotor Glider', id: 'glider_id_075_modernmilitaryred' }, + // Neo Versa bundle + { role: 'Neo Phrenzy', id: 'bid_288_cyberscavengerfemaleblue' }, + // Royale Bomber bundle + { role: 'Royale Air Glider', id: 'glider_id_105_snowboard' }, + // Reflex bundle + { role: 'Response Unit', id: 'bid_140_streetopsmale' }, + { role: 'Pivot Glider', id: 'glider_id_092_streetops' }, + // Wildcat bundle + { role: 'Sleek Strike', id: 'bid_521_wildcat' }, + // Rogue Spider Knight bundle + { role: 'Time Keeper', id: 'bid_395_phantom' }, + // Stealth Reflex bundle + { role: 'Stealth Response Unit', id: 'bid_249_streetopsstealth' }, + // Aerial Assault One glider (S1 exclusive) + { role: 'Aerial Assault One', id: 'glider_id_001', styleTag: 'Stage2' }, + // Extra exclusive pickaxes from master list + { role: 'Trusty No. 2', id: 'pickaxe_id_029_assassin' }, + { role: 'Party Animal', id: 'pickaxe_id_020_keg' }, + { role: 'Crowbar', id: 'pickaxe_id_092_bling' }, + { role: 'Instigator', id: 'pickaxe_id_039_tacticalblack' }, + // Missing emotes from master list + { role: "Widow's Pirouette Emote", id: 'eid_ashtonboardwalk' }, + { role: 'Hot Marat Emote', id: 'eid_wir' }, + { role: 'Kiss the Cup Emote', id: 'eid_trophycelebration' }, + // Missing skins from master list + { role: 'Gildedguy', id: 'cid_a_141_athena_commando_m_dojo' }, + { role: 'Tart Tycoon', id: 'cid_851_athena_commando_m_bittenhead' }, + // Gliders + { role: 'Mako Glider', id: 'glider_warthog' }, + // Rare / STW / Creator banners (checked via homebase_banners in common_core) + { role: 'Alpha Tester Banner', bannerId: 'OtherBanner51' }, + { role: 'World Cup Banner', bannerId: 'BRWorldCup2019' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner1' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner2' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner3' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner4' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner5' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner6' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner7' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner8' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner9' }, + // STW Founder banners + { role: 'STW Founder Banner', bannerId: 'FounderTier5Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier4Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier3Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier2Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier1Banner1' }, + // STW Endurance banners + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater04' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater03' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater02' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater01' }, + // STW Survival / Wargames banners + { role: 'STW Event Banner', bannerId: 'SurvivalBannerStorm2018Hard' }, + { role: 'STW Event Banner', bannerId: 'SurvivalBannerHolidayHard' }, + { role: 'STW Event Banner', bannerId: 'WargamesTprifts' }, + { role: 'STW Event Banner', bannerId: 'WargamesTiki' }, + { role: 'STW Event Banner', bannerId: 'WargamesPods' }, + // STW Achievement banners + { role: 'STW Achievement Banner', bannerId: 'AchievementUnspeakableHorrors' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementWorldExplorer' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGuardianAngel' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementPlaysWellWithOthers' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementTalentedBuilder' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGoGnome' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementLootLegend' }, + // FNCS / Skirmish / World Cup banners + { role: 'FNCS Banner', bannerId: 'BRS23_FNCS_Drops' }, + { role: 'FNCS Banner', bannerId: 'S11FNCS' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishBushBandits' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishDustyDogs' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishFortKnights' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishLuckyLlamas' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishRiftRaiders' }, + // Season Level 100 banners + { role: 'Season Level 100 Banner', bannerId: 'BRS10Level100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS9Lvl100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS8Level100' }, + // Creator / Influencer banners 10-58 + { role: 'Creator Banner', bannerId: 'InfluencerBanner10' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner11' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner12' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner13' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner14' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner15' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner16' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner17' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner18' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner19' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner20' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner21' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner22' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner23' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner24' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner25' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner26' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner27' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner28' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner29' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner30' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner31' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner32' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner33' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner34' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner35' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner36' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner37' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner38' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner39' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner40' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner41' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner42' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner43' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner44' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner45' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner46' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner47' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner48' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner49' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner50' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner51' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner52' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner53' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner54' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner55' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner56' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner57' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner58' }, + // OT Banners + { role: 'OT1 Banner', bannerId: 'OT1Banner' }, + { role: 'OT2 Banner', bannerId: 'OT2Banner' }, + { role: 'OT3 Banner', bannerId: 'OT3Banner' }, + { role: 'OT4 Banner', bannerId: 'OT4Banner' }, + { role: 'OT5 Banner', bannerId: 'OT5Banner' }, + { role: 'OT6 Banner', bannerId: 'OT6Banner' }, + { role: 'OT7 Banner', bannerId: 'OT7Banner' }, + { role: 'OT8 Banner', bannerId: 'OT8Banner' }, + { role: 'OT9 Banner', bannerId: 'OT9Banner' }, + { role: 'OT10 Banner', bannerId: 'OT10Banner' }, + { role: 'OT11 Banner', bannerId: 'OT11Banner' }, + // STW Founders + { role: 'STW Founders', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Ultimate STW Founders', id: 'tiertoken_collectionbook_ultimate', stw: true }, +] + +// ───────────────────────────────────────────────────────────────────────────── +// EPIC OAUTH CONSTANTS +// ───────────────────────────────────────────────────────────────────────────── +const SWITCH_TOKEN = 'OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDAxYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3'; +const IOS_TOKEN = 'M2Y2OWU1NmM3NjQ5NDkyYzhjYzI5ZjFhZjA4YThhMTI6YjUxZWU5Y2IxMjIzNGY1MGE2OWVmYTY3ZWY1MzgxMmU='; +const EPIC_OAUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; +const EPIC_OAUTH_PROD03 = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token'; +const EPIC_EXCHANGE = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange'; +const EPIC_DEVICE_AUTH = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/deviceAuthorization'; + +const pending = new Map(); +const verifiedUsers = new Map(); // userId -> { guildId, assignedRoles: string[], epicName: string } +const LOG_CHANNEL_ID = '1517695849952706642'; + +// ───────────────────────────────────────────────────────────────────────────── +// SHARED HELPERS +// ───────────────────────────────────────────────────────────────────────────── +async function fetchWithTimeout(url, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { return await fetch(url, { signal: ctrl.signal }); } + finally { clearTimeout(timer); } +} + +// ─── get icon URL — build directly from known CDN pattern, no API roundtrip ── +async function fetchIconUrl(cosmeticId) { + // Direct CDN URL — fortnite-api.com images are always at this pattern + const url = `https://fortnite-api.com/images/cosmetics/br/${cosmeticId.toLowerCase()}/icon.png`; + const res = await fetchWithTimeout(url, 8000); + if (res.ok) return url; + // Fallback to API lookup if direct URL 404s + const apiRes = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`, 8000); + if (!apiRes.ok) throw new Error(`API ${apiRes.status} for ${cosmeticId}`); + const json = await apiRes.json(); + if (!json.data?.images?.icon) throw new Error(`No icon for ${cosmeticId}`); + return json.data.images.icon; +} + +async function downloadAndProcess(imageUrl) { + const res = await fetchWithTimeout(imageUrl, 15000); + if (!res.ok) throw new Error(`Download failed ${res.status}`); + const raw = Buffer.from(await res.arrayBuffer()); + return sharp(raw) + .resize(128, 128, { fit: 'cover', position: 'centre', kernel: 'lanczos3' }) + .sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + +async function createEmojiSafe(guild, buffer, name, reason) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { + return await guild.emojis.create({ attachment: buffer, name, reason }); + } catch (err) { + const isRL = err.status === 429 || err.httpStatus === 429 || + (err.message || '').toLowerCase().includes('rate limit'); + if (isRL) { + const wait = ((err.retryAfter || 5) * 1000) + 1000; + console.warn(` ⏳ Rate limited "${name}", waiting ${Math.ceil(wait/1000)}s...`); + await new Promise(r => setTimeout(r, wait)); + } else { + throw err; + } + } + } + throw new Error(`Gave up on emoji "${name}"`); +} + +// ─── Epic helpers ───────────────────────────────────────────────────────────── +async function getSwitchAccessToken() { + const res = await fetch(EPIC_OAUTH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `basic ${SWITCH_TOKEN}` }, + body: 'grant_type=client_credentials', + }); + return (await res.json()).access_token; +} + +async function createDeviceCode(token) { + const res = await fetch(EPIC_DEVICE_AUTH, { + method: 'POST', + headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return res.json(); +} + +async function waitForLogin(deviceCode, maxMs = 300000) { + const start = Date.now(); + while (Date.now() - start < maxMs) { + await new Promise(r => setTimeout(r, 10000)); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${SWITCH_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=device_code&device_code=${deviceCode}`, + }); + const data = await res.json(); + if (res.status === 200 && data.access_token) return data; + const err = data.errorCode || ''; + if (err.includes('not_found') || err.includes('expired')) return null; + } + return null; +} + +async function exchangeForIosToken(accessToken) { + const exRes = await fetch(EPIC_EXCHANGE, { headers: { 'Authorization': `bearer ${accessToken}` } }); + const exData = await exRes.json(); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${IOS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=exchange_code&exchange_code=${exData.code}`, + }); + return res.json(); +} + +async function getProfile(accountId, accessToken, profileId) { + const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=${profileId}&rvn=-1`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Authorization': `bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + return res.json(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SKIN CHECKER IMAGE RENDERER (RiftCheckerBot style) +// ───────────────────────────────────────────────────────────────────────────── +const { createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'); +const path = require('path'); + +// Register Fortnite font (Burbank Big Condensed Black) + Tahoma +GlobalFonts.registerFromPath( + path.join(__dirname, 'BurbankBigCondensed-Black.ttf'), 'Burbank' +); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahoma.ttf', 'Tahoma'); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahomabd.ttf', 'TahomaB'); + +// Custom background URL — set with !setbg , cleared with !clearbg +let customBgUrl = null; + +const ICON = 96; +const COLS = 12; // wider rows = shorter images +const LABEL = 26; +const CELL_H = ICON + LABEL; +const PAD = 14; +const HDR_H = 72; +const FTR_H = 36; + +const RC = { + common: '#c8c8c8', uncommon: '#60aa3a', rare: '#4990e2', + epic: '#874bd2', legendary: '#d3782e', mythic: '#ffd700', + icon: '#54ceec', marvel: '#e63c3c', dark: '#641464', + frozen: '#94dffc', lava: '#ca4d1d', shadow: '#505050', + gaming: '#1a8e48', slurp: '#00e9b0', +}; + +const OG_STYLE_ICONS = { + 'cid_029_athena_commando_f_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png', + 'cid_030_athena_commando_m_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png', + 'cid_028_athena_commando_f': 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 'cid_701_athena_commando_m_bananaagent': 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png', + 'cid_116_athena_commando_m_carbideblack': 'https://fortnite-api.com/images/cosmetics/br/cid_116_athena_commando_m_carbideblack/variants/parts/stage1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Cache the logo so we only fetch once per session +let _logoBuffer = null; +async function getLogoBuffer() { + if (_logoBuffer) return _logoBuffer; + try { + const res = await fetchWithTimeout( + 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 8000 + ); + if (res.ok) _logoBuffer = Buffer.from(await res.arrayBuffer()); + } catch { _logoBuffer = null; } + return _logoBuffer; +} + +const CACHE_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cache'); +const RARITY_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'styles', 'rift', 'rarity'); +const ICON_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cosmetic_icons'); +const fs = require('fs'); + +// Cosmetics that have special OG variant images in local cache +// key = lowercase cosmetic ID, value = local cache filename +const LOCAL_OG_ICONS = { + 'cid_029_athena_commando_f_halloween': 'pink_ghoul.png', // Pink Ghoul + 'cid_030_athena_commando_m_halloween': 'purple_skull.png', // Purple Skull + 'cid_028_athena_commando_f': 'og_rene.png', // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': 'og_aat.png', // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': 'omega_max.png', // Max Omega + 'cid_701_athena_commando_m_bananaagent': 'gold_peely.png', // Gold Agent Peely +}; + +// Remote OG variant URLs for cosmetics not in local cache +const REMOTE_OG_ICONS = { + 'pickaxe_lockjaw': 'https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png', + 'glider_id_001': 'https://fortnite-api.com/images/cosmetics/br/glider_id_001/variants/particle/particle1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Load a rarity background image from local cache +const rarityImageCache = {}; +async function getRarityImage(rarity) { + const key = (rarity || 'common').toLowerCase(); + if (rarityImageCache[key]) return rarityImageCache[key]; + const filePath = path.join(RARITY_DIR, `${key}.png`); + if (fs.existsSync(filePath)) { + try { + const img = await loadImage(fs.readFileSync(filePath)); + rarityImageCache[key] = img; + return img; + } catch { /* fall through */ } + } + // fallback to common + const fallback = path.join(RARITY_DIR, 'common.png'); + if (fs.existsSync(fallback)) { + const img = await loadImage(fs.readFileSync(fallback)); + rarityImageCache['common'] = img; + return img; + } + return null; +} + +// Get cosmetic icon — local cache first, then remote +async function fetchSkinImageBuffer(cosmeticId, useOgStyle = false) { + const id = cosmeticId.toLowerCase(); + + // 1. Try local OG variant if requested + if (useOgStyle && LOCAL_OG_ICONS[id]) { + const localPath = path.join(CACHE_DIR, LOCAL_OG_ICONS[id]); + if (fs.existsSync(localPath)) return fs.readFileSync(localPath); + } + + // 2. Try remote OG variant if requested + if (useOgStyle && REMOTE_OG_ICONS[id]) { + try { + const res = await fetchWithTimeout(REMOTE_OG_ICONS[id], 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + } + + // 3. Check if this is a banner ID — fetch from banner API + const bannerPrefixes = ['ot','influencerbanner','foundertier','brs','survival','endurance','wargames','achievement','otherbanner','skirmish','worldcup']; + if (bannerPrefixes.some(p => id.startsWith(p))) { + // Try local RiftCheckerBot cache first + try { + const files = fs.readdirSync(CACHE_DIR); + const match = files.find(f => f.toLowerCase() === id + '.png' || f.toLowerCase() === cosmeticId + '.png'); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + // Fetch from banner API + try { + const bMeta = await getBannerMeta(); + const iconUrl = bMeta[id]?.icon; + if (iconUrl) { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } + } catch { /* fall through */ } + return null; + } + + // 4. Try local cache with exact cosmetic ID match + try { + const files = fs.readdirSync(CACHE_DIR); + const baseName = cosmeticId + '.png'; + const match = files.find(f => f.toLowerCase() === baseName.toLowerCase()); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + + // 5. Fortnite API CDN + const iconUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/icon.png`; + try { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + + // 6. Fallback: smallIcon + const smallUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/smallicon.png`; + try { + const res = await fetchWithTimeout(smallUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* give up */ } + + return null; +} + +async function renderLockerImage(items, countLabel, subtitle, submittedBy = null, ogStyleOwned = null) { + if (!items || items.length === 0) return null; + + const LOGO_SIZE = HDR_H - 8; + const rows = Math.ceil(items.length / COLS); + const cols = Math.min(items.length, COLS); + const W = Math.max(cols * ICON + PAD * 2, 400); + const H = HDR_H + rows * CELL_H + PAD + FTR_H; + + const canvas = createCanvas(W, H); + const ctx = canvas.getContext('2d'); + + // ── Background ─────────────────────────────────────────────────────────── + if (customBgUrl) { + try { + const res = await fetchWithTimeout(customBgUrl, 10000); + if (res.ok) { + let rawBuf = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get('content-type') || ''; + const isGif = customBgUrl.toLowerCase().endsWith('.gif') || contentType.includes('gif'); + // For GIFs extract first frame; for everything else just resize + const processed = await sharp(rawBuf, { animated: isGif }) + .resize(W, H, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + ctx.drawImage(await loadImage(processed), 0, 0, W, H); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(0, 0, W, H); + } else { throw new Error('bg fetch failed'); } + } catch { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + } else { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; + for (let c = 0; c <= cols; c++) { + ctx.beginPath(); ctx.moveTo(PAD + c * ICON, HDR_H); ctx.lineTo(PAD + c * ICON, HDR_H + rows * CELL_H); ctx.stroke(); + } + for (let r = 0; r <= rows; r++) { + ctx.beginPath(); ctx.moveTo(PAD, HDR_H + r * CELL_H); ctx.lineTo(PAD + cols * ICON, HDR_H + r * CELL_H); ctx.stroke(); + } + + // ── Logo ───────────────────────────────────────────────────────────────── + const logoBuf = await getLogoBuffer(); + let logoEndX = PAD; + if (logoBuf) { + try { + const logoResized = await sharp(logoBuf).resize(LOGO_SIZE, LOGO_SIZE, { fit: 'cover' }).png().toBuffer(); + const logoImg = await loadImage(logoResized); + const cx = PAD + LOGO_SIZE / 2, cy = 4 + LOGO_SIZE / 2, lr = LOGO_SIZE / 2; + ctx.save(); + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.clip(); + ctx.drawImage(logoImg, PAD, 4, LOGO_SIZE, LOGO_SIZE); + ctx.restore(); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.stroke(); + logoEndX = PAD + LOGO_SIZE + 10; + } catch { logoEndX = PAD; } + } + + // ── Header text ─────────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 26px TahomaB'; + ctx.fillText(countLabel, logoEndX, 40); + ctx.fillStyle = '#aaaaaa'; + ctx.font = '13px Tahoma'; + ctx.fillText(subtitle.toUpperCase(), logoEndX, 60); + + // ── Cosmetic grid ───────────────────────────────────────────────────────── + let idx = 0; + for (const item of items) { + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const x = PAD + col * ICON; + const y = HDR_H + row * CELL_H; + const id = item.id.toLowerCase(); + + // Determine if we should show OG style for this item + const hasOgRequirement = !!OG_STYLE_REQUIREMENTS[id]; + const ownsOgStyle = ogStyleOwned ? ogStyleOwned.has(id) : false; + const useOg = hasOgRequirement && ownsOgStyle; + + // Effective rarity: mythic if OG style owned, otherwise use metaMap rarity + const effectiveRarity = useOg ? 'mythic' : (item.rarity || 'common'); + + // ── Draw rarity background from local cache ─────────────────────────── + const rarityFilePath = path.join(RARITY_DIR, `${effectiveRarity}.png`); + if (fs.existsSync(rarityFilePath)) { + try { + const rarBuf = await sharp(fs.readFileSync(rarityFilePath)) + .resize(ICON, ICON, { fit: 'fill' }).png().toBuffer(); + ctx.drawImage(await loadImage(rarBuf), x, y, ICON, ICON); + } catch { /* skip */ } + } else { + ctx.fillStyle = (RC[effectiveRarity] || RC.common) + '28'; + ctx.fillRect(x, y, ICON, ICON); + } + + // ── Gradient border around the cell ────────────────────────────────── + const borderColor = RC[effectiveRarity] || RC.common; + // Top edge gradient (left to right) + const topGrad = ctx.createLinearGradient(x, y, x + ICON, y); + topGrad.addColorStop(0, borderColor + '00'); + topGrad.addColorStop(0.3, borderColor + 'ff'); + topGrad.addColorStop(0.7, borderColor + 'ff'); + topGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = topGrad; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ICON, y); ctx.stroke(); + + // Bottom edge gradient + const botGrad = ctx.createLinearGradient(x, y + ICON, x + ICON, y + ICON); + botGrad.addColorStop(0, borderColor + '00'); + botGrad.addColorStop(0.3, borderColor + 'ff'); + botGrad.addColorStop(0.7, borderColor + 'ff'); + botGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = botGrad; + ctx.beginPath(); ctx.moveTo(x, y + ICON); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // Left edge gradient (top to bottom) + const leftGrad = ctx.createLinearGradient(x, y, x, y + ICON); + leftGrad.addColorStop(0, borderColor + '00'); + leftGrad.addColorStop(0.3, borderColor + 'ff'); + leftGrad.addColorStop(0.7, borderColor + 'ff'); + leftGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = leftGrad; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + ICON); ctx.stroke(); + + // Right edge gradient + const rightGrad = ctx.createLinearGradient(x + ICON, y, x + ICON, y + ICON); + rightGrad.addColorStop(0, borderColor + '00'); + rightGrad.addColorStop(0.3, borderColor + 'ff'); + rightGrad.addColorStop(0.7, borderColor + 'ff'); + rightGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = rightGrad; + ctx.beginPath(); ctx.moveTo(x + ICON, y); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // ── Draw cosmetic icon ──────────────────────────────────────────────── + const buf = await fetchSkinImageBuffer(item.id, useOg); + if (buf) { + try { + const resized = await sharp(buf).resize(ICON - 6, ICON - 6, { fit: 'cover' }).png().toBuffer(); + ctx.drawImage(await loadImage(resized), x + 3, y + 3, ICON - 6, ICON - 6); + } catch { /* skip */ } + } + + // ── Name label ──────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'center'; + const maxW = ICON - 4; + let name = item.name || ''; + while (name.length > 3 && ctx.measureText(name).width > maxW) name = name.slice(0, -1); + if (name !== item.name) name = name.slice(0, -1) + '..'; + ctx.fillText(name, x + ICON / 2, y + ICON + 17, maxW); + ctx.textAlign = 'left'; + idx++; + } + + // ── Footer ──────────────────────────────────────────────────────────────── + const footerY = H - FTR_H; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, footerY, W, FTR_H); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, footerY); ctx.lineTo(W, footerY); ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px TahomaB'; + ctx.fillText(submittedBy ? `Submitted by: ${submittedBy}` : 'Made by jitterbuxg', PAD, footerY + 14); + ctx.fillStyle = '#ffd700'; + ctx.font = '10px Tahoma'; + ctx.fillText('.gg/collecting', PAD, footerY + 28); + + ctx.fillStyle = '#888888'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'right'; + ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), W - PAD, footerY + 21); + ctx.textAlign = 'left'; + + return canvas.toBuffer('image/png'); +} + +// Banner metadata cache — fetched once from fortnite-api.com/v1/banners +let bannerMetaCache = null; +async function getBannerMeta() { + if (bannerMetaCache) return bannerMetaCache; + try { + const res = await fetchWithTimeout('https://fortnite-api.com/v1/banners', 10000); + if (res.ok) { + const json = await res.json(); + const map = {}; + for (const b of (json.data || [])) { + map[b.id.toLowerCase()] = { + name: b.devName || b.id, + icon: b.images?.icon || b.images?.smallIcon, + rarity: 'common', + }; + } + bannerMetaCache = map; + return map; + } + } catch { /* ignore */ } + return {}; +} + +const BASE_EXCLUSIVE_BANNER_IDS = [ + // OT Banners + 'ot1banner','ot2banner','ot3banner','ot4banner','ot5banner', + 'ot6banner','ot7banner','ot8banner','ot9banner','ot10banner','ot11banner', + // Alpha tester / rare + 'otherbanner51', + // World Cup / FNCS / Skirmish + 'brworldcup2019','brs9worldcup2019','brs23_fncs_drops','s11fncs', + 'brskirmishbushbandits','brskirmishdustydogs','brskirmishfortknights', + 'brskirmishluckyllamas','brskirmishriftraiders', + // Season level 100 + 'brs8level100','brs9lvl100','brs10level100', +]; + +function looksLikeBannerId(rawId) { + const id = String(rawId || '').trim().toLowerCase(); + if (!id) return false; + return ( + id.includes('banner') || + id.startsWith('ot') || + id.startsWith('brs') || + id.startsWith('brseason') || + id.startsWith('brworldcup') || + id.startsWith('brskirmish') || + id.startsWith('endurance') || + id.startsWith('wargames') || + id.startsWith('survival') || + id.startsWith('achievement') || + id.startsWith('starlight') || + id.startsWith('winter2018') || + id.startsWith('newsletter') || + id.startsWith('horde') + ); +} + +function getExclusiveBannerIds() { + const ids = new Set(BASE_EXCLUSIVE_BANNER_IDS); + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) ids.add(req.bannerId.toLowerCase()); + } + + try { + const listPath = path.join(__dirname, 'RiftCheckerBot-main', 'exclusive.txt'); + const lines = fs.readFileSync(listPath, 'utf8').split(/\r?\n/); + for (const line of lines) { + const id = line.trim().toLowerCase(); + if (looksLikeBannerId(id)) ids.add(id); + } + } catch { /* bundled list is optional */ } + + return ids; +} + +function commonCoreOwnsId(ccItems, wantedId) { + const needle = wantedId.toLowerCase(); + return Object.values(ccItems || {}).some(item => { + const templateId = (item.templateId || '').toLowerCase(); + const itemId = templateId.split(':').pop(); + return itemId === needle || templateId.includes(needle); + }); +} + +function collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap = null) { + const items = []; + const seen = new Set(); + + for (const id of getExclusiveBannerIds()) { + if (seen.has(id) || !commonCoreOwnsId(ccItems, id)) continue; + seen.add(id); + + const info = bannerMeta[id] || {}; + const name = info.name || id; + items.push({ id, name, rarity: 'mythic' }); + if (metaMap) metaMap[id] = { name, rarity: 'mythic' }; + } + + return items; +} + +// Batch fetch cosmetic metadata (name + rarity) for up to 50 IDs at once +async function fetchCosmeticMeta(ids) { + const map = {}; + if (!ids || ids.length === 0) return map; + for (let i = 0; i < ids.length; i += 50) { + const chunk = ids.slice(i, i + 50); + try { + const qs = chunk.map(id => `id=${encodeURIComponent(id)}`).join('&'); + const res = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/search/ids?language=en&${qs}`, 10000); + if (!res.ok) continue; + const json = await res.json(); + for (const item of (json.data || [])) { + map[item.id.toLowerCase()] = { name: item.name, rarity: item.rarity?.value || 'common' }; + } + } catch { /* skip chunk on error */ } + } + return map; +} + +// Cosmetics that have OG style requirements - id -> one or more owned style tags +const OG_STYLE_REQUIREMENTS = { + 'cid_029_athena_commando_f_halloween': ['Mat3'], // Pink Ghoul + 'cid_030_athena_commando_m_halloween': ['Mat1'], // Purple Skull + 'cid_028_athena_commando_f': ['Mat3'], // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': ['Stage2'], // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': ['Stage5'], // Max Omega + 'cid_701_athena_commando_m_bananaagent': ['Stage4'], // Gold Agent Peely + 'pickaxe_lockjaw': ['Stage2'], // Black & Gold Raider's Revenge + 'glider_id_001': ['Stage2'], // OG Aerial Assault One +}; + +function requiredStyleTags(reqOrTags) { + if (!reqOrTags) return []; + if (Array.isArray(reqOrTags)) return reqOrTags; + if (Array.isArray(reqOrTags.styleTags)) return reqOrTags.styleTags; + return reqOrTags.styleTag ? [reqOrTags.styleTag] : []; +} + +function ownsAnyStyleTag(ownedStyleSet, tags) { + const wanted = requiredStyleTags(tags).map(tag => String(tag).toLowerCase()); + if (wanted.length === 0) return true; + if (!ownedStyleSet) return false; + const owned = [...ownedStyleSet].map(tag => String(tag).toLowerCase()); + return wanted.some(tag => owned.includes(tag)); +} + +function qualifiesForRequirement(req, ownedMap) { + const id = req.id.toLowerCase(); + if (!ownedMap[id]) return false; + return ownsAnyStyleTag(ownedMap[id], req); +} + +function uniqueIds(ids) { + return [...new Set(ids.filter(Boolean))]; +} + +function buildOwnedMap(athenaData) { + const owned = {}; // id -> Set of owned style tags + const ogStyleOwned = new Set(); + const outfits = []; + const pickaxes = []; + const emotes = []; + const emoticons = []; // AthenaEmoji type (in-game emoticons, NOT emotes) + const backblings = []; + const gliders = []; + const sprays = []; + const items = athenaData?.profileChanges?.[0]?.profile?.items || {}; + for (const item of Object.values(items)) { + const tpl = (item.templateId || '').toLowerCase(); + if (!tpl.startsWith('athena') && !tpl.startsWith('character')) continue; + const id = tpl.split(':')[1] || ''; + if (!owned[id]) owned[id] = new Set(); + for (const v of (item.attributes?.variants || [])) + for (const tag of (v.owned || [])) owned[id].add(tag); + + if (tpl.startsWith('athenacharacter:') || tpl.startsWith('character:')) outfits.push(id); + else if (tpl.startsWith('athenapickaxe:')) pickaxes.push(id); + else if (tpl.startsWith('athenadance:')) emotes.push(id); + else if (tpl.startsWith('athenaemoji:')) emoticons.push(id); + else if (tpl.startsWith('athenabackpack:')) backblings.push(id); + else if (tpl.startsWith('athenaglider:')) gliders.push(id); + else if (tpl.startsWith('athenasprays:') || tpl.startsWith('athenaspray:')) sprays.push(id); + } + // Populate ogStyleOwned after all variants are collected + for (const id of Object.keys(owned)) { + if (OG_STYLE_REQUIREMENTS[id] && ownsAnyStyleTag(owned[id], OG_STYLE_REQUIREMENTS[id])) { + ogStyleOwned.add(id); + } + } + return { + owned, + ogStyleOwned, + outfits: uniqueIds(outfits), + pickaxes: uniqueIds(pickaxes), + emotes: uniqueIds(emotes), + emoticons: uniqueIds(emoticons), + backblings: uniqueIds(backblings), + gliders: uniqueIds(gliders), + sprays: uniqueIds(sprays), + }; +} + +function getRolesForLocker(ownedMap, ccItems) { + const earned = []; + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) { + const has = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (has) earned.push(req.role); + continue; + } + if (req.stw) { + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + if (ccIds.some(cc => cc.includes(req.id.toLowerCase()))) earned.push(req.role); + continue; + } + if (qualifiesForRequirement(req, ownedMap)) earned.push(req.role); + } + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + const hasFounder = ccIds.some(cc => cc.includes('founder')); + const hasUltimate = ccIds.some(cc => cc.includes('tiertoken_collectionbook_ultimate')); + if (hasUltimate) { earned.push('Ultimate STW Founders'); earned.push('STW Founders'); } + else if (hasFounder) earned.push('STW Founders'); + return [...new Set(earned)]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOT READY +// ───────────────────────────────────────────────────────────────────────────── +client.once('ready', async () => { + console.log(`✅ Logged in as ${client.user.tag}`); + console.log('Commands: !setuproles | !addicons | !skipemoji | !stopemojis | !setroleicons | !removeoldemojis | !verify'); + + // Set bot avatar to Renegade Raider Black & Gold style + try { + const imgRes = await fetch('https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png'); + const imgBuf = Buffer.from(await imgRes.arrayBuffer()); + await client.user.setAvatar(imgBuf); + console.log('✅ Bot avatar set to Renegade Raider Black & Gold.'); + } catch (err) { + console.warn('Could not set avatar (may already be set or rate limited):', err.message); + } + + // Register /commands slash command to guild for instant availability + const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const slashCommands = [ + new SlashCommandBuilder() + .setName('commands') + .setDescription('List all bot commands') + .toJSON(), + ]; + try { + await rest.put(Routes.applicationGuildCommands(client.user.id, process.env.EMOJI_GUILD_ID), { body: slashCommands }); + console.log('✅ Slash commands registered to guild.'); + } catch (err) { + console.error('Failed to register slash commands:', err.message); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /commands slash command +// ───────────────────────────────────────────────────────────────────────────── +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand() || interaction.commandName !== 'commands') return; + + const embed = new EmbedBuilder() + .setColor(0x1a1a2e) + .setAuthor({ + name: 'jitterbuxg', + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Bot Commands') + .setThumbnail('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .addFields( + { + name: 'Admin Setup', + value: [ + '`!setuproles` — Create all Fortnite roles', + '`!addicons` — Upload skin icons as server emojis', + '`!setroleicons` — Apply emojis as role icons', + '`!removeoldemojis` — Wipe all emojis for a fresh upload', + '`!setbg ` — Set custom background (supports GIFs)', + '`!clearbg` — Reset to default background', + ].join('\n'), + inline: false, + }, + { + name: 'During !addicons', + value: '`!skipemoji` — Skip current · `!stopemojis` — Cancel session', + inline: false, + }, + { + name: 'Verification', + value: '`!verify` — Link your Epic account and auto-assign roles based on your locker', + inline: false, + }, + { + name: 'What !verify checks', + value: [ + '**OG Skins** — Renegade Raider (B&G), Ghoul Trooper (Pink), Skull Trooper (Purple), + more', + '**Exclusives** — Galaxy, Ikonik, Glow, Honor Guard, Double Helix, Wildcat, + more', + '**Pickaxes** — Raider\'s Revenge (B&G), Axe of Champions, AC/DC, + more', + '**Emotes** — Fresh, Floss, Take the L, Orange Justice, + more', + '**Banners** — OT1–OT11, Creator Banners, STW Founders, + more', + ].join('\n'), + inline: false, + } + ) + .setImage('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .setFooter({ text: 'Made by jitterbuxg • .gg/collecting' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: false }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setbg / !clearbg — custom background for skin checker images +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + if (message.content.startsWith('!setbg ')) { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + const url = message.content.slice(7).trim(); + if (!url.startsWith('http')) return message.reply('Please provide a valid image URL.'); + customBgUrl = url; + return message.reply(`Background set. All future skin checker images will use this background.`); + } + if (message.content === '!clearbg') { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + customBgUrl = null; + return message.reply('Background cleared. Back to default dark gradient.'); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setuproles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setuproles') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply(`Creating up to ${roles.length} roles...`); + let created = 0, skipped = 0; + const existing = message.guild.roles.cache.map(r => r.name.toLowerCase()); + + for (const role of roles) { + if (existing.includes(role.name.toLowerCase())) { skipped++; continue; } + try { + await message.guild.roles.create({ name: role.name, color: role.color, reason: 'Bulk role setup' }); + console.log(`Created: ${role.name}`); + created++; + await new Promise(r => setTimeout(r, 300)); + } catch (err) { + console.error(`Failed "${role.name}": ${err.message}`); + } + } + message.channel.send(`Done! Created **${created}** roles, skipped **${skipped}** existing.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !removeoldemojis +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!removeoldemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Removing ALL emojis from **${emojiGuild.name}**...`); + console.log(`🎯 Clearing all emojis from: ${emojiGuild.name} (${emojiGuild.id})`); + await emojiGuild.emojis.fetch(); + + const allEmojis = [...emojiGuild.emojis.cache.values()]; + console.log(`📋 Found ${allEmojis.length} emojis to delete`); + let removed = 0; + + for (const emoji of allEmojis) { + try { + await emoji.delete('Clearing all emojis for fresh upload'); + console.log(`🗑 Deleted: ${emoji.name}`); + removed++; + await new Promise(r => setTimeout(r, 400)); + } catch (err) { + console.error(`Failed to delete ${emoji.name}: ${err.message}`); + } + } + message.channel.send(`Removed **${removed}** emojis from **${emojiGuild.name}**. Run !addicons to re-upload.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !addicons + !skipemoji + !stopemojis +// ───────────────────────────────────────────────────────────────────────────── + +// Shared state for the active addicons session +let emojiSession = null; // { resolve, name, channel } + +// !skipemoji — skip the currently stuck emoji +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!skipemoji') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply(`Skipping **${emojiSession.name}**...`); + emojiSession.resolve('skip'); +}); + +// !stopemojis — stop the whole session +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!stopemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply('Stopping emoji upload session.'); + emojiSession.resolve('stop'); +}); + +// Lock to prevent !addicons running multiple times simultaneously +let addIconsRunning = false; + +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!addicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + if (addIconsRunning) + return message.reply('An emoji upload is already in progress. Wait for it to finish or use `!stopemojis` to cancel.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Uploading ${roleIconMap.length} skin icons to **${emojiGuild.name}**.\nType \`!skipemoji\` to skip a stuck one, or \`!stopemojis\` to cancel.`); + await emojiGuild.emojis.fetch(); + const existing = new Set(emojiGuild.emojis.cache.map(e => e.name.toLowerCase())); + let added = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + if (existing.has(entry.emoji.toLowerCase())) { + console.log(`⏭ Exists: ${entry.emoji}`); + skipped++; + continue; + } + + // Set up skip/stop listener for this entry + let sessionResolve; + const skipPromise = new Promise(res => { sessionResolve = res; }); + emojiSession = { resolve: sessionResolve, name: entry.name, channel: message.channel }; + + process.stdout.write(`📥 ${entry.name} — fetching... `); + try { + const iconUrl = entry.overrideUrl || entry.bannerUrl || await fetchIconUrl(entry.id); + process.stdout.write(`processing... `); + const buffer = await downloadAndProcess(iconUrl); + process.stdout.write(`uploading... `); + + // Race the upload against a skip/stop signal + const result = await Promise.race([ + createEmojiSafe(emojiGuild, buffer, entry.emoji, `Skin icon: ${entry.name}`) + .then(() => 'done'), + skipPromise, + ]); + + if (result === 'stop') { + console.log('🛑 Stopped by user.'); + emojiSession = null; + return message.channel.send(`Stopped. Added **${added}**, skipped **${skipped}**, failed **${failed}**.`); + } + if (result === 'skip') { + console.log(`⏭ Skipped: ${entry.name}`); + skipped++; + emojiSession = null; + continue; + } + + console.log(`✅`); + added++; + } catch (err) { + console.log(`❌ ${err.message}`); + failed++; + } + + emojiSession = null; + await new Promise(r => setTimeout(r, 2000)); + } + + emojiSession = null; + message.channel.send(`Done. Added **${added}** emojis to **${emojiGuild.name}**. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setroleicons +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setroleicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply('Applying emojis to roles...'); + await emojiGuild.emojis.fetch(); + await message.guild.roles.fetch(); + let updated = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + const role = message.guild.roles.cache.find(r => r.name.toLowerCase() === entry.name.toLowerCase()); + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === entry.emoji.toLowerCase()); + if (!role) { console.log(`⏭ Role not found: ${entry.name}`); skipped++; continue; } + if (!emoji) { console.log(`⚠ Emoji missing: ${entry.emoji} — run !addicons first`); skipped++; continue; } + try { + await role.setIcon(emoji); + console.log(`✅ ${entry.name} → :${entry.emoji}:`); + updated++; + } catch (err) { + console.error(`❌ "${entry.name}": ${err.message}`); + failed++; + } + await new Promise(r => setTimeout(r, 500)); + } + message.channel.send(`Done. Applied icons to **${updated}** roles. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !unverify — remove verification and strip roles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!unverify') return; + + const userId = message.author.id; + + if (!verifiedUsers.has(userId)) + return message.reply('You are not currently verified. Use `!verify` to link your account.'); + + const prev = verifiedUsers.get(userId); + const guild = client.guilds.cache.get(prev.guildId) || message.guild; + + if (guild) { + try { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) { + for (const roleName of prev.assignedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (role && member.roles.cache.has(role.id)) { + await member.roles.remove(role, 'User unverified').catch(() => null); + } + } + } + } catch (err) { + console.error('Unverify role removal error:', err.message); + } + } + + verifiedUsers.delete(userId); + message.reply(`Your verification as **${prev.epicName}** has been removed and your roles have been stripped. You can now use \`!verify\` to link a different account.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !verify +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!verify') return; + + const userId = message.author.id; + + // Support DMs — if in DM, we need to find the guild separately + let guild = message.guild; + if (!guild) { + // Try to find the main guild from EMOJI_GUILD_ID + guild = client.guilds.cache.get(process.env.EMOJI_GUILD_ID) || client.guilds.cache.first(); + if (!guild) return message.reply('Could not find the server. Please use !verify in the server.'); + } + + // Block if already verified + if (verifiedUsers.has(userId)) { + const prev = verifiedUsers.get(userId); + return message.reply(`You are already verified as **${prev.epicName}**. Use \`!unverify\` to unlink your account and verify a different one.`); + } + + if (pending.has(userId)) + return message.reply('You already have a verification in progress. Check your DMs.'); + + await message.reply('A login link has been sent to your DMs.'); + + try { + const switchToken = await getSwitchAccessToken(); + const deviceData = await createDeviceCode(switchToken); + if (!deviceData.user_code || !deviceData.device_code) + return message.author.send('Failed to generate a login link. Try again later.'); + + const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('Fortnite Account Verification') + .setDescription( + `**[Click here to verify →](${loginUrl})**\n\n` + + `Code: \`${deviceData.user_code}\`\n\n` + + `Expires in **5 minutes**. The bot will check your locker and assign your roles automatically.` + ) + .setFooter({ text: 'Read-only — the bot only checks your cosmetics.' }); + + await message.author.send({ embeds: [embed] }); + pending.set(userId, { guildId: guild.id, channelId: message.channel.id }); + + const tokenData = await waitForLogin(deviceData.device_code); + pending.delete(userId); + + if (!tokenData) + return message.author.send('Login expired. Use !verify to try again.'); + + const iosData = await exchangeForIosToken(tokenData.access_token); + if (!iosData.access_token) + return message.author.send('Authentication failed. Please try again.'); + + const { account_id: accountId, displayName = 'Unknown', access_token: accessToken } = iosData; + await message.author.send(`Logged in as **${displayName}**. Fetching locker...`); + + const [athenaData, commonCoreData] = await Promise.all([ + getProfile(accountId, accessToken, 'athena'), + getProfile(accountId, accessToken, 'common_core'), + ]); + + if (!athenaData?.profileChanges) + return message.author.send('Failed to read your locker. Please try again.'); + + const { owned: ownedMap, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays } = buildOwnedMap(athenaData); + const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {}; + const earnedRoles = getRolesForLocker(ownedMap, ccItems); + + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) + return message.author.send('Could not find you in the server. Make sure you are still a member.'); + + const assigned = [], notFound = []; + for (const roleName of earnedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (!role) { notFound.push(roleName); continue; } + if (!member.roles.cache.has(role.id)) + await member.roles.add(role, 'Fortnite locker verification').catch(() => null); + assigned.push(roleName); + } + + // Store verified state so they can't re-verify without !unverify + verifiedUsers.set(userId, { guildId: guild.id, assignedRoles: assigned, epicName: displayName }); + + const verifyEmbed = new EmbedBuilder() + .setColor(0x2B2D31) + .setTitle(`${displayName}'s Verification`) + .setDescription( + assigned.length === 0 + ? 'No exclusive roles found.' + : assigned.map(r => `— ${r}`).join('\n') + ) + .addFields( + { name: 'Total Outfits', value: String(outfits.length), inline: true }, + { name: 'Total Pickaxes', value: String(pickaxes.length), inline: true }, + ) + .setFooter({ text: 'Made by jitterbuxg' }); + + await message.author.send({ embeds: [verifyEmbed] }); + + // Send verification log to designated channel + try { + const logChannel = await client.channels.fetch(LOG_CHANNEL_ID).catch(() => null); + if (logChannel) { + // Fetch server emojis to use next to role names + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + await emojiGuild?.emojis.fetch().catch(() => null); + + const getServerEmoji = (roleName) => { + if (!emojiGuild) return ''; + // Map role names to emoji names we uploaded + const emojiNameMap = { + 'Galaxy': 'Galaxy', 'Ikonik': 'Ikonik', 'Glow': 'Glow', + 'OG Renegade Raider': 'RenegadeRaider', 'OG Ghoul Trooper': 'GhoulTrooper', + 'OG Skull Trooper': 'SkullTrooper', 'OG Aerial Assault Trooper': 'AerialAssaultTrooper', + 'Black Knight': 'BlackKnight', 'Double Helix': 'DoubleHelix', + 'Honor Guard': 'HonorGuard', 'Wonder': 'Wonder', 'Eon': 'Eon', + 'Dark Vertex': 'DarkVertex', 'Neo Versa': 'NeoVersa', + 'Royale Bomber': 'RoyaleBomber', 'Wildcat': 'Wildcat', + 'Stealth Reflex': 'StealthReflex', 'Reflex': 'Reflex', + "Raider's Revenge": 'RaidersRevenge', 'Stellar Axe': 'StellarAxe', + 'AC/DC': 'ACDC', 'Merry Mint Axe': 'MerryMintAxe', + 'Axe of Champions': 'AxeOfChampions', 'Blade of Champions': 'BladeOfChampions', + 'Fully Golden Peely': 'FullyGoldenPeely', + }; + const emojiName = emojiNameMap[roleName]; + if (!emojiName) return ''; + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === emojiName.toLowerCase()); + return emoji ? `${emoji} ` : ''; + }; + + const logEmbed = new EmbedBuilder() + .setColor(assigned.length > 0 ? 0xFFD700 : 0x444444) + .setAuthor({ + name: displayName, + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Account Verified') + .setDescription( + assigned.length === 0 + ? 'No exclusive cosmetics found on this account.' + : assigned.map(r => `${getServerEmoji(r)}${r}`).join('\n') + ) + .addFields( + { name: 'Discord', value: message.author.username, inline: true }, + { name: 'Epic Account', value: displayName, inline: true }, + { name: 'Roles Given', value: String(assigned.length), inline: true }, + ) + .setFooter({ text: '.gg/collecting • Made by jitterbuxg' }) + .setTimestamp(); + await logChannel.send({ embeds: [logEmbed] }); + } + } catch (err) { + console.error('Log channel error:', err.message); + } + + // ── 1. Fetch metadata for all owned cosmetics ───────────────────────── + const allIds = [...outfits, ...pickaxes]; + const metaMap = await fetchCosmeticMeta(allIds); + + // Override rarity to mythic for known exclusives + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) { + const id = req.id.toLowerCase(); + if (qualifiesForRequirement(req, ownedMap) && metaMap[id]) { + metaMap[id].rarity = 'mythic'; + if (requiredStyleTags(req).length > 0) metaMap[id].name = req.role; + } + } + } + + // ── 2. Exclusives rendered image ────────────────────────────────────── + try { + const exclusiveItems = []; + const exclusiveSeen = new Set(); + const addExclusiveItem = (item) => { + const key = item.id.toLowerCase(); + if (exclusiveSeen.has(key)) return; + exclusiveSeen.add(key); + exclusiveItems.push(item); + }; + + // Exclusive cosmetics (skins, pickaxes, emotes, emoticons) + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId || req.stw) continue; + const id = req.id.toLowerCase(); + if (!qualifiesForRequirement(req, ownedMap)) continue; + const meta = metaMap[id] || {}; + addExclusiveItem({ id, name: req.role || meta.name || id, rarity: 'mythic' }); + } + + // Exclusive & STW banners - pulled from role requirements and bundled master list + const bannerMeta = await getBannerMeta(); + for (const item of collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap)) { + addExclusiveItem(item); + } + + if (exclusiveItems.length > 0) { + await message.author.send('Building your exclusive cosmetics image...'); + const exclusiveImg = await renderLockerImage( + exclusiveItems, + `${exclusiveItems.length} (0)`, + 'EXCLUSIVES', + message.author.username, + ogStyleOwned + ); + if (exclusiveImg) { + const top5 = exclusiveItems.slice(0, 5).map(i => i.name).join(' + '); + const suffix = exclusiveItems.length > 5 ? ` + ${exclusiveItems.length - 5} more` : ''; + await message.author.send({ + content: `**${top5}${suffix}** (${outfits.length} skin count)`, + files: [{ attachment: exclusiveImg, name: 'exclusives.png' }], + }); + } + } + } catch (err) { + console.error('Exclusive image error:', err.message); + } + + // ── 3. Full locker — all items, sorted by rarity, one image per category ── + try { + // Rarity sort order (matches RiftCheckerBot) + const RARITY_ORDER = ['mythic','legendary','dark','lava','slurp','starwars','marvel','frozen','gaminglegends','shadow','icon','dc','epic','rare','uncommon','common']; + const sortByRarity = (ids) => [...ids].sort((a, b) => { + const ra = RARITY_ORDER.indexOf(metaMap[a.toLowerCase()]?.rarity || 'common'); + const rb = RARITY_ORDER.indexOf(metaMap[b.toLowerCase()]?.rarity || 'common'); + return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb); + }); + + // Collect owned exclusive banners + const bannerMeta = await getBannerMeta(); + const ownedExclusiveBannerIds = []; + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) continue; + const owned = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (owned) { + const bid = req.bannerId.toLowerCase(); + if (!metaMap[bid]) { + metaMap[bid] = { name: bannerMeta[bid]?.name || req.role, rarity: 'mythic' }; + } else { + metaMap[bid].rarity = 'mythic'; + } + ownedExclusiveBannerIds.push(req.bannerId); + } + } + + // Fetch metadata for all cosmetic categories + const allCategoryIds = [...outfits, ...pickaxes, ...emotes, ...emoticons, ...backblings, ...gliders, ...sprays]; + const allMeta = await fetchCosmeticMeta(allCategoryIds); + for (const [k, v] of Object.entries(allMeta)) metaMap[k] = metaMap[k] || v; + + const categories = [ + { ids: sortByRarity(outfits), label: 'OUTFITS', subtitle: `${outfits.length} outfits` }, + { ids: sortByRarity(pickaxes), label: 'PICKAXES', subtitle: `${pickaxes.length} pickaxes` }, + { ids: sortByRarity(emotes), label: 'EMOTES', subtitle: `${emotes.length} emotes` }, + { ids: sortByRarity(emoticons), label: 'EMOTICONS', subtitle: `${emoticons.length} emoticons` }, + { ids: sortByRarity(backblings), label: 'BACK BLINGS', subtitle: `${backblings.length} back blings` }, + { ids: sortByRarity(gliders), label: 'GLIDERS', subtitle: `${gliders.length} gliders` }, + { ids: sortByRarity(sprays), label: 'SPRAYS', subtitle: `${sprays.length} sprays` }, + ...(ownedExclusiveBannerIds.length > 0 + ? [{ ids: ownedExclusiveBannerIds, label: 'BANNERS', subtitle: `${ownedExclusiveBannerIds.length} banners` }] + : []), + ].filter(c => c.ids.length > 0); + + if (categories.length === 0) { + await message.author.send('No cosmetics found in locker.'); + return; + } + + await message.author.send(`Building your full locker (${categories.length} categories)...`); + + for (const cat of categories) { + const items = cat.ids.map(id => ({ + id, + name: metaMap[id.toLowerCase()]?.name || id, + rarity: metaMap[id.toLowerCase()]?.rarity || 'common', + })); + + const img = await renderLockerImage( + items, + `${items.length} (0)`, + cat.label, + message.author.username, + ogStyleOwned + ); + + if (img) { + await message.author.send({ + content: `**${displayName} — ${cat.subtitle}**`, + files: [{ attachment: img, name: `${cat.label.toLowerCase().replace(/\s/g, '_')}.png` }], + }); + } + + await new Promise(r => setTimeout(r, 500)); + } + + } catch (err) { + console.error('Locker image error:', err.message); + } + + } catch (err) { + pending.delete(userId); + console.error('Verify error:', err); + message.author.send('Unexpected error. Try again.').catch(() => null); + } +}); + +client.login(process.env.TOKEN); diff --git a/bot.backup-codex-20260628-023343.js b/bot.backup-codex-20260628-023343.js new file mode 100644 index 0000000..f84ad44 --- /dev/null +++ b/bot.backup-codex-20260628-023343.js @@ -0,0 +1,1996 @@ +// ============================================= +// FORTNITE DISCORD BOT — ALL-IN-ONE +// ============================================= +// Commands: +// !setuproles — create all roles +// !addicons — upload skin icons as server emojis +// !setroleicons — apply emojis to roles +// !removeoldemojis — delete old skin emojis +// !verify — verify Fortnite account & auto-assign roles +// +// Run with: node bot.js +// ============================================= + +require('dotenv').config(); +const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder, REST, Routes, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js'); +const sharp = require('sharp'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: ['CHANNEL'], +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ROLES TO CREATE +// ───────────────────────────────────────────────────────────────────────────── +const roles = [ + { name: 'Fully Golden Peely', color: 0xFFD700 }, + { name: 'OG Aerial Assault Trooper', color: 0x8A8A8A }, + { name: 'OG Ghoul Trooper', color: 0xA89DB8 }, + { name: 'OG Renegade Raider', color: 0x7B2D2D }, + { name: 'OG Skull Trooper', color: 0x9B59B6 }, + { name: 'Barricade', color: 0x9E9E9E }, + { name: 'Double Helix', color: 0xD0443C }, + { name: 'Honor Guard', color: 0x4A90D9 }, + { name: 'Wonder', color: 0x2ECC71 }, + { name: 'Surf Strider', color: 0x1ABC9C }, + { name: 'Stealth Reflex', color: 0xE8927C }, + { name: 'Dark Vertex', color: 0x8E44AD }, + { name: 'Royale Bomber', color: 0xE67E22 }, + { name: 'Wildcat', color: 0xE8832A }, + { name: 'Clyde', color: 0xE8832A }, + { name: 'Eon', color: 0x27AE60 }, + { name: 'Huntmaster Saber', color: 0xD4AC0D }, + { name: 'Dark Skully', color: 0x7D6B8A }, + { name: 'Galaxy', color: 0x5B7DD8 }, + { name: 'Ikonik', color: 0xD0443C }, + { name: 'Black Knight', color: 0xCCCCCC }, + { name: 'Glow', color: 0xBB8FCC }, + { name: 'Rogue Spider Knight', color: 0x2ECC71 }, + { name: 'Thrilldiver', color: 0x4A90D9 }, + { name: 'Freediver', color: 0x9E9E9E }, + { name: 'Neo Versa', color: 0x9B59B6 }, + { name: 'Cobalt Snowfoot', color: 0x1ABC9C }, + { name: 'Florin', color: 0x8E44AD }, + { name: "What's Wrong, Toona Fish?", color: 0xCCCCCC }, + { name: 'Cameo Needs Loot', color: 0x2ECC71 }, + { name: 'Victory!', color: 0x9B59B6 }, + { name: 'Collegiate items', color: 0x2ECC71 }, + { name: 'StreamElements', color: 0xF1C40F }, + { name: 'Llamas', color: 0xFF69B4 }, + { name: 'Repaz', color: 0xD4AC0D }, + { name: 'lollek', color: 0xFFFFFF }, + { name: 'Celebration Heart', color: 0x9B59B6 }, + { name: 'Funflower', color: 0xD4C227 }, + { name: 'Level 100', color: 0xBB44EE }, + { name: 'Alpha Banner', color: 0x777777 }, + { name: 'YouTube Banner', color: 0x777777 }, + { name: 'STW Founders', color: 0xE67E22 }, + { name: 'Ultimate STW Founders', color: 0x888888 }, + { name: 'Rare Usernames', color: 0xF1C40F }, + { name: 'Skin Collector', color: 0x888888 }, + { name: 'PS+ Pack Collector', color: 0x3498DB }, + { name: 'Spray Collector', color: 0xCCCCCC }, + { name: 'Rare Banners', color: 0x888888 }, + { name: 'Emote Collector', color: 0xCCCCCC }, + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", color: 0x7B2D2D }, + { name: 'Axe of Champions', color: 0xF1C40F }, + { name: 'Blade of Champions', color: 0xF1C40F }, + { name: 'Axecalibur', color: 0xCCCCCC }, + { name: 'EVA', color: 0x4A90D9 }, + { name: 'AC/DC', color: 0xF1C40F }, + { name: 'Resonator', color: 0x27AE60 }, + { name: 'Pinpoint', color: 0xD0443C }, + { name: 'Stellar Axe', color: 0x5B7DD8 }, + { name: 'Stealth Angular Axe', color: 0x9B59B6 }, + { name: 'Dark Razor', color: 0x7D6B8A }, + { name: 'Electri-claw', color: 0xE8832A }, + { name: 'Dark Splitter', color: 0x8E44AD }, + { name: 'Merry Mint Axe', color: 0x2ECC71 }, + { name: 'Indigo Inverter', color: 0x1ABC9C }, + { name: 'Rose Glow', color: 0xFF69B4 }, + { name: 'Controller', color: 0x3498DB }, + { name: 'Pneumatic Twin', color: 0x4A90D9 }, + { name: 'Perfect Point', color: 0x9E9E9E }, + { name: 'Petrol Pump', color: 0xE67E22 }, + { name: 'Future Relic Scythe', color: 0x8E44AD }, + // --- Exclusive Emoticons --- + { name: 'Skull Trooper Emoticon', color: 0x9B59B6 }, + { name: 'Maevys Emoticon', color: 0xD0443C }, + { name: 'ZetFar Emoticon', color: 0x4A90D9 }, + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', color: 0x3498DB }, // PS Plus Pack 1 + { name: 'Blue Striker', color: 0x3498DB }, // PS Plus Pack 2 + { name: 'Prodigy', color: 0x3498DB }, // PS Plus Pack 4 + { name: 'Carbon Commando', color: 0x3498DB }, // PS Plus Pack 5 + { name: 'Trilogy', color: 0x3498DB }, // PS Plus Pack 8 + { name: 'Point Patroller', color: 0x3498DB }, // PS Plus Pack 9 + { name: 'Cloud Striker', color: 0x3498DB }, // PS Plus Pack 12 + { name: 'Fixer', color: 0x3498DB }, // PS Plus Pack 14 + { name: 'Blizzard Bomber', color: 0x3498DB }, // PS Plus Pack 18 + { name: 'Jun-Hwan', color: 0x3498DB }, // Blue Phoenix Pack + { name: 'Lina Scorch', color: 0x3498DB }, // Blazin Wheels Pack + { name: 'Indigo Kuno', color: 0x9B59B6 }, // PS Generations Cup + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Havoc', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Trailblazer', color: 0x9146FF }, // Twitch Prime Pack 2 + // --- Intel / PC Exclusive --- + { name: 'Reflex', color: 0x0071C5 }, // Intel bundle + // --- OG Battle Pass (C1S2) --- + { name: 'Sparkle Specialist', color: 0xFF69B4 }, + { name: 'Royale Knight', color: 0xCCCCCC }, + { name: 'Blue Squire', color: 0x4A90D9 }, + // --- OG Battle Pass (C1S3) --- + { name: 'The Reaper', color: 0x888888 }, + { name: 'Elite Agent', color: 0x888888 }, + { name: 'Rogue Agent', color: 0x888888 }, + // --- OG Battle Pass (C1S4) --- + { name: 'Omega', color: 0x8E44AD }, + // --- Chapter 2 Season 2 Battle Pass (golden styles) --- + { name: 'Skye', color: 0xF1C40F }, + { name: 'TNTina', color: 0xF1C40F }, + { name: 'Meowscles', color: 0xF1C40F }, + { name: 'Midas', color: 0xFFD700 }, + // --- Gaming Legends / Collab Exclusives --- + { name: 'Travis Scott', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Astro Jack', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Kratos', color: 0xCCCCCC }, // PlayStation collab + { name: 'Master Chief', color: 0x4A90D9 }, // Xbox collab + // --- World Cup / Tournament --- + { name: 'World Warrior', color: 0xF1C40F }, // Fortnite World Cup 2019 + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', color: 0xFF69B4 }, + // --- Save the World Founders --- + { name: 'Rose Team Leader', color: 0xE67E22 }, + { name: 'Warpaint', color: 0xE67E22 }, + // --- OT Banners --- + { name: 'OT1 Banner', color: 0xFFD700 }, + { name: 'OT2 Banner', color: 0xFFD700 }, + { name: 'OT3 Banner', color: 0xFFD700 }, + { name: 'OT4 Banner', color: 0xFFD700 }, + { name: 'OT5 Banner', color: 0xFFD700 }, + { name: 'OT6 Banner', color: 0xFFD700 }, + { name: 'OT7 Banner', color: 0xFFD700 }, + { name: 'OT8 Banner', color: 0xFFD700 }, + { name: 'OT9 Banner', color: 0xFFD700 }, + { name: 'OT10 Banner', color: 0xFFD700 }, + { name: 'OT11 Banner', color: 0xFFD700 }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// EMOJI / ICON MAP +// ───────────────────────────────────────────────────────────────────────────── +const roleIconMap = [ + { name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' }, + { name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' }, + { name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' }, + { name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' }, + { name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' }, + { name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' }, + { name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' }, + { name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' }, + { name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' }, + { name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' }, + { name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' }, + { name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' }, + { name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' }, + { name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' }, + { name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' }, + { name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' }, + { name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' }, + { name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' }, + { name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' }, + { name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' }, + // { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, // temporarily disabled — upload stalls + { name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' }, + { name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' }, + { name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' }, + { name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' }, + { name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' }, + { name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' }, + { name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' }, + { name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' }, + { name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' }, + + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", id: 'Pickaxe_Lockjaw', emoji: 'RaidersRevenge' }, + { name: 'Axe of Champions', id: 'Pickaxe_ID_376_FNCS', emoji: 'AxeOfChampions' }, + { name: 'Blade of Champions', id: 'Pickaxe_FNCS_S32', emoji: 'BladeOfChampions' }, + { name: 'Axecalibur', id: 'Pickaxe_ID_011_Medieval', emoji: 'Axecalibur' }, + { name: 'EVA', id: 'Pickaxe_ID_028_Space', emoji: 'EVA' }, + { name: 'AC/DC', id: 'Pickaxe_ID_013_Teslacoil', emoji: 'ACDC' }, + { name: 'Resonator', id: 'Pickaxe_ID_077_CarbideWhite', emoji: 'Resonator' }, + { name: 'Pinpoint', id: 'Pickaxe_ID_099_ModernMilitaryRed', emoji: 'Pinpoint' }, + { name: 'Stellar Axe', id: 'Pickaxe_ID_116_Celestial', emoji: 'StellarAxe' }, + { name: 'Stealth Angular Axe', id: 'Pickaxe_ID_189_StreetOpsStealth', emoji: 'StealthAngularAxe' }, + { name: 'Dark Razor', id: 'Pickaxe_ID_178_SpeedyMidnight', emoji: 'DarkRazor' }, + { name: 'Electri-claw', id: 'Pickaxe_ID_398_WildCatFemale', emoji: 'Electriclaw' }, + { name: 'Dark Splitter', id: 'Pickaxe_ID_461_SkullBriteCube', emoji: 'DarkSplitter' }, + { name: 'Merry Mint Axe', id: 'Pickaxe_ID_294_CandyCane', emoji: 'MerryMintAxe' }, + { name: 'Indigo Inverter', id: 'Pickaxe_SnowNinjaDark', emoji: 'IndigoInverter' }, + { name: 'Rose Glow', id: 'Pickaxe_ID_153_RoseLeader', emoji: 'RoseGlow' }, + { name: 'Controller', id: 'Pickaxe_ID_088_PSBurnout', emoji: 'Controller' }, + { name: 'Pneumatic Twin', id: 'Pickaxe_ID_256_TechOpsBlue', emoji: 'PneumaticTwin' }, + { name: 'Perfect Point', id: 'Pickaxe_ID_464_LongShortsMale', emoji: 'PerfectPoint' }, + { name: 'Petrol Pump', id: 'Pickaxe_RetroWheels', emoji: 'PetrolPump' }, + { name: 'Future Relic Scythe', id: 'Pickaxe_SnowSoldierFashion', emoji: 'FutureRelicScythe' }, + + // --- OT Banners (OT1–OT11) --- + { name: 'OT1 Banner', id: 'OT1Banner', emoji: 'OT1Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot1banner/icon.png' }, + { name: 'OT2 Banner', id: 'OT2Banner', emoji: 'OT2Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot2banner/icon.png' }, + { name: 'OT3 Banner', id: 'OT3Banner', emoji: 'OT3Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot3banner/icon.png' }, + { name: 'OT4 Banner', id: 'OT4Banner', emoji: 'OT4Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot4banner/icon.png' }, + { name: 'OT5 Banner', id: 'OT5Banner', emoji: 'OT5Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot5banner/icon.png' }, + { name: 'OT6 Banner', id: 'OT6Banner', emoji: 'OT6Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot6banner/icon.png' }, + { name: 'OT7 Banner', id: 'OT7Banner', emoji: 'OT7Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot7banner/icon.png' }, + { name: 'OT8 Banner', id: 'OT8Banner', emoji: 'OT8Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot8banner/icon.png' }, + { name: 'OT9 Banner', id: 'OT9Banner', emoji: 'OT9Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot9banner/icon.png' }, + { name: 'OT10 Banner', id: 'OT10Banner', emoji: 'OT10Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot10banner/icon.png' }, + { name: 'OT11 Banner', id: 'OT11Banner', emoji: 'OT11Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot11banner/icon.png' }, + + // --- Exclusive Emoticons (in-game emoji cosmetics) --- + { name: 'Skull Trooper Emoticon', id: 'Emoji_Skulltrooper', emoji: 'EmoticonSkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_skulltrooper/smallicon.png' }, + { name: 'Maevys Emoticon', id: 'Emoji_S22_Showdown_Mae', emoji: 'EmoticonMaevys', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_mae/smallicon.png' }, + { name: 'ZetFar Emoticon', id: 'Emoji_S22_Showdown_Zet', emoji: 'EmoticonZetFar', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_zet/smallicon.png' }, + + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', id: 'CID_052_Athena_Commando_F_PSBlue', emoji: 'BlueTeamLeader' }, + { name: 'Blue Striker', id: 'CID_138_Athena_Commando_M_PSBurnout', emoji: 'BlueStriker' }, + { name: 'Prodigy', id: 'CID_296_Athena_Commando_M_Math', emoji: 'Prodigy' }, + { name: 'Carbon Commando', id: 'CID_360_Athena_Commando_M_TechOpsBlue', emoji: 'CarbonCommando' }, + { name: 'Trilogy', id: 'CID_657_Athena_Commando_F_TechOpsBlue', emoji: 'Trilogy' }, + { name: 'Point Patroller', id: 'CID_711_Athena_Commando_M_LongShorts', emoji: 'PointPatroller' }, + { name: 'Cloud Striker', id: 'CID_926_Athena_Commando_F_StreetFashionDiamond', emoji: 'CloudStriker' }, + { name: 'Fixer', id: 'CID_A_101_Athena_Commando_M_TacticalWoodlandBlue', emoji: 'Fixer' }, + { name: 'Blizzard Bomber', id: 'CID_A_421_Athena_Commando_F_BlizzardBomber', emoji: 'BlizzardBomber' }, + { name: 'Jun-Hwan', id: 'Character_BlueJet', emoji: 'JunHwan' }, + { name: 'Lina Scorch', id: 'Character_RetroWheels', emoji: 'LinaScorch' }, + { name: 'Indigo Kuno', id: 'CID_660_Athena_Commando_F_BandageNinjaBlue', emoji: 'IndigoKuno' }, + + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', id: 'CID_085_Athena_Commando_M_Twitch', emoji: 'SubCommander' }, + { name: 'Havoc', id: 'CID_089_Athena_Commando_M_RetroGrey', emoji: 'Havoc' }, + { name: 'Trailblazer', id: 'CID_114_Athena_Commando_F_TacticalWoodland', emoji: 'Trailblazer' }, + + // --- Intel / PC Exclusive --- + { name: 'Reflex', id: 'CID_259_Athena_Commando_M_StreetOps', emoji: 'Reflex' }, + + // --- OG Battle Pass --- + { name: 'Sparkle Specialist', id: 'CID_039_Athena_Commando_F_Disco', emoji: 'SparkleSpecialist' }, + { name: 'Royale Knight', id: 'CID_033_Athena_Commando_F_Medieval', emoji: 'RoyaleKnight' }, + { name: 'Blue Squire', id: 'CID_032_Athena_Commando_M_Medieval', emoji: 'BlueSquire' }, + { name: 'The Reaper', id: 'CID_084_Athena_Commando_M_Assassin', emoji: 'TheReaper' }, + { name: 'Elite Agent', id: 'CID_083_Athena_Commando_F_Tactical', emoji: 'EliteAgent' }, + { name: 'Rogue Agent', id: 'CID_090_Athena_Commando_M_Tactical', emoji: 'RogueAgent' }, + { name: 'Omega', id: 'CID_116_Athena_Commando_M_CarbideBlack', emoji: 'Omega' }, + + // --- Ch2S2 Battle Pass gold styles --- + { name: 'Skye', id: 'CID_690_Athena_Commando_F_Photographer', emoji: 'Skye' }, + { name: 'TNTina', id: 'CID_691_Athena_Commando_F_TNTina', emoji: 'TNTina' }, + { name: 'Meowscles', id: 'CID_693_Athena_Commando_M_BuffCat', emoji: 'Meowscles' }, + { name: 'Midas', id: 'CID_694_Athena_Commando_M_CatBurglar', emoji: 'Midas' }, + + // --- Gaming Legends / Collab --- + { name: 'Travis Scott', id: 'CID_703_Athena_Commando_M_Cyclone', emoji: 'TravisScott' }, + { name: 'Astro Jack', id: 'CID_761_Athena_Commando_M_CycloneSpace', emoji: 'AstroJack' }, + { name: 'Kratos', id: 'CID_964_Athena_Commando_M_Historian_869BC', emoji: 'Kratos' }, + { name: 'Master Chief', id: 'CID_971_Athena_Commando_M_Jupiter_S0Z6M', emoji: 'MasterChief' }, + + // --- World Cup / Tournament --- + { name: 'World Warrior', id: 'CID_478_Athena_Commando_F_WorldCup', emoji: 'WorldWarrior' }, + + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', id: 'Character_TubaGummi', emoji: 'ColorSplashJellie' }, + + // --- Save the World Founders --- + { name: 'Rose Team Leader', id: 'CID_096_Athena_Commando_F_Founder', emoji: 'RoseTeamLeader' }, + { name: 'Warpaint', id: 'CID_095_Athena_Commando_M_Founder', emoji: 'Warpaint' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// VERIFY ROLE REQUIREMENTS +// ───────────────────────────────────────────────────────────────────────────── +const ROLE_REQUIREMENTS = [ + // OG Season 1 skins (style required) + { role: 'OG Renegade Raider', id: 'cid_028_athena_commando_f', styleTag: 'Mat3', acceptBase: true }, + { role: 'OG Aerial Assault Trooper', id: 'cid_017_athena_commando_m', styleTag: 'Stage2' }, + { role: 'OG Ghoul Trooper', id: 'cid_029_athena_commando_f_halloween', styleTag: 'Mat3' }, + { role: 'OG Skull Trooper', id: 'cid_030_athena_commando_m_halloween', styleTag: 'Mat1' }, + // OG Battle Pass skins + { role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' }, + { role: 'Blue Squire', id: 'cid_032_athena_commando_m_medieval' }, + { role: 'Royale Knight', id: 'cid_033_athena_commando_f_medieval' }, + { role: 'Sparkle Specialist', id: 'cid_039_athena_commando_f_disco' }, + { role: 'The Reaper', id: 'cid_084_athena_commando_m_assassin' }, + { role: 'Elite Agent', id: 'cid_083_athena_commando_f_tactical' }, + { role: 'Omega', id: 'cid_116_athena_commando_m_carbideblack', styleTag: 'Stage5' }, + { role: 'Carbide', id: 'cid_115_athena_commando_m_carbideblue', styleTag: 'Stage5' }, + // Samsung bundles + { role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' }, + { role: 'Stellar Axe', id: 'pickaxe_id_116_celestial' }, + { role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' }, + { role: 'Scenario Emote', id: 'eid_kpopdance03' }, + { role: 'Glow', id: 'cid_479_athena_commando_f_davinci' }, + { role: 'Levitate Emote', id: 'eid_davinci' }, + // Device / Platform exclusives + { role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' }, + { role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' }, + { role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' }, + { role: 'Resonator', id: 'pickaxe_id_077_carbidewhite' }, + { role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' }, + { role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' }, + { role: 'Pinpoint', id: 'pickaxe_id_099_modernmilitaryred' }, + { role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' }, + { role: 'Cobalt Snowfoot', id: 'character_snowninjadark' }, + { role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' }, + { role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' }, + { role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' }, + { role: 'Reflex', id: 'cid_259_athena_commando_m_streetops' }, + { role: 'Angular Axe', id: 'pickaxe_id_118_streetops' }, + { role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' }, + { role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' }, + { role: 'Huntmaster Saber', id: 'character_masterkeyorder' }, + // PlayStation Plus exclusives + { role: 'Blue Team Leader', id: 'cid_052_athena_commando_f_psblue' }, + { role: 'Blue Striker', id: 'cid_138_athena_commando_m_psburnout' }, + { role: 'Carbon Commando', id: 'cid_360_athena_commando_m_techopsblue' }, + { role: 'Prodigy', id: 'cid_296_athena_commando_m_math' }, + { role: 'Cloud Striker', id: 'cid_926_athena_commando_f_streetfashiondiamond' }, + { role: 'Fixer', id: 'cid_a_101_athena_commando_m_tacticalwoodlandblue' }, + { role: 'Point Patroller', id: 'cid_711_athena_commando_m_longshorts' }, + // Twitch Prime exclusives + { role: 'Sub Commander', id: 'cid_085_athena_commando_m_twitch' }, + { role: 'Havoc', id: 'cid_089_athena_commando_m_retrogrey' }, + { role: 'Trailblazer', id: 'cid_114_athena_commando_f_tacticalwoodland' }, + // Exclusive Pickaxes + { role: "Raider's Revenge", id: 'pickaxe_lockjaw', styleTag: 'Stage2', acceptBase: true }, + { role: 'AC/DC', id: 'pickaxe_id_013_teslacoil' }, + { role: 'Merry Mint Axe', id: 'pickaxe_id_294_candycane' }, + { role: 'Tenderizer', id: 'pickaxe_id_044_tacticalurbanhammer' }, + { role: 'Axe of Champions', id: 'pickaxe_id_376_fncs' }, + { role: 'Blade of Champions', id: 'pickaxe_fncs_s32' }, + // OG / Exclusive Emotes + { role: 'Fresh Emote', id: 'eid_fresh' }, + { role: 'Rambunctious Emote', id: 'eid_zippy' }, + { role: 'Tidy Emote', id: 'eid_tidy' }, + { role: 'Floss Emote', id: 'eid_floss' }, + { role: 'Take the L Emote', id: 'eid_takethelathena' }, + { role: 'Orange Justice Emote', id: 'eid_goodvibes' }, + { role: 'Ride the Pony Emote', id: 'eid_ridethepony_athena' }, + { role: 'Pony Up Emote', id: 'eid_ridetheponymultiple' }, + { role: 'Evasive Maneuvers Emote', id: 'eid_temple' }, + // Exclusive Emoticons + { role: 'Cameo Needs Loot', id: 'emoji_s20_needloot' }, + // Founder exclusives + { role: 'Rose Team Leader', id: 'cid_096_athena_commando_f_founder' }, + { role: 'Warpaint', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Fully Golden Peely', id: 'cid_701_athena_commando_m_bananaagent', styleTag: 'Stage4' }, + // Samsung Galaxy bundle items + { role: 'Galactic Disc', id: 'bid_138_celestial' }, + { role: 'Discovery Glider', id: 'glider_id_090_celestial' }, + // IKONIK bundle - Scenario already added above + // Glow bundle - Levitate already added above + // EON bundle + { role: 'Aurora Glider', id: 'glider_id_056_carbidewhite' }, + // Dark Vertex bundle + { role: 'Dark Deflector', id: 'bid_234_speedymidnight' }, + { role: 'Razor Edge', id: 'pickaxe_id_065_speedyred' }, + { role: 'Dark Forerunner Glider', id: 'glider_id_131_speedymidnight' }, + // Double Helix bundle + { role: 'Telemetry', id: 'bid_114_modernmilitaryred' }, + { role: 'Rotor Glider', id: 'glider_id_075_modernmilitaryred' }, + // Neo Versa bundle + { role: 'Neo Phrenzy', id: 'bid_288_cyberscavengerfemaleblue' }, + // Royale Bomber bundle + { role: 'Royale Air Glider', id: 'glider_id_105_snowboard' }, + // Reflex bundle + { role: 'Response Unit', id: 'bid_140_streetopsmale' }, + { role: 'Pivot Glider', id: 'glider_id_092_streetops' }, + // Wildcat bundle + { role: 'Sleek Strike', id: 'bid_521_wildcat' }, + // Rogue Spider Knight bundle + { role: 'Time Keeper', id: 'bid_395_phantom' }, + // Stealth Reflex bundle + { role: 'Stealth Response Unit', id: 'bid_249_streetopsstealth' }, + // Aerial Assault One glider (S1 exclusive) + { role: 'Aerial Assault One', id: 'glider_id_001', styleTag: 'Stage2' }, + // Extra exclusive pickaxes from master list + { role: 'Trusty No. 2', id: 'pickaxe_id_029_assassin' }, + { role: 'Party Animal', id: 'pickaxe_id_020_keg' }, + { role: 'Crowbar', id: 'pickaxe_id_092_bling' }, + { role: 'Instigator', id: 'pickaxe_id_039_tacticalblack' }, + // Missing emotes from master list + { role: "Widow's Pirouette Emote", id: 'eid_ashtonboardwalk' }, + { role: 'Hot Marat Emote', id: 'eid_wir' }, + { role: 'Kiss the Cup Emote', id: 'eid_trophycelebration' }, + // Missing skins from master list + { role: 'Gildedguy', id: 'cid_a_141_athena_commando_m_dojo' }, + { role: 'Tart Tycoon', id: 'cid_851_athena_commando_m_bittenhead' }, + // Gliders + { role: 'Mako Glider', id: 'glider_warthog' }, + // Rare / STW / Creator banners (checked via homebase_banners in common_core) + { role: 'Alpha Tester Banner', bannerId: 'OtherBanner51' }, + { role: 'World Cup Banner', bannerId: 'BRWorldCup2019' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner1' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner2' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner3' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner4' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner5' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner6' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner7' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner8' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner9' }, + // STW Founder banners + { role: 'STW Founder Banner', bannerId: 'FounderTier5Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier4Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier3Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier2Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier1Banner1' }, + // STW Endurance banners + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater04' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater03' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater02' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater01' }, + // STW Survival / Wargames banners + { role: 'STW Event Banner', bannerId: 'SurvivalBannerStorm2018Hard' }, + { role: 'STW Event Banner', bannerId: 'SurvivalBannerHolidayHard' }, + { role: 'STW Event Banner', bannerId: 'WargamesTprifts' }, + { role: 'STW Event Banner', bannerId: 'WargamesTiki' }, + { role: 'STW Event Banner', bannerId: 'WargamesPods' }, + // STW Achievement banners + { role: 'STW Achievement Banner', bannerId: 'AchievementUnspeakableHorrors' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementWorldExplorer' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGuardianAngel' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementPlaysWellWithOthers' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementTalentedBuilder' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGoGnome' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementLootLegend' }, + // FNCS / Skirmish / World Cup banners + { role: 'FNCS Banner', bannerId: 'BRS23_FNCS_Drops' }, + { role: 'FNCS Banner', bannerId: 'S11FNCS' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishBushBandits' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishDustyDogs' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishFortKnights' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishLuckyLlamas' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishRiftRaiders' }, + // Season Level 100 banners + { role: 'Season Level 100 Banner', bannerId: 'BRS10Level100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS9Lvl100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS8Level100' }, + // Creator / Influencer banners 10-58 + { role: 'Creator Banner', bannerId: 'InfluencerBanner10' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner11' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner12' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner13' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner14' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner15' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner16' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner17' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner18' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner19' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner20' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner21' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner22' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner23' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner24' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner25' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner26' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner27' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner28' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner29' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner30' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner31' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner32' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner33' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner34' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner35' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner36' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner37' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner38' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner39' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner40' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner41' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner42' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner43' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner44' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner45' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner46' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner47' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner48' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner49' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner50' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner51' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner52' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner53' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner54' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner55' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner56' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner57' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner58' }, + // OT Banners + { role: 'OT1 Banner', bannerId: 'OT1Banner' }, + { role: 'OT2 Banner', bannerId: 'OT2Banner' }, + { role: 'OT3 Banner', bannerId: 'OT3Banner' }, + { role: 'OT4 Banner', bannerId: 'OT4Banner' }, + { role: 'OT5 Banner', bannerId: 'OT5Banner' }, + { role: 'OT6 Banner', bannerId: 'OT6Banner' }, + { role: 'OT7 Banner', bannerId: 'OT7Banner' }, + { role: 'OT8 Banner', bannerId: 'OT8Banner' }, + { role: 'OT9 Banner', bannerId: 'OT9Banner' }, + { role: 'OT10 Banner', bannerId: 'OT10Banner' }, + { role: 'OT11 Banner', bannerId: 'OT11Banner' }, + // STW Founders + { role: 'STW Founders', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Ultimate STW Founders', id: 'tiertoken_collectionbook_ultimate', stw: true }, +] + +// ───────────────────────────────────────────────────────────────────────────── +// EPIC OAUTH CONSTANTS +// ───────────────────────────────────────────────────────────────────────────── +const SWITCH_TOKEN = 'OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDAxYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3'; +const IOS_TOKEN = 'M2Y2OWU1NmM3NjQ5NDkyYzhjYzI5ZjFhZjA4YThhMTI6YjUxZWU5Y2IxMjIzNGY1MGE2OWVmYTY3ZWY1MzgxMmU='; +const EPIC_OAUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; +const EPIC_OAUTH_PROD03 = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token'; +const EPIC_EXCHANGE = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange'; +const EPIC_DEVICE_AUTH = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/deviceAuthorization'; + +const pending = new Map(); +const verifiedUsers = new Map(); // userId -> { guildId, assignedRoles: string[], epicName: string } +const LOG_CHANNEL_ID = '1517695849952706642'; + +// ───────────────────────────────────────────────────────────────────────────── +// SHARED HELPERS +// ───────────────────────────────────────────────────────────────────────────── +async function fetchWithTimeout(url, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { return await fetch(url, { signal: ctrl.signal }); } + finally { clearTimeout(timer); } +} + +// ─── get icon URL — build directly from known CDN pattern, no API roundtrip ── +async function fetchIconUrl(cosmeticId) { + // Direct CDN URL — fortnite-api.com images are always at this pattern + const url = `https://fortnite-api.com/images/cosmetics/br/${cosmeticId.toLowerCase()}/icon.png`; + const res = await fetchWithTimeout(url, 8000); + if (res.ok) return url; + // Fallback to API lookup if direct URL 404s + const apiRes = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`, 8000); + if (!apiRes.ok) throw new Error(`API ${apiRes.status} for ${cosmeticId}`); + const json = await apiRes.json(); + if (!json.data?.images?.icon) throw new Error(`No icon for ${cosmeticId}`); + return json.data.images.icon; +} + +async function downloadAndProcess(imageUrl) { + const res = await fetchWithTimeout(imageUrl, 15000); + if (!res.ok) throw new Error(`Download failed ${res.status}`); + const raw = Buffer.from(await res.arrayBuffer()); + return sharp(raw) + .resize(128, 128, { fit: 'cover', position: 'centre', kernel: 'lanczos3' }) + .sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + +async function createEmojiSafe(guild, buffer, name, reason) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { + return await guild.emojis.create({ attachment: buffer, name, reason }); + } catch (err) { + const isRL = err.status === 429 || err.httpStatus === 429 || + (err.message || '').toLowerCase().includes('rate limit'); + if (isRL) { + const wait = ((err.retryAfter || 5) * 1000) + 1000; + console.warn(` ⏳ Rate limited "${name}", waiting ${Math.ceil(wait/1000)}s...`); + await new Promise(r => setTimeout(r, wait)); + } else { + throw err; + } + } + } + throw new Error(`Gave up on emoji "${name}"`); +} + +// ─── Epic helpers ───────────────────────────────────────────────────────────── +async function getSwitchAccessToken() { + const res = await fetch(EPIC_OAUTH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `basic ${SWITCH_TOKEN}` }, + body: 'grant_type=client_credentials', + }); + return (await res.json()).access_token; +} + +async function createDeviceCode(token) { + const res = await fetch(EPIC_DEVICE_AUTH, { + method: 'POST', + headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return res.json(); +} + +async function waitForLogin(deviceCode, maxMs = 300000) { + const start = Date.now(); + while (Date.now() - start < maxMs) { + await new Promise(r => setTimeout(r, 10000)); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${SWITCH_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=device_code&device_code=${deviceCode}`, + }); + const data = await res.json(); + if (res.status === 200 && data.access_token) return data; + const err = data.errorCode || ''; + if (err.includes('not_found') || err.includes('expired')) return null; + } + return null; +} + +async function exchangeForIosToken(accessToken) { + const exRes = await fetch(EPIC_EXCHANGE, { headers: { 'Authorization': `bearer ${accessToken}` } }); + const exData = await exRes.json(); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${IOS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=exchange_code&exchange_code=${exData.code}`, + }); + return res.json(); +} + +async function getProfile(accountId, accessToken, profileId) { + const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=${profileId}&rvn=-1`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Authorization': `bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + return res.json(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SKIN CHECKER IMAGE RENDERER (RiftCheckerBot style) +// ───────────────────────────────────────────────────────────────────────────── +const { createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'); +const path = require('path'); + +// Register Fortnite font (Burbank Big Condensed Black) + Tahoma +GlobalFonts.registerFromPath( + path.join(__dirname, 'BurbankBigCondensed-Black.ttf'), 'Burbank' +); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahoma.ttf', 'Tahoma'); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahomabd.ttf', 'TahomaB'); + +// Custom background URL — set with !setbg , cleared with !clearbg +let customBgUrl = null; + +const ICON = 96; +const COLS = 12; // wider rows = shorter images +const LABEL = 26; +const CELL_H = ICON + LABEL; +const PAD = 14; +const HDR_H = 72; +const FTR_H = 36; + +const RC = { + common: '#c8c8c8', uncommon: '#60aa3a', rare: '#4990e2', + epic: '#874bd2', legendary: '#d3782e', mythic: '#ffd700', + icon: '#54ceec', marvel: '#e63c3c', dark: '#641464', + frozen: '#94dffc', lava: '#ca4d1d', shadow: '#505050', + gaming: '#1a8e48', slurp: '#00e9b0', +}; + +const OG_STYLE_ICONS = { + 'cid_029_athena_commando_f_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png', + 'cid_030_athena_commando_m_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png', + 'cid_028_athena_commando_f': 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 'cid_701_athena_commando_m_bananaagent': 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png', + 'cid_116_athena_commando_m_carbideblack': 'https://fortnite-api.com/images/cosmetics/br/cid_116_athena_commando_m_carbideblack/variants/parts/stage1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Cache the logo so we only fetch once per session +let _logoBuffer = null; +async function getLogoBuffer() { + if (_logoBuffer) return _logoBuffer; + try { + const res = await fetchWithTimeout( + 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 8000 + ); + if (res.ok) _logoBuffer = Buffer.from(await res.arrayBuffer()); + } catch { _logoBuffer = null; } + return _logoBuffer; +} + +const CACHE_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cache'); +const RARITY_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'styles', 'rift', 'rarity'); +const ICON_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cosmetic_icons'); +const fs = require('fs'); + +// Cosmetics that have special OG variant images in local cache +// key = lowercase cosmetic ID, value = local cache filename +const LOCAL_OG_ICONS = { + 'cid_029_athena_commando_f_halloween': 'pink_ghoul.png', // Pink Ghoul + 'cid_030_athena_commando_m_halloween': 'purple_skull.png', // Purple Skull + 'cid_028_athena_commando_f': 'og_rene.png', // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': 'og_aat.png', // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': 'omega_max.png', // Max Omega + 'cid_701_athena_commando_m_bananaagent': 'gold_peely.png', // Gold Agent Peely +}; + +// Remote OG variant URLs for cosmetics not in local cache +const REMOTE_OG_ICONS = { + 'pickaxe_lockjaw': 'https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png', + 'glider_id_001': 'https://fortnite-api.com/images/cosmetics/br/glider_id_001/variants/particle/particle1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Load a rarity background image from local cache +const rarityImageCache = {}; +async function getRarityImage(rarity) { + const key = (rarity || 'common').toLowerCase(); + if (rarityImageCache[key]) return rarityImageCache[key]; + const filePath = path.join(RARITY_DIR, `${key}.png`); + if (fs.existsSync(filePath)) { + try { + const img = await loadImage(fs.readFileSync(filePath)); + rarityImageCache[key] = img; + return img; + } catch { /* fall through */ } + } + // fallback to common + const fallback = path.join(RARITY_DIR, 'common.png'); + if (fs.existsSync(fallback)) { + const img = await loadImage(fs.readFileSync(fallback)); + rarityImageCache['common'] = img; + return img; + } + return null; +} + +// Get cosmetic icon — local cache first, then remote +async function fetchSkinImageBuffer(cosmeticId, useOgStyle = false) { + const id = cosmeticId.toLowerCase(); + + // 1. Try local OG variant if requested + if (useOgStyle && LOCAL_OG_ICONS[id]) { + const localPath = path.join(CACHE_DIR, LOCAL_OG_ICONS[id]); + if (fs.existsSync(localPath)) return fs.readFileSync(localPath); + } + + // 2. Try remote OG variant if requested + if (useOgStyle && REMOTE_OG_ICONS[id]) { + try { + const res = await fetchWithTimeout(REMOTE_OG_ICONS[id], 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + } + + // 3. Check if this is a banner ID — fetch from banner API + const bannerPrefixes = ['ot','influencerbanner','foundertier','brs','survival','endurance','wargames','achievement','otherbanner','skirmish','worldcup','horde','starlight','winter','newsletter']; + if (bannerPrefixes.some(p => id.startsWith(p))) { + // Try local RiftCheckerBot cache first + try { + const files = fs.readdirSync(CACHE_DIR); + const match = files.find(f => f.toLowerCase() === id + '.png' || f.toLowerCase() === cosmeticId + '.png'); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + // Fetch from banner API + try { + const bMeta = await getBannerMeta(); + const iconUrl = bMeta[id]?.icon; + if (iconUrl) { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } + } catch { /* fall through */ } + return null; + } + + // 4. Try local cache with exact cosmetic ID match + try { + const files = fs.readdirSync(CACHE_DIR); + const baseName = cosmeticId + '.png'; + const match = files.find(f => f.toLowerCase() === baseName.toLowerCase()); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + + // 5. Fortnite API CDN + const iconUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/icon.png`; + try { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + + // 6. Fallback: smallIcon + const smallUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/smallicon.png`; + try { + const res = await fetchWithTimeout(smallUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* give up */ } + + return null; +} + +async function renderLockerImage(items, countLabel, subtitle, submittedBy = null, ogStyleOwned = null) { + if (!items || items.length === 0) return null; + + const LOGO_SIZE = HDR_H - 8; + const rows = Math.ceil(items.length / COLS); + const cols = Math.min(items.length, COLS); + const W = Math.max(cols * ICON + PAD * 2, 400); + const H = HDR_H + rows * CELL_H + PAD + FTR_H; + + const canvas = createCanvas(W, H); + const ctx = canvas.getContext('2d'); + + // ── Background ─────────────────────────────────────────────────────────── + if (customBgUrl) { + try { + const res = await fetchWithTimeout(customBgUrl, 10000); + if (res.ok) { + let rawBuf = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get('content-type') || ''; + const isGif = customBgUrl.toLowerCase().endsWith('.gif') || contentType.includes('gif'); + // GIF backgrounds are flattened to their first frame for the generated PNG. + const processed = await sharp(rawBuf, isGif ? { pages: 1 } : {}) + .resize(W, H, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + ctx.drawImage(await loadImage(processed), 0, 0, W, H); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(0, 0, W, H); + } else { throw new Error('bg fetch failed'); } + } catch { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + } else { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; + for (let c = 0; c <= cols; c++) { + ctx.beginPath(); ctx.moveTo(PAD + c * ICON, HDR_H); ctx.lineTo(PAD + c * ICON, HDR_H + rows * CELL_H); ctx.stroke(); + } + for (let r = 0; r <= rows; r++) { + ctx.beginPath(); ctx.moveTo(PAD, HDR_H + r * CELL_H); ctx.lineTo(PAD + cols * ICON, HDR_H + r * CELL_H); ctx.stroke(); + } + + // ── Logo ───────────────────────────────────────────────────────────────── + const logoBuf = await getLogoBuffer(); + let logoEndX = PAD; + if (logoBuf) { + try { + const logoResized = await sharp(logoBuf).resize(LOGO_SIZE, LOGO_SIZE, { fit: 'cover' }).png().toBuffer(); + const logoImg = await loadImage(logoResized); + const cx = PAD + LOGO_SIZE / 2, cy = 4 + LOGO_SIZE / 2, lr = LOGO_SIZE / 2; + ctx.save(); + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.clip(); + ctx.drawImage(logoImg, PAD, 4, LOGO_SIZE, LOGO_SIZE); + ctx.restore(); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.stroke(); + logoEndX = PAD + LOGO_SIZE + 10; + } catch { logoEndX = PAD; } + } + + // ── Header text ─────────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 26px TahomaB'; + ctx.fillText(countLabel, logoEndX, 40); + ctx.fillStyle = '#aaaaaa'; + ctx.font = '13px Tahoma'; + ctx.fillText(subtitle.toUpperCase(), logoEndX, 60); + + // ── Cosmetic grid ───────────────────────────────────────────────────────── + let idx = 0; + for (const item of items) { + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const x = PAD + col * ICON; + const y = HDR_H + row * CELL_H; + const id = item.id.toLowerCase(); + + // Determine if we should show OG style for this item + const hasOgRequirement = !!OG_STYLE_REQUIREMENTS[id]; + const ownsOgStyle = ogStyleOwned ? ogStyleOwned.has(id) : false; + const useOg = hasOgRequirement && ownsOgStyle; + + // Effective rarity: mythic if OG style owned, otherwise use metaMap rarity + const effectiveRarity = useOg ? 'mythic' : (item.rarity || 'common'); + + // ── Draw rarity background from local cache ─────────────────────────── + const rarityFilePath = path.join(RARITY_DIR, `${effectiveRarity}.png`); + if (fs.existsSync(rarityFilePath)) { + try { + const rarBuf = await sharp(fs.readFileSync(rarityFilePath)) + .resize(ICON, ICON, { fit: 'fill' }).png().toBuffer(); + ctx.drawImage(await loadImage(rarBuf), x, y, ICON, ICON); + } catch { /* skip */ } + } else { + ctx.fillStyle = (RC[effectiveRarity] || RC.common) + '28'; + ctx.fillRect(x, y, ICON, ICON); + } + + // ── Gradient border around the cell ────────────────────────────────── + const borderColor = RC[effectiveRarity] || RC.common; + // Top edge gradient (left to right) + const topGrad = ctx.createLinearGradient(x, y, x + ICON, y); + topGrad.addColorStop(0, borderColor + '00'); + topGrad.addColorStop(0.3, borderColor + 'ff'); + topGrad.addColorStop(0.7, borderColor + 'ff'); + topGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = topGrad; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ICON, y); ctx.stroke(); + + // Bottom edge gradient + const botGrad = ctx.createLinearGradient(x, y + ICON, x + ICON, y + ICON); + botGrad.addColorStop(0, borderColor + '00'); + botGrad.addColorStop(0.3, borderColor + 'ff'); + botGrad.addColorStop(0.7, borderColor + 'ff'); + botGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = botGrad; + ctx.beginPath(); ctx.moveTo(x, y + ICON); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // Left edge gradient (top to bottom) + const leftGrad = ctx.createLinearGradient(x, y, x, y + ICON); + leftGrad.addColorStop(0, borderColor + '00'); + leftGrad.addColorStop(0.3, borderColor + 'ff'); + leftGrad.addColorStop(0.7, borderColor + 'ff'); + leftGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = leftGrad; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + ICON); ctx.stroke(); + + // Right edge gradient + const rightGrad = ctx.createLinearGradient(x + ICON, y, x + ICON, y + ICON); + rightGrad.addColorStop(0, borderColor + '00'); + rightGrad.addColorStop(0.3, borderColor + 'ff'); + rightGrad.addColorStop(0.7, borderColor + 'ff'); + rightGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = rightGrad; + ctx.beginPath(); ctx.moveTo(x + ICON, y); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // ── Draw cosmetic icon ──────────────────────────────────────────────── + const buf = await fetchSkinImageBuffer(item.id, useOg); + if (buf) { + try { + const resized = await sharp(buf).resize(ICON - 6, ICON - 6, { fit: 'cover' }).png().toBuffer(); + ctx.drawImage(await loadImage(resized), x + 3, y + 3, ICON - 6, ICON - 6); + } catch { /* skip */ } + } + + // ── Name label ──────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'center'; + const maxW = ICON - 4; + let name = item.name || ''; + while (name.length > 3 && ctx.measureText(name).width > maxW) name = name.slice(0, -1); + if (name !== item.name) name = name.slice(0, -1) + '..'; + ctx.fillText(name, x + ICON / 2, y + ICON + 17, maxW); + ctx.textAlign = 'left'; + idx++; + } + + // ── Footer ──────────────────────────────────────────────────────────────── + const footerY = H - FTR_H; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, footerY, W, FTR_H); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, footerY); ctx.lineTo(W, footerY); ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px TahomaB'; + ctx.fillText(submittedBy ? `Submitted by: ${submittedBy}` : 'Made by jitterbuxg', PAD, footerY + 14); + ctx.fillStyle = '#ffd700'; + ctx.font = '10px Tahoma'; + ctx.fillText('.gg/collecting', PAD, footerY + 28); + + ctx.fillStyle = '#888888'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'right'; + ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), W - PAD, footerY + 21); + ctx.textAlign = 'left'; + + return canvas.toBuffer('image/png'); +} + +async function sendLockerImageEmbed(message, { title, description, imageBuffer, fileName }) { + const attachment = new AttachmentBuilder(imageBuffer, { name: fileName }); + const safeTitle = String(title || 'Locker Check').length > 250 + ? `${String(title).slice(0, 247)}...` + : String(title || 'Locker Check'); + const embed = new EmbedBuilder() + .setColor(0xFFD700) + .setTitle(safeTitle) + .setDescription(description) + .setImage(`attachment://${fileName}`) + .setTimestamp(); + + await message.author.send({ embeds: [embed], files: [attachment] }); +} + +// Banner metadata cache — fetched once from fortnite-api.com/v1/banners +let bannerMetaCache = null; +async function getBannerMeta() { + if (bannerMetaCache) return bannerMetaCache; + try { + const res = await fetchWithTimeout('https://fortnite-api.com/v1/banners', 10000); + if (res.ok) { + const json = await res.json(); + const map = {}; + for (const b of (json.data || [])) { + map[b.id.toLowerCase()] = { + name: b.devName || b.id, + icon: b.images?.icon || b.images?.smallIcon, + rarity: 'common', + }; + } + bannerMetaCache = map; + return map; + } + } catch { /* ignore */ } + return {}; +} + +const BASE_EXCLUSIVE_BANNER_IDS = [ + // OT Banners + 'ot1banner','ot2banner','ot3banner','ot4banner','ot5banner', + 'ot6banner','ot7banner','ot8banner','ot9banner','ot10banner','ot11banner', + // Alpha tester / rare + 'otherbanner51', + // World Cup / FNCS / Skirmish + 'brworldcup2019','brs9worldcup2019','brs23_fncs_drops','s11fncs', + 'brskirmishbushbandits','brskirmishdustydogs','brskirmishfortknights', + 'brskirmishluckyllamas','brskirmishriftraiders', + // Season level 100 + 'brs8level100','brs9lvl100','brs10level100', +]; + +function looksLikeBannerId(rawId) { + const id = String(rawId || '').trim().toLowerCase(); + if (!id) return false; + return ( + id.includes('banner') || + id.startsWith('ot') || + id.startsWith('brs') || + id.startsWith('brseason') || + id.startsWith('brworldcup') || + id.startsWith('brskirmish') || + id.startsWith('endurance') || + id.startsWith('wargames') || + id.startsWith('survival') || + id.startsWith('achievement') || + id.startsWith('starlight') || + id.startsWith('winter2018') || + id.startsWith('newsletter') || + id.startsWith('horde') + ); +} + +function getExclusiveBannerIds() { + const ids = new Set(BASE_EXCLUSIVE_BANNER_IDS); + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) ids.add(req.bannerId.toLowerCase()); + } + + return ids; +} + +function normalizeLooseName(value) { + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ''); +} + +function readMasterListBannerNames() { + const names = new Set(); + const listPaths = [ + path.join(__dirname, 'Fortnite_Master_Exclusive_List.txt'), + path.join(__dirname, 'RiftCheckerBot-main', 'Fortnite_Master_Exclusive_List.txt'), + ]; + + for (const listPath of listPaths) { + try { + const text = fs.readFileSync(listPath, 'utf8'); + const entryMatches = text.matchAll(/-\s*([^-\r\n=]*?\bBanner)\b/g); + for (const match of entryMatches) { + const clean = match[1].replace(/^Missing\s+/i, '').trim(); + const normalized = normalizeLooseName(clean); + if ( + normalized && + normalized !== 'banner' && + normalized !== 'banners' && + normalized !== 'missingbanner' && + !normalized.includes('manysearch') && + !normalized.includes('founderspickaxes') + ) { + names.add(normalized); + } + } + if (/Founder.s Banners/i.test(text)) names.add(normalizeLooseName("Founder's Banner")); + } catch { /* list is optional */ } + } + + return names; +} + +function addMasterListBannerIds(ids, bannerMeta = {}) { + const wantedNames = readMasterListBannerNames(); + if (wantedNames.size === 0) return ids; + + for (const [id, info] of Object.entries(bannerMeta || {})) { + const normalizedName = normalizeLooseName(info?.name || id); + if (!normalizedName) continue; + for (const wanted of wantedNames) { + if (normalizedName === wanted || normalizedName.includes(wanted)) { + ids.add(id.toLowerCase()); + } + } + } + + return ids; +} + +function getOwnedBannerIdsFromCommonCore(ccItems) { + const owned = new Set(); + for (const item of Object.values(ccItems || {})) { + const candidates = [ + item.templateId, + item.attributes?.banner_id, + item.attributes?.bannerId, + item.attributes?.banner_icon, + item.attributes?.bannerIcon, + ]; + + for (const raw of candidates) { + const value = String(raw || '').toLowerCase(); + if (!value) continue; + const id = value.includes(':') ? value.split(':').pop() : value; + if (looksLikeBannerId(id) || value.includes('homebasebannericon') || value.includes('bannertoken')) { + owned.add(id); + } + } + } + return owned; +} + +function commonCoreOwnsId(ccItems, wantedId) { + const needle = wantedId.toLowerCase(); + if (getOwnedBannerIdsFromCommonCore(ccItems).has(needle)) return true; + return Object.values(ccItems || {}).some(item => { + const templateId = (item.templateId || '').toLowerCase(); + const itemId = templateId.split(':').pop(); + return itemId === needle || templateId.includes(needle); + }); +} + +function collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap = null) { + const items = []; + const seen = new Set(); + const bannerIds = addMasterListBannerIds(getExclusiveBannerIds(), bannerMeta); + const ownedBannerIds = getOwnedBannerIdsFromCommonCore(ccItems); + + for (const id of bannerIds) { + if (seen.has(id) || (!ownedBannerIds.has(id) && !commonCoreOwnsId(ccItems, id))) continue; + seen.add(id); + + const info = bannerMeta[id] || {}; + const name = info.name || id; + items.push({ id, name, rarity: 'mythic' }); + if (metaMap) metaMap[id] = { name, rarity: 'mythic' }; + } + + return items; +} + +// Batch fetch cosmetic metadata (name + rarity) for up to 50 IDs at once +async function fetchCosmeticMeta(ids) { + const map = {}; + if (!ids || ids.length === 0) return map; + for (let i = 0; i < ids.length; i += 50) { + const chunk = ids.slice(i, i + 50); + try { + const qs = chunk.map(id => `id=${encodeURIComponent(id)}`).join('&'); + const res = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/search/ids?language=en&${qs}`, 10000); + if (!res.ok) continue; + const json = await res.json(); + for (const item of (json.data || [])) { + map[item.id.toLowerCase()] = { name: item.name, rarity: item.rarity?.value || 'common' }; + } + } catch { /* skip chunk on error */ } + } + return map; +} + +// Cosmetics that have OG style requirements - id -> one or more owned style tags +const OG_STYLE_REQUIREMENTS = { + 'cid_029_athena_commando_f_halloween': ['Mat3', 'Material.Mat3'], // Pink Ghoul + 'cid_030_athena_commando_m_halloween': ['Mat1', 'ClothingColor.Mat1'], // Purple Skull + 'cid_028_athena_commando_f': ['Mat3', 'Material.Mat3', 'Material.OG'], // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': ['Stage2', 'Progressive.Stage2'], // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': ['Stage5', 'Progressive.Stage5'], // Max Omega + 'cid_701_athena_commando_m_bananaagent': ['Stage4', 'Progressive.Stage4'], // Gold Agent Peely + 'pickaxe_lockjaw': ['Stage2', 'Particle.Particle1', 'Particle1', 'Particle.OG'], // Black & Gold Raider's Revenge + 'glider_id_001': ['Stage2', 'Particle.Particle1', 'Particle1'], // OG Aerial Assault One +}; + +function requiredStyleTags(reqOrTags) { + if (!reqOrTags) return []; + if (Array.isArray(reqOrTags)) return reqOrTags; + if (Array.isArray(reqOrTags.styleTags)) return reqOrTags.styleTags; + return reqOrTags.styleTag ? [reqOrTags.styleTag] : []; +} + +function ownsAnyStyleTag(ownedStyleSet, tags) { + const wanted = requiredStyleTags(tags).map(tag => String(tag).toLowerCase()); + if (wanted.length === 0) return true; + if (!ownedStyleSet) return false; + const normalizeTag = tag => String(tag || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const wantedNorm = wanted.map(normalizeTag); + const owned = [...ownedStyleSet].map(tag => String(tag).toLowerCase()); + const ownedNorm = owned.map(normalizeTag); + return wanted.some((tag, idx) => { + const normalized = wantedNorm[idx]; + return owned.some(ownedTag => ownedTag === tag || ownedTag.endsWith(`.${tag}`)) || + ownedNorm.some(ownedTag => ownedTag === normalized || ownedTag.endsWith(normalized)); + }); +} + +function qualifiesForRequirement(req, ownedMap) { + const id = req.id.toLowerCase(); + if (!ownedMap[id]) return false; + if (req.acceptBase) return true; + return ownsAnyStyleTag(ownedMap[id], req); +} + +function uniqueIds(ids) { + return [...new Set(ids.filter(Boolean))]; +} + +function buildOwnedMap(athenaData) { + const owned = {}; // id -> Set of owned style tags + const ogStyleOwned = new Set(); + const outfits = []; + const pickaxes = []; + const emotes = []; + const emoticons = []; // AthenaEmoji type (in-game emoticons, NOT emotes) + const backblings = []; + const gliders = []; + const sprays = []; + const items = athenaData?.profileChanges?.[0]?.profile?.items || {}; + for (const item of Object.values(items)) { + const tpl = (item.templateId || '').toLowerCase(); + if (!tpl.startsWith('athena') && !tpl.startsWith('character')) continue; + const id = tpl.split(':')[1] || ''; + if (!owned[id]) owned[id] = new Set(); + for (const v of (item.attributes?.variants || [])) { + for (const tag of (v.owned || [])) owned[id].add(tag); + if (v.active) owned[id].add(v.active); + } + + if (tpl.startsWith('athenacharacter:') || tpl.startsWith('character:')) outfits.push(id); + else if (tpl.startsWith('athenapickaxe:')) pickaxes.push(id); + else if (tpl.startsWith('athenadance:')) { + if (id.startsWith('spray') || id.startsWith('spid_')) sprays.push(id); + else emotes.push(id); + } + else if (tpl.startsWith('athenaemoji:')) emoticons.push(id); + else if (tpl.startsWith('athenabackpack:')) backblings.push(id); + else if (tpl.startsWith('athenaglider:')) gliders.push(id); + else if (tpl.startsWith('athenasprays:') || tpl.startsWith('athenaspray:')) sprays.push(id); + } + // Populate ogStyleOwned after all variants are collected + for (const id of Object.keys(owned)) { + if (OG_STYLE_REQUIREMENTS[id] && ownsAnyStyleTag(owned[id], OG_STYLE_REQUIREMENTS[id])) { + ogStyleOwned.add(id); + } + } + for (const req of ROLE_REQUIREMENTS) { + const id = req.id?.toLowerCase(); + if (id && req.acceptBase && owned[id]) ogStyleOwned.add(id); + } + return { + owned, + ogStyleOwned, + outfits: uniqueIds(outfits), + pickaxes: uniqueIds(pickaxes), + emotes: uniqueIds(emotes), + emoticons: uniqueIds(emoticons), + backblings: uniqueIds(backblings), + gliders: uniqueIds(gliders), + sprays: uniqueIds(sprays), + }; +} + +function getRolesForLocker(ownedMap, ccItems) { + const earned = []; + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) { + const has = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (has) earned.push(req.role); + continue; + } + if (req.stw) { + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + if (ccIds.some(cc => cc.includes(req.id.toLowerCase()))) earned.push(req.role); + continue; + } + if (qualifiesForRequirement(req, ownedMap)) earned.push(req.role); + } + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + const hasFounder = ccIds.some(cc => cc.includes('founder')); + const hasUltimate = ccIds.some(cc => cc.includes('tiertoken_collectionbook_ultimate')); + if (hasUltimate) { earned.push('Ultimate STW Founders'); earned.push('STW Founders'); } + else if (hasFounder) earned.push('STW Founders'); + return [...new Set(earned)]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOT READY +// ───────────────────────────────────────────────────────────────────────────── +client.once('ready', async () => { + console.log(`✅ Logged in as ${client.user.tag}`); + console.log('Commands: !setuproles | !addicons | !skipemoji | !stopemojis | !setroleicons | !removeoldemojis | !verify'); + + // Set bot avatar to Renegade Raider Black & Gold style + try { + const imgRes = await fetch('https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png'); + const imgBuf = Buffer.from(await imgRes.arrayBuffer()); + await client.user.setAvatar(imgBuf); + console.log('✅ Bot avatar set to Renegade Raider Black & Gold.'); + } catch (err) { + console.warn('Could not set avatar (may already be set or rate limited):', err.message); + } + + // Register /commands slash command to guild for instant availability + const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const slashCommands = [ + new SlashCommandBuilder() + .setName('commands') + .setDescription('List all bot commands') + .toJSON(), + ]; + try { + await rest.put(Routes.applicationGuildCommands(client.user.id, process.env.EMOJI_GUILD_ID), { body: slashCommands }); + console.log('✅ Slash commands registered to guild.'); + } catch (err) { + console.error('Failed to register slash commands:', err.message); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /commands slash command +// ───────────────────────────────────────────────────────────────────────────── +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand() || interaction.commandName !== 'commands') return; + + const embed = new EmbedBuilder() + .setColor(0x1a1a2e) + .setAuthor({ + name: 'jitterbuxg', + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Bot Commands') + .setThumbnail('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .addFields( + { + name: 'Admin Setup', + value: [ + '`!setuproles` — Create all Fortnite roles', + '`!addicons` — Upload skin icons as server emojis', + '`!setroleicons` — Apply emojis as role icons', + '`!removeoldemojis` — Wipe all emojis for a fresh upload', + '`!setbg ` — Set custom background (supports GIFs)', + '`!clearbg` — Reset to default background', + ].join('\n'), + inline: false, + }, + { + name: 'During !addicons', + value: '`!skipemoji` — Skip current · `!stopemojis` — Cancel session', + inline: false, + }, + { + name: 'Verification', + value: '`!verify` — Link your Epic account and auto-assign roles based on your locker', + inline: false, + }, + { + name: 'What !verify checks', + value: [ + '**OG Skins** — Renegade Raider (B&G), Ghoul Trooper (Pink), Skull Trooper (Purple), + more', + '**Exclusives** — Galaxy, Ikonik, Glow, Honor Guard, Double Helix, Wildcat, + more', + '**Pickaxes** — Raider\'s Revenge (B&G), Axe of Champions, AC/DC, + more', + '**Emotes** — Fresh, Floss, Take the L, Orange Justice, + more', + '**Banners** — OT1–OT11, Creator Banners, STW Founders, + more', + ].join('\n'), + inline: false, + } + ) + .setImage('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .setFooter({ text: 'Made by jitterbuxg • .gg/collecting' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: false }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setbg / !clearbg — custom background for skin checker images +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + if (message.content.startsWith('!setbg ')) { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + const url = message.content.slice(7).trim(); + if (!url.startsWith('http')) return message.reply('Please provide a valid image URL.'); + customBgUrl = url; + return message.reply(`Background set. All future skin checker images will use this background.`); + } + if (message.content === '!clearbg') { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + customBgUrl = null; + return message.reply('Background cleared. Back to default dark gradient.'); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setuproles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setuproles') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply(`Creating up to ${roles.length} roles...`); + let created = 0, skipped = 0; + const existing = message.guild.roles.cache.map(r => r.name.toLowerCase()); + + for (const role of roles) { + if (existing.includes(role.name.toLowerCase())) { skipped++; continue; } + try { + await message.guild.roles.create({ name: role.name, color: role.color, reason: 'Bulk role setup' }); + console.log(`Created: ${role.name}`); + created++; + await new Promise(r => setTimeout(r, 300)); + } catch (err) { + console.error(`Failed "${role.name}": ${err.message}`); + } + } + message.channel.send(`Done! Created **${created}** roles, skipped **${skipped}** existing.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !removeoldemojis +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!removeoldemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Removing ALL emojis from **${emojiGuild.name}**...`); + console.log(`🎯 Clearing all emojis from: ${emojiGuild.name} (${emojiGuild.id})`); + await emojiGuild.emojis.fetch(); + + const allEmojis = [...emojiGuild.emojis.cache.values()]; + console.log(`📋 Found ${allEmojis.length} emojis to delete`); + let removed = 0; + + for (const emoji of allEmojis) { + try { + await emoji.delete('Clearing all emojis for fresh upload'); + console.log(`🗑 Deleted: ${emoji.name}`); + removed++; + await new Promise(r => setTimeout(r, 400)); + } catch (err) { + console.error(`Failed to delete ${emoji.name}: ${err.message}`); + } + } + message.channel.send(`Removed **${removed}** emojis from **${emojiGuild.name}**. Run !addicons to re-upload.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !addicons + !skipemoji + !stopemojis +// ───────────────────────────────────────────────────────────────────────────── + +// Shared state for the active addicons session +let emojiSession = null; // { resolve, name, channel } + +// !skipemoji — skip the currently stuck emoji +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!skipemoji') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply(`Skipping **${emojiSession.name}**...`); + emojiSession.resolve('skip'); +}); + +// !stopemojis — stop the whole session +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!stopemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply('Stopping emoji upload session.'); + emojiSession.resolve('stop'); +}); + +// Lock to prevent !addicons running multiple times simultaneously +let addIconsRunning = false; + +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!addicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + if (addIconsRunning) + return message.reply('An emoji upload is already in progress. Wait for it to finish or use `!stopemojis` to cancel.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Uploading ${roleIconMap.length} skin icons to **${emojiGuild.name}**.\nType \`!skipemoji\` to skip a stuck one, or \`!stopemojis\` to cancel.`); + await emojiGuild.emojis.fetch(); + const existing = new Set(emojiGuild.emojis.cache.map(e => e.name.toLowerCase())); + let added = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + if (existing.has(entry.emoji.toLowerCase())) { + console.log(`⏭ Exists: ${entry.emoji}`); + skipped++; + continue; + } + + // Set up skip/stop listener for this entry + let sessionResolve; + const skipPromise = new Promise(res => { sessionResolve = res; }); + emojiSession = { resolve: sessionResolve, name: entry.name, channel: message.channel }; + + process.stdout.write(`📥 ${entry.name} — fetching... `); + try { + const iconUrl = entry.overrideUrl || entry.bannerUrl || await fetchIconUrl(entry.id); + process.stdout.write(`processing... `); + const buffer = await downloadAndProcess(iconUrl); + process.stdout.write(`uploading... `); + + // Race the upload against a skip/stop signal + const result = await Promise.race([ + createEmojiSafe(emojiGuild, buffer, entry.emoji, `Skin icon: ${entry.name}`) + .then(() => 'done'), + skipPromise, + ]); + + if (result === 'stop') { + console.log('🛑 Stopped by user.'); + emojiSession = null; + return message.channel.send(`Stopped. Added **${added}**, skipped **${skipped}**, failed **${failed}**.`); + } + if (result === 'skip') { + console.log(`⏭ Skipped: ${entry.name}`); + skipped++; + emojiSession = null; + continue; + } + + console.log(`✅`); + added++; + } catch (err) { + console.log(`❌ ${err.message}`); + failed++; + } + + emojiSession = null; + await new Promise(r => setTimeout(r, 2000)); + } + + emojiSession = null; + message.channel.send(`Done. Added **${added}** emojis to **${emojiGuild.name}**. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setroleicons +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setroleicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply('Applying emojis to roles...'); + await emojiGuild.emojis.fetch(); + await message.guild.roles.fetch(); + let updated = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + const role = message.guild.roles.cache.find(r => r.name.toLowerCase() === entry.name.toLowerCase()); + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === entry.emoji.toLowerCase()); + if (!role) { console.log(`⏭ Role not found: ${entry.name}`); skipped++; continue; } + if (!emoji) { console.log(`⚠ Emoji missing: ${entry.emoji} — run !addicons first`); skipped++; continue; } + try { + await role.setIcon(emoji); + console.log(`✅ ${entry.name} → :${entry.emoji}:`); + updated++; + } catch (err) { + console.error(`❌ "${entry.name}": ${err.message}`); + failed++; + } + await new Promise(r => setTimeout(r, 500)); + } + message.channel.send(`Done. Applied icons to **${updated}** roles. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !unverify — remove verification and strip roles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!unverify') return; + + const userId = message.author.id; + + if (!verifiedUsers.has(userId)) + return message.reply('You are not currently verified. Use `!verify` to link your account.'); + + const prev = verifiedUsers.get(userId); + const guild = client.guilds.cache.get(prev.guildId) || message.guild; + + if (guild) { + try { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) { + for (const roleName of prev.assignedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (role && member.roles.cache.has(role.id)) { + await member.roles.remove(role, 'User unverified').catch(() => null); + } + } + } + } catch (err) { + console.error('Unverify role removal error:', err.message); + } + } + + verifiedUsers.delete(userId); + message.reply(`Your verification as **${prev.epicName}** has been removed and your roles have been stripped. You can now use \`!verify\` to link a different account.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !verify +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!verify') return; + + const userId = message.author.id; + + // Support DMs — if in DM, we need to find the guild separately + let guild = message.guild; + if (!guild) { + // Try to find the main guild from EMOJI_GUILD_ID + guild = client.guilds.cache.get(process.env.EMOJI_GUILD_ID) || client.guilds.cache.first(); + if (!guild) return message.reply('Could not find the server. Please use !verify in the server.'); + } + + // Block if already verified + if (verifiedUsers.has(userId)) { + const prev = verifiedUsers.get(userId); + return message.reply(`You are already verified as **${prev.epicName}**. Use \`!unverify\` to unlink your account and verify a different one.`); + } + + if (pending.has(userId)) + return message.reply('You already have a verification in progress. Check your DMs.'); + + await message.reply('A login link has been sent to your DMs.'); + + try { + const switchToken = await getSwitchAccessToken(); + const deviceData = await createDeviceCode(switchToken); + if (!deviceData.user_code || !deviceData.device_code) + return message.author.send('Failed to generate a login link. Try again later.'); + + const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('Fortnite Account Verification') + .setDescription( + `**[Click here to verify →](${loginUrl})**\n\n` + + `Code: \`${deviceData.user_code}\`\n\n` + + `Expires in **5 minutes**. The bot will check your locker and assign your roles automatically.` + ) + .setFooter({ text: 'Read-only — the bot only checks your cosmetics.' }); + + await message.author.send({ embeds: [embed] }); + pending.set(userId, { guildId: guild.id, channelId: message.channel.id }); + + const tokenData = await waitForLogin(deviceData.device_code); + pending.delete(userId); + + if (!tokenData) + return message.author.send('Login expired. Use !verify to try again.'); + + const iosData = await exchangeForIosToken(tokenData.access_token); + if (!iosData.access_token) + return message.author.send('Authentication failed. Please try again.'); + + const { account_id: accountId, displayName = 'Unknown', access_token: accessToken } = iosData; + await message.author.send(`Logged in as **${displayName}**. Fetching locker...`); + + const [athenaData, commonCoreData] = await Promise.all([ + getProfile(accountId, accessToken, 'athena'), + getProfile(accountId, accessToken, 'common_core'), + ]); + + if (!athenaData?.profileChanges) + return message.author.send('Failed to read your locker. Please try again.'); + + const { owned: ownedMap, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays } = buildOwnedMap(athenaData); + const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {}; + const earnedRoles = getRolesForLocker(ownedMap, ccItems); + + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) + return message.author.send('Could not find you in the server. Make sure you are still a member.'); + + const assigned = [], notFound = []; + for (const roleName of earnedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (!role) { notFound.push(roleName); continue; } + if (!member.roles.cache.has(role.id)) + await member.roles.add(role, 'Fortnite locker verification').catch(() => null); + assigned.push(roleName); + } + + // Store verified state so they can't re-verify without !unverify + verifiedUsers.set(userId, { guildId: guild.id, assignedRoles: assigned, epicName: displayName }); + + const verifyEmbed = new EmbedBuilder() + .setColor(0x2B2D31) + .setTitle(`${displayName}'s Verification`) + .setDescription( + assigned.length === 0 + ? 'No exclusive roles found.' + : assigned.map(r => `— ${r}`).join('\n') + ) + .addFields( + { name: 'Total Outfits', value: String(outfits.length), inline: true }, + { name: 'Total Pickaxes', value: String(pickaxes.length), inline: true }, + ) + .setFooter({ text: 'Made by jitterbuxg' }); + + await message.author.send({ embeds: [verifyEmbed] }); + + // Send verification log to designated channel + try { + const logChannel = await client.channels.fetch(LOG_CHANNEL_ID).catch(() => null); + if (logChannel) { + // Fetch server emojis to use next to role names + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + await emojiGuild?.emojis.fetch().catch(() => null); + + const getServerEmoji = (roleName) => { + if (!emojiGuild) return ''; + // Map role names to emoji names we uploaded + const emojiNameMap = { + 'Galaxy': 'Galaxy', 'Ikonik': 'Ikonik', 'Glow': 'Glow', + 'OG Renegade Raider': 'RenegadeRaider', 'OG Ghoul Trooper': 'GhoulTrooper', + 'OG Skull Trooper': 'SkullTrooper', 'OG Aerial Assault Trooper': 'AerialAssaultTrooper', + 'Black Knight': 'BlackKnight', 'Double Helix': 'DoubleHelix', + 'Honor Guard': 'HonorGuard', 'Wonder': 'Wonder', 'Eon': 'Eon', + 'Dark Vertex': 'DarkVertex', 'Neo Versa': 'NeoVersa', + 'Royale Bomber': 'RoyaleBomber', 'Wildcat': 'Wildcat', + 'Stealth Reflex': 'StealthReflex', 'Reflex': 'Reflex', + "Raider's Revenge": 'RaidersRevenge', 'Stellar Axe': 'StellarAxe', + 'AC/DC': 'ACDC', 'Merry Mint Axe': 'MerryMintAxe', + 'Axe of Champions': 'AxeOfChampions', 'Blade of Champions': 'BladeOfChampions', + 'Fully Golden Peely': 'FullyGoldenPeely', + }; + const emojiName = emojiNameMap[roleName]; + if (!emojiName) return ''; + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === emojiName.toLowerCase()); + return emoji ? `${emoji} ` : ''; + }; + + const logEmbed = new EmbedBuilder() + .setColor(assigned.length > 0 ? 0xFFD700 : 0x444444) + .setAuthor({ + name: displayName, + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Account Verified') + .setDescription( + assigned.length === 0 + ? 'No exclusive cosmetics found on this account.' + : assigned.map(r => `${getServerEmoji(r)}${r}`).join('\n') + ) + .addFields( + { name: 'Discord', value: message.author.username, inline: true }, + { name: 'Epic Account', value: displayName, inline: true }, + { name: 'Roles Given', value: String(assigned.length), inline: true }, + ) + .setFooter({ text: '.gg/collecting • Made by jitterbuxg' }) + .setTimestamp(); + await logChannel.send({ embeds: [logEmbed] }); + } + } catch (err) { + console.error('Log channel error:', err.message); + } + + // ── 1. Fetch metadata for all owned cosmetics ───────────────────────── + const allIds = [...outfits, ...pickaxes]; + const metaMap = await fetchCosmeticMeta(allIds); + + // Override rarity to mythic for known exclusives + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) { + const id = req.id.toLowerCase(); + if (qualifiesForRequirement(req, ownedMap) && metaMap[id]) { + metaMap[id].rarity = 'mythic'; + if (requiredStyleTags(req).length > 0) metaMap[id].name = req.role; + } + } + } + + // ── 2. Exclusives rendered image ────────────────────────────────────── + try { + const exclusiveItems = []; + const exclusiveSeen = new Set(); + const addExclusiveItem = (item) => { + const key = item.id.toLowerCase(); + if (exclusiveSeen.has(key)) return; + exclusiveSeen.add(key); + exclusiveItems.push(item); + }; + + // Exclusive cosmetics (skins, pickaxes, emotes, emoticons) + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId || req.stw) continue; + const id = req.id.toLowerCase(); + if (!qualifiesForRequirement(req, ownedMap)) continue; + const meta = metaMap[id] || {}; + addExclusiveItem({ id, name: req.role || meta.name || id, rarity: 'mythic' }); + } + + // Exclusive & STW banners - pulled from role requirements and bundled master list + const bannerMeta = await getBannerMeta(); + for (const item of collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap)) { + addExclusiveItem(item); + } + + if (exclusiveItems.length > 0) { + await message.author.send('Building your exclusive cosmetics image...'); + const exclusiveImg = await renderLockerImage( + exclusiveItems, + `${exclusiveItems.length} (0)`, + 'EXCLUSIVES', + message.author.username, + ogStyleOwned + ); + if (exclusiveImg) { + const top5 = exclusiveItems.slice(0, 5).map(i => i.name).join(' + '); + const suffix = exclusiveItems.length > 5 ? ` + ${exclusiveItems.length - 5} more` : ''; + await sendLockerImageEmbed(message, { + title: `${top5}${suffix}`, + description: `${outfits.length} skin count`, + imageBuffer: exclusiveImg, + fileName: 'exclusives.png', + }); + } + } + } catch (err) { + console.error('Exclusive image error:', err.message); + } + + // ── 3. Full locker — all items, sorted by rarity, one image per category ── + try { + // Rarity sort order (matches RiftCheckerBot) + const RARITY_ORDER = ['mythic','legendary','dark','lava','slurp','starwars','marvel','frozen','gaminglegends','shadow','icon','dc','epic','rare','uncommon','common']; + const sortByRarity = (ids) => [...ids].sort((a, b) => { + const ra = RARITY_ORDER.indexOf(metaMap[a.toLowerCase()]?.rarity || 'common'); + const rb = RARITY_ORDER.indexOf(metaMap[b.toLowerCase()]?.rarity || 'common'); + return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb); + }); + + // Collect owned exclusive banners from role requirements, bundled IDs, and the master list. + const bannerMeta = await getBannerMeta(); + const ownedExclusiveBanners = collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap); + const ownedExclusiveBannerIds = ownedExclusiveBanners.map(item => item.id); + + // Fetch metadata for all cosmetic categories + const allCategoryIds = [...outfits, ...pickaxes, ...emotes, ...emoticons, ...backblings, ...gliders, ...sprays]; + const allMeta = await fetchCosmeticMeta(allCategoryIds); + for (const [k, v] of Object.entries(allMeta)) metaMap[k] = metaMap[k] || v; + + const categories = [ + { ids: sortByRarity(outfits), label: 'OUTFITS', subtitle: `${outfits.length} outfits` }, + { ids: sortByRarity(pickaxes), label: 'PICKAXES', subtitle: `${pickaxes.length} pickaxes` }, + { ids: sortByRarity(emotes), label: 'EMOTES', subtitle: `${emotes.length} emotes` }, + { ids: sortByRarity(emoticons), label: 'EMOTICONS', subtitle: `${emoticons.length} emoticons` }, + { ids: sortByRarity(backblings), label: 'BACK BLINGS', subtitle: `${backblings.length} back blings` }, + { ids: sortByRarity(gliders), label: 'GLIDERS', subtitle: `${gliders.length} gliders` }, + { ids: sortByRarity(sprays), label: 'SPRAYS', subtitle: `${sprays.length} sprays` }, + ...(ownedExclusiveBannerIds.length > 0 + ? [{ ids: ownedExclusiveBannerIds, label: 'BANNERS', subtitle: `${ownedExclusiveBannerIds.length} banners` }] + : []), + ].filter(c => c.ids.length > 0); + + if (categories.length === 0) { + await message.author.send('No cosmetics found in locker.'); + return; + } + + await message.author.send(`Building your full locker (${categories.length} categories)...`); + + for (const cat of categories) { + const items = cat.ids.map(id => ({ + id, + name: metaMap[id.toLowerCase()]?.name || id, + rarity: metaMap[id.toLowerCase()]?.rarity || 'common', + })); + + const img = await renderLockerImage( + items, + `${items.length} (0)`, + cat.label, + message.author.username, + ogStyleOwned + ); + + if (img) { + await sendLockerImageEmbed(message, { + title: `${displayName} - ${cat.label}`, + description: cat.subtitle, + imageBuffer: img, + fileName: `${cat.label.toLowerCase().replace(/\s/g, '_')}.png`, + }); + } + + await new Promise(r => setTimeout(r, 500)); + } + + } catch (err) { + console.error('Locker image error:', err.message); + } + + } catch (err) { + pending.delete(userId); + console.error('Verify error:', err); + message.author.send('Unexpected error. Try again.').catch(() => null); + } +}); + +client.login(process.env.TOKEN); diff --git a/bot.backup-codex-20260628-024038.js b/bot.backup-codex-20260628-024038.js new file mode 100644 index 0000000..5027c0d --- /dev/null +++ b/bot.backup-codex-20260628-024038.js @@ -0,0 +1,2000 @@ +// ============================================= +// FORTNITE DISCORD BOT — ALL-IN-ONE +// ============================================= +// Commands: +// !setuproles — create all roles +// !addicons — upload skin icons as server emojis +// !setroleicons — apply emojis to roles +// !removeoldemojis — delete old skin emojis +// !verify — verify Fortnite account & auto-assign roles +// +// Run with: node bot.js +// ============================================= + +require('dotenv').config(); +const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder, REST, Routes, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js'); +const sharp = require('sharp'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: ['CHANNEL'], +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ROLES TO CREATE +// ───────────────────────────────────────────────────────────────────────────── +const roles = [ + { name: 'Fully Golden Peely', color: 0xFFD700 }, + { name: 'OG Aerial Assault Trooper', color: 0x8A8A8A }, + { name: 'OG Ghoul Trooper', color: 0xA89DB8 }, + { name: 'OG Renegade Raider', color: 0x7B2D2D }, + { name: 'OG Skull Trooper', color: 0x9B59B6 }, + { name: 'Barricade', color: 0x9E9E9E }, + { name: 'Double Helix', color: 0xD0443C }, + { name: 'Honor Guard', color: 0x4A90D9 }, + { name: 'Wonder', color: 0x2ECC71 }, + { name: 'Surf Strider', color: 0x1ABC9C }, + { name: 'Stealth Reflex', color: 0xE8927C }, + { name: 'Dark Vertex', color: 0x8E44AD }, + { name: 'Royale Bomber', color: 0xE67E22 }, + { name: 'Wildcat', color: 0xE8832A }, + { name: 'Clyde', color: 0xE8832A }, + { name: 'Eon', color: 0x27AE60 }, + { name: 'Huntmaster Saber', color: 0xD4AC0D }, + { name: 'Dark Skully', color: 0x7D6B8A }, + { name: 'Galaxy', color: 0x5B7DD8 }, + { name: 'Ikonik', color: 0xD0443C }, + { name: 'Black Knight', color: 0xCCCCCC }, + { name: 'Glow', color: 0xBB8FCC }, + { name: 'Rogue Spider Knight', color: 0x2ECC71 }, + { name: 'Thrilldiver', color: 0x4A90D9 }, + { name: 'Freediver', color: 0x9E9E9E }, + { name: 'Neo Versa', color: 0x9B59B6 }, + { name: 'Cobalt Snowfoot', color: 0x1ABC9C }, + { name: 'Florin', color: 0x8E44AD }, + { name: "What's Wrong, Toona Fish?", color: 0xCCCCCC }, + { name: 'Cameo Needs Loot', color: 0x2ECC71 }, + { name: 'Victory!', color: 0x9B59B6 }, + { name: 'Collegiate items', color: 0x2ECC71 }, + { name: 'StreamElements', color: 0xF1C40F }, + { name: 'Llamas', color: 0xFF69B4 }, + { name: 'Repaz', color: 0xD4AC0D }, + { name: 'lollek', color: 0xFFFFFF }, + { name: 'Celebration Heart', color: 0x9B59B6 }, + { name: 'Funflower', color: 0xD4C227 }, + { name: 'Level 100', color: 0xBB44EE }, + { name: 'Alpha Banner', color: 0x777777 }, + { name: 'YouTube Banner', color: 0x777777 }, + { name: 'STW Founders', color: 0xE67E22 }, + { name: 'Ultimate STW Founders', color: 0x888888 }, + { name: 'Rare Usernames', color: 0xF1C40F }, + { name: 'Skin Collector', color: 0x888888 }, + { name: 'PS+ Pack Collector', color: 0x3498DB }, + { name: 'Spray Collector', color: 0xCCCCCC }, + { name: 'Rare Banners', color: 0x888888 }, + { name: 'Emote Collector', color: 0xCCCCCC }, + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", color: 0x7B2D2D }, + { name: 'Axe of Champions', color: 0xF1C40F }, + { name: 'Blade of Champions', color: 0xF1C40F }, + { name: 'Axecalibur', color: 0xCCCCCC }, + { name: 'EVA', color: 0x4A90D9 }, + { name: 'AC/DC', color: 0xF1C40F }, + { name: 'Resonator', color: 0x27AE60 }, + { name: 'Pinpoint', color: 0xD0443C }, + { name: 'Stellar Axe', color: 0x5B7DD8 }, + { name: 'Stealth Angular Axe', color: 0x9B59B6 }, + { name: 'Dark Razor', color: 0x7D6B8A }, + { name: 'Electri-claw', color: 0xE8832A }, + { name: 'Dark Splitter', color: 0x8E44AD }, + { name: 'Merry Mint Axe', color: 0x2ECC71 }, + { name: 'Indigo Inverter', color: 0x1ABC9C }, + { name: 'Rose Glow', color: 0xFF69B4 }, + { name: 'Controller', color: 0x3498DB }, + { name: 'Pneumatic Twin', color: 0x4A90D9 }, + { name: 'Perfect Point', color: 0x9E9E9E }, + { name: 'Petrol Pump', color: 0xE67E22 }, + { name: 'Future Relic Scythe', color: 0x8E44AD }, + // --- Exclusive Emoticons --- + { name: 'Skull Trooper Emoticon', color: 0x9B59B6 }, + { name: 'Maevys Emoticon', color: 0xD0443C }, + { name: 'ZetFar Emoticon', color: 0x4A90D9 }, + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', color: 0x3498DB }, // PS Plus Pack 1 + { name: 'Blue Striker', color: 0x3498DB }, // PS Plus Pack 2 + { name: 'Prodigy', color: 0x3498DB }, // PS Plus Pack 4 + { name: 'Carbon Commando', color: 0x3498DB }, // PS Plus Pack 5 + { name: 'Trilogy', color: 0x3498DB }, // PS Plus Pack 8 + { name: 'Point Patroller', color: 0x3498DB }, // PS Plus Pack 9 + { name: 'Cloud Striker', color: 0x3498DB }, // PS Plus Pack 12 + { name: 'Fixer', color: 0x3498DB }, // PS Plus Pack 14 + { name: 'Blizzard Bomber', color: 0x3498DB }, // PS Plus Pack 18 + { name: 'Jun-Hwan', color: 0x3498DB }, // Blue Phoenix Pack + { name: 'Lina Scorch', color: 0x3498DB }, // Blazin Wheels Pack + { name: 'Indigo Kuno', color: 0x9B59B6 }, // PS Generations Cup + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Havoc', color: 0x9146FF }, // Twitch Prime Pack 1 + { name: 'Trailblazer', color: 0x9146FF }, // Twitch Prime Pack 2 + // --- Intel / PC Exclusive --- + { name: 'Reflex', color: 0x0071C5 }, // Intel bundle + // --- OG Battle Pass (C1S2) --- + { name: 'Sparkle Specialist', color: 0xFF69B4 }, + { name: 'Royale Knight', color: 0xCCCCCC }, + { name: 'Blue Squire', color: 0x4A90D9 }, + // --- OG Battle Pass (C1S3) --- + { name: 'The Reaper', color: 0x888888 }, + { name: 'Elite Agent', color: 0x888888 }, + { name: 'Rogue Agent', color: 0x888888 }, + // --- OG Battle Pass (C1S4) --- + { name: 'Omega', color: 0x8E44AD }, + // --- Chapter 2 Season 2 Battle Pass (golden styles) --- + { name: 'Skye', color: 0xF1C40F }, + { name: 'TNTina', color: 0xF1C40F }, + { name: 'Meowscles', color: 0xF1C40F }, + { name: 'Midas', color: 0xFFD700 }, + // --- Gaming Legends / Collab Exclusives --- + { name: 'Travis Scott', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Astro Jack', color: 0xF1C40F }, // Icon Series, never returned + { name: 'Kratos', color: 0xCCCCCC }, // PlayStation collab + { name: 'Master Chief', color: 0x4A90D9 }, // Xbox collab + // --- World Cup / Tournament --- + { name: 'World Warrior', color: 0xF1C40F }, // Fortnite World Cup 2019 + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', color: 0xFF69B4 }, + // --- Save the World Founders --- + { name: 'Rose Team Leader', color: 0xE67E22 }, + { name: 'Warpaint', color: 0xE67E22 }, + // --- OT Banners --- + { name: 'OT1 Banner', color: 0xFFD700 }, + { name: 'OT2 Banner', color: 0xFFD700 }, + { name: 'OT3 Banner', color: 0xFFD700 }, + { name: 'OT4 Banner', color: 0xFFD700 }, + { name: 'OT5 Banner', color: 0xFFD700 }, + { name: 'OT6 Banner', color: 0xFFD700 }, + { name: 'OT7 Banner', color: 0xFFD700 }, + { name: 'OT8 Banner', color: 0xFFD700 }, + { name: 'OT9 Banner', color: 0xFFD700 }, + { name: 'OT10 Banner', color: 0xFFD700 }, + { name: 'OT11 Banner', color: 0xFFD700 }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// EMOJI / ICON MAP +// ───────────────────────────────────────────────────────────────────────────── +const roleIconMap = [ + { name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' }, + { name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' }, + { name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' }, + { name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' }, + { name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' }, + { name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' }, + { name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' }, + { name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' }, + { name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' }, + { name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' }, + { name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' }, + { name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' }, + { name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' }, + { name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' }, + { name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' }, + { name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' }, + { name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' }, + { name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' }, + { name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' }, + { name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' }, + // { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, // temporarily disabled — upload stalls + { name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' }, + { name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' }, + { name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' }, + { name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' }, + { name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' }, + { name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' }, + { name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' }, + { name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' }, + { name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' }, + + // --- Exclusive Pickaxes --- + { name: "Raider's Revenge", id: 'Pickaxe_Lockjaw', emoji: 'RaidersRevenge' }, + { name: 'Axe of Champions', id: 'Pickaxe_ID_376_FNCS', emoji: 'AxeOfChampions' }, + { name: 'Blade of Champions', id: 'Pickaxe_FNCS_S32', emoji: 'BladeOfChampions' }, + { name: 'Axecalibur', id: 'Pickaxe_ID_011_Medieval', emoji: 'Axecalibur' }, + { name: 'EVA', id: 'Pickaxe_ID_028_Space', emoji: 'EVA' }, + { name: 'AC/DC', id: 'Pickaxe_ID_013_Teslacoil', emoji: 'ACDC' }, + { name: 'Resonator', id: 'Pickaxe_ID_077_CarbideWhite', emoji: 'Resonator' }, + { name: 'Pinpoint', id: 'Pickaxe_ID_099_ModernMilitaryRed', emoji: 'Pinpoint' }, + { name: 'Stellar Axe', id: 'Pickaxe_ID_116_Celestial', emoji: 'StellarAxe' }, + { name: 'Stealth Angular Axe', id: 'Pickaxe_ID_189_StreetOpsStealth', emoji: 'StealthAngularAxe' }, + { name: 'Dark Razor', id: 'Pickaxe_ID_178_SpeedyMidnight', emoji: 'DarkRazor' }, + { name: 'Electri-claw', id: 'Pickaxe_ID_398_WildCatFemale', emoji: 'Electriclaw' }, + { name: 'Dark Splitter', id: 'Pickaxe_ID_461_SkullBriteCube', emoji: 'DarkSplitter' }, + { name: 'Merry Mint Axe', id: 'Pickaxe_ID_294_CandyCane', emoji: 'MerryMintAxe' }, + { name: 'Indigo Inverter', id: 'Pickaxe_SnowNinjaDark', emoji: 'IndigoInverter' }, + { name: 'Rose Glow', id: 'Pickaxe_ID_153_RoseLeader', emoji: 'RoseGlow' }, + { name: 'Controller', id: 'Pickaxe_ID_088_PSBurnout', emoji: 'Controller' }, + { name: 'Pneumatic Twin', id: 'Pickaxe_ID_256_TechOpsBlue', emoji: 'PneumaticTwin' }, + { name: 'Perfect Point', id: 'Pickaxe_ID_464_LongShortsMale', emoji: 'PerfectPoint' }, + { name: 'Petrol Pump', id: 'Pickaxe_RetroWheels', emoji: 'PetrolPump' }, + { name: 'Future Relic Scythe', id: 'Pickaxe_SnowSoldierFashion', emoji: 'FutureRelicScythe' }, + + // --- OT Banners (OT1–OT11) --- + { name: 'OT1 Banner', id: 'OT1Banner', emoji: 'OT1Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot1banner/icon.png' }, + { name: 'OT2 Banner', id: 'OT2Banner', emoji: 'OT2Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot2banner/icon.png' }, + { name: 'OT3 Banner', id: 'OT3Banner', emoji: 'OT3Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot3banner/icon.png' }, + { name: 'OT4 Banner', id: 'OT4Banner', emoji: 'OT4Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot4banner/icon.png' }, + { name: 'OT5 Banner', id: 'OT5Banner', emoji: 'OT5Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot5banner/icon.png' }, + { name: 'OT6 Banner', id: 'OT6Banner', emoji: 'OT6Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot6banner/icon.png' }, + { name: 'OT7 Banner', id: 'OT7Banner', emoji: 'OT7Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot7banner/icon.png' }, + { name: 'OT8 Banner', id: 'OT8Banner', emoji: 'OT8Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot8banner/icon.png' }, + { name: 'OT9 Banner', id: 'OT9Banner', emoji: 'OT9Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot9banner/icon.png' }, + { name: 'OT10 Banner', id: 'OT10Banner', emoji: 'OT10Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot10banner/icon.png' }, + { name: 'OT11 Banner', id: 'OT11Banner', emoji: 'OT11Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot11banner/icon.png' }, + + // --- Exclusive Emoticons (in-game emoji cosmetics) --- + { name: 'Skull Trooper Emoticon', id: 'Emoji_Skulltrooper', emoji: 'EmoticonSkullTrooper', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_skulltrooper/smallicon.png' }, + { name: 'Maevys Emoticon', id: 'Emoji_S22_Showdown_Mae', emoji: 'EmoticonMaevys', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_mae/smallicon.png' }, + { name: 'ZetFar Emoticon', id: 'Emoji_S22_Showdown_Zet', emoji: 'EmoticonZetFar', + overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_zet/smallicon.png' }, + + // --- PlayStation Exclusives --- + { name: 'Blue Team Leader', id: 'CID_052_Athena_Commando_F_PSBlue', emoji: 'BlueTeamLeader' }, + { name: 'Blue Striker', id: 'CID_138_Athena_Commando_M_PSBurnout', emoji: 'BlueStriker' }, + { name: 'Prodigy', id: 'CID_296_Athena_Commando_M_Math', emoji: 'Prodigy' }, + { name: 'Carbon Commando', id: 'CID_360_Athena_Commando_M_TechOpsBlue', emoji: 'CarbonCommando' }, + { name: 'Trilogy', id: 'CID_657_Athena_Commando_F_TechOpsBlue', emoji: 'Trilogy' }, + { name: 'Point Patroller', id: 'CID_711_Athena_Commando_M_LongShorts', emoji: 'PointPatroller' }, + { name: 'Cloud Striker', id: 'CID_926_Athena_Commando_F_StreetFashionDiamond', emoji: 'CloudStriker' }, + { name: 'Fixer', id: 'CID_A_101_Athena_Commando_M_TacticalWoodlandBlue', emoji: 'Fixer' }, + { name: 'Blizzard Bomber', id: 'CID_A_421_Athena_Commando_F_BlizzardBomber', emoji: 'BlizzardBomber' }, + { name: 'Jun-Hwan', id: 'Character_BlueJet', emoji: 'JunHwan' }, + { name: 'Lina Scorch', id: 'Character_RetroWheels', emoji: 'LinaScorch' }, + { name: 'Indigo Kuno', id: 'CID_660_Athena_Commando_F_BandageNinjaBlue', emoji: 'IndigoKuno' }, + + // --- Twitch / Amazon Prime --- + { name: 'Sub Commander', id: 'CID_085_Athena_Commando_M_Twitch', emoji: 'SubCommander' }, + { name: 'Havoc', id: 'CID_089_Athena_Commando_M_RetroGrey', emoji: 'Havoc' }, + { name: 'Trailblazer', id: 'CID_114_Athena_Commando_F_TacticalWoodland', emoji: 'Trailblazer' }, + + // --- Intel / PC Exclusive --- + { name: 'Reflex', id: 'CID_259_Athena_Commando_M_StreetOps', emoji: 'Reflex' }, + + // --- OG Battle Pass --- + { name: 'Sparkle Specialist', id: 'CID_039_Athena_Commando_F_Disco', emoji: 'SparkleSpecialist' }, + { name: 'Royale Knight', id: 'CID_033_Athena_Commando_F_Medieval', emoji: 'RoyaleKnight' }, + { name: 'Blue Squire', id: 'CID_032_Athena_Commando_M_Medieval', emoji: 'BlueSquire' }, + { name: 'The Reaper', id: 'CID_084_Athena_Commando_M_Assassin', emoji: 'TheReaper' }, + { name: 'Elite Agent', id: 'CID_083_Athena_Commando_F_Tactical', emoji: 'EliteAgent' }, + { name: 'Rogue Agent', id: 'CID_090_Athena_Commando_M_Tactical', emoji: 'RogueAgent' }, + { name: 'Omega', id: 'CID_116_Athena_Commando_M_CarbideBlack', emoji: 'Omega' }, + + // --- Ch2S2 Battle Pass gold styles --- + { name: 'Skye', id: 'CID_690_Athena_Commando_F_Photographer', emoji: 'Skye' }, + { name: 'TNTina', id: 'CID_691_Athena_Commando_F_TNTina', emoji: 'TNTina' }, + { name: 'Meowscles', id: 'CID_693_Athena_Commando_M_BuffCat', emoji: 'Meowscles' }, + { name: 'Midas', id: 'CID_694_Athena_Commando_M_CatBurglar', emoji: 'Midas' }, + + // --- Gaming Legends / Collab --- + { name: 'Travis Scott', id: 'CID_703_Athena_Commando_M_Cyclone', emoji: 'TravisScott' }, + { name: 'Astro Jack', id: 'CID_761_Athena_Commando_M_CycloneSpace', emoji: 'AstroJack' }, + { name: 'Kratos', id: 'CID_964_Athena_Commando_M_Historian_869BC', emoji: 'Kratos' }, + { name: 'Master Chief', id: 'CID_971_Athena_Commando_M_Jupiter_S0Z6M', emoji: 'MasterChief' }, + + // --- World Cup / Tournament --- + { name: 'World Warrior', id: 'CID_478_Athena_Commando_F_WorldCup', emoji: 'WorldWarrior' }, + + // --- V-Bucks Card Promo --- + { name: 'Color Splash Jellie', id: 'Character_TubaGummi', emoji: 'ColorSplashJellie' }, + + // --- Save the World Founders --- + { name: 'Rose Team Leader', id: 'CID_096_Athena_Commando_F_Founder', emoji: 'RoseTeamLeader' }, + { name: 'Warpaint', id: 'CID_095_Athena_Commando_M_Founder', emoji: 'Warpaint' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// VERIFY ROLE REQUIREMENTS +// ───────────────────────────────────────────────────────────────────────────── +const ROLE_REQUIREMENTS = [ + // OG Season 1 skins (style required) + { role: 'OG Renegade Raider', id: 'cid_028_athena_commando_f', styleTag: 'Mat3' }, + { role: 'OG Aerial Assault Trooper', id: 'cid_017_athena_commando_m', styleTag: 'Stage2' }, + { role: 'OG Ghoul Trooper', id: 'cid_029_athena_commando_f_halloween', styleTag: 'Mat3' }, + { role: 'OG Skull Trooper', id: 'cid_030_athena_commando_m_halloween', styleTag: 'Mat1' }, + // OG Battle Pass skins + { role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' }, + { role: 'Blue Squire', id: 'cid_032_athena_commando_m_medieval' }, + { role: 'Royale Knight', id: 'cid_033_athena_commando_f_medieval' }, + { role: 'Sparkle Specialist', id: 'cid_039_athena_commando_f_disco' }, + { role: 'The Reaper', id: 'cid_084_athena_commando_m_assassin' }, + { role: 'Elite Agent', id: 'cid_083_athena_commando_f_tactical' }, + { role: 'Omega', id: 'cid_116_athena_commando_m_carbideblack', styleTag: 'Stage5' }, + { role: 'Carbide', id: 'cid_115_athena_commando_m_carbideblue', styleTag: 'Stage5' }, + // Samsung bundles + { role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' }, + { role: 'Stellar Axe', id: 'pickaxe_id_116_celestial' }, + { role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' }, + { role: 'Scenario Emote', id: 'eid_kpopdance03' }, + { role: 'Glow', id: 'cid_479_athena_commando_f_davinci' }, + { role: 'Levitate Emote', id: 'eid_davinci' }, + // Device / Platform exclusives + { role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' }, + { role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' }, + { role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' }, + { role: 'Resonator', id: 'pickaxe_id_077_carbidewhite' }, + { role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' }, + { role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' }, + { role: 'Pinpoint', id: 'pickaxe_id_099_modernmilitaryred' }, + { role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' }, + { role: 'Cobalt Snowfoot', id: 'character_snowninjadark' }, + { role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' }, + { role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' }, + { role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' }, + { role: 'Reflex', id: 'cid_259_athena_commando_m_streetops' }, + { role: 'Angular Axe', id: 'pickaxe_id_118_streetops' }, + { role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' }, + { role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' }, + { role: 'Huntmaster Saber', id: 'character_masterkeyorder' }, + // PlayStation Plus exclusives + { role: 'Blue Team Leader', id: 'cid_052_athena_commando_f_psblue' }, + { role: 'Blue Striker', id: 'cid_138_athena_commando_m_psburnout' }, + { role: 'Carbon Commando', id: 'cid_360_athena_commando_m_techopsblue' }, + { role: 'Prodigy', id: 'cid_296_athena_commando_m_math' }, + { role: 'Cloud Striker', id: 'cid_926_athena_commando_f_streetfashiondiamond' }, + { role: 'Fixer', id: 'cid_a_101_athena_commando_m_tacticalwoodlandblue' }, + { role: 'Point Patroller', id: 'cid_711_athena_commando_m_longshorts' }, + // Twitch Prime exclusives + { role: 'Sub Commander', id: 'cid_085_athena_commando_m_twitch' }, + { role: 'Havoc', id: 'cid_089_athena_commando_m_retrogrey' }, + { role: 'Trailblazer', id: 'cid_114_athena_commando_f_tacticalwoodland' }, + // Exclusive Pickaxes + { role: "Raider's Revenge", id: 'pickaxe_lockjaw', styleTag: 'Stage2' }, + { role: 'AC/DC', id: 'pickaxe_id_013_teslacoil' }, + { role: 'Merry Mint Axe', id: 'pickaxe_id_294_candycane' }, + { role: 'Tenderizer', id: 'pickaxe_id_044_tacticalurbanhammer' }, + { role: 'Axe of Champions', id: 'pickaxe_id_376_fncs' }, + { role: 'Blade of Champions', id: 'pickaxe_fncs_s32' }, + // OG / Exclusive Emotes + { role: 'Fresh Emote', id: 'eid_fresh' }, + { role: 'Rambunctious Emote', id: 'eid_zippy' }, + { role: 'Tidy Emote', id: 'eid_tidy' }, + { role: 'Floss Emote', id: 'eid_floss' }, + { role: 'Take the L Emote', id: 'eid_takethelathena' }, + { role: 'Orange Justice Emote', id: 'eid_goodvibes' }, + { role: 'Ride the Pony Emote', id: 'eid_ridethepony_athena' }, + { role: 'Pony Up Emote', id: 'eid_ridetheponymultiple' }, + { role: 'Evasive Maneuvers Emote', id: 'eid_temple' }, + // Exclusive Emoticons + { role: 'Cameo Needs Loot', id: 'emoji_s20_needloot' }, + // Founder exclusives + { role: 'Rose Team Leader', id: 'cid_096_athena_commando_f_founder' }, + { role: 'Warpaint', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Fully Golden Peely', id: 'cid_701_athena_commando_m_bananaagent', styleTag: 'Stage4' }, + // Samsung Galaxy bundle items + { role: 'Galactic Disc', id: 'bid_138_celestial' }, + { role: 'Discovery Glider', id: 'glider_id_090_celestial' }, + // IKONIK bundle - Scenario already added above + // Glow bundle - Levitate already added above + // EON bundle + { role: 'Aurora Glider', id: 'glider_id_056_carbidewhite' }, + // Dark Vertex bundle + { role: 'Dark Deflector', id: 'bid_234_speedymidnight' }, + { role: 'Razor Edge', id: 'pickaxe_id_065_speedyred' }, + { role: 'Dark Forerunner Glider', id: 'glider_id_131_speedymidnight' }, + // Double Helix bundle + { role: 'Telemetry', id: 'bid_114_modernmilitaryred' }, + { role: 'Rotor Glider', id: 'glider_id_075_modernmilitaryred' }, + // Neo Versa bundle + { role: 'Neo Phrenzy', id: 'bid_288_cyberscavengerfemaleblue' }, + // Royale Bomber bundle + { role: 'Royale Air Glider', id: 'glider_id_105_snowboard' }, + // Reflex bundle + { role: 'Response Unit', id: 'bid_140_streetopsmale' }, + { role: 'Pivot Glider', id: 'glider_id_092_streetops' }, + // Wildcat bundle + { role: 'Sleek Strike', id: 'bid_521_wildcat' }, + // Rogue Spider Knight bundle + { role: 'Time Keeper', id: 'bid_395_phantom' }, + // Stealth Reflex bundle + { role: 'Stealth Response Unit', id: 'bid_249_streetopsstealth' }, + // Aerial Assault One glider (S1 exclusive) + { role: 'Aerial Assault One', id: 'glider_id_001', styleTag: 'Stage2' }, + // Extra exclusive pickaxes from master list + { role: 'Trusty No. 2', id: 'pickaxe_id_029_assassin' }, + { role: 'Party Animal', id: 'pickaxe_id_020_keg' }, + { role: 'Crowbar', id: 'pickaxe_id_092_bling' }, + { role: 'Instigator', id: 'pickaxe_id_039_tacticalblack' }, + // Missing emotes from master list + { role: "Widow's Pirouette Emote", id: 'eid_ashtonboardwalk' }, + { role: 'Hot Marat Emote', id: 'eid_wir' }, + { role: 'Kiss the Cup Emote', id: 'eid_trophycelebration' }, + // Missing skins from master list + { role: 'Gildedguy', id: 'cid_a_141_athena_commando_m_dojo' }, + { role: 'Tart Tycoon', id: 'cid_851_athena_commando_m_bittenhead' }, + // Gliders + { role: 'Mako Glider', id: 'glider_warthog' }, + // Rare / STW / Creator banners (checked via homebase_banners in common_core) + { role: 'Alpha Tester Banner', bannerId: 'OtherBanner51' }, + { role: 'World Cup Banner', bannerId: 'BRWorldCup2019' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner1' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner2' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner3' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner4' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner5' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner6' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner7' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner8' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner9' }, + // STW Founder banners + { role: 'STW Founder Banner', bannerId: 'FounderTier5Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier4Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier3Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier2Banner1' }, + { role: 'STW Founder Banner', bannerId: 'FounderTier1Banner1' }, + // STW Endurance banners + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater04' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater03' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater02' }, + { role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater01' }, + // STW Survival / Wargames banners + { role: 'STW Event Banner', bannerId: 'SurvivalBannerStorm2018Hard' }, + { role: 'STW Event Banner', bannerId: 'SurvivalBannerHolidayHard' }, + { role: 'STW Event Banner', bannerId: 'WargamesTprifts' }, + { role: 'STW Event Banner', bannerId: 'WargamesTiki' }, + { role: 'STW Event Banner', bannerId: 'WargamesPods' }, + // STW Achievement banners + { role: 'STW Achievement Banner', bannerId: 'AchievementUnspeakableHorrors' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementWorldExplorer' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGuardianAngel' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementPlaysWellWithOthers' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementTalentedBuilder' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementGoGnome' }, + { role: 'STW Achievement Banner', bannerId: 'AchievementLootLegend' }, + // FNCS / Skirmish / World Cup banners + { role: 'FNCS Banner', bannerId: 'BRS23_FNCS_Drops' }, + { role: 'FNCS Banner', bannerId: 'S11FNCS' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishBushBandits' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishDustyDogs' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishFortKnights' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishLuckyLlamas' }, + { role: 'Skirmish Banner', bannerId: 'BRSkirmishRiftRaiders' }, + // Season Level 100 banners + { role: 'Season Level 100 Banner', bannerId: 'BRS10Level100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS9Lvl100' }, + { role: 'Season Level 100 Banner', bannerId: 'BRS8Level100' }, + // Creator / Influencer banners 10-58 + { role: 'Creator Banner', bannerId: 'InfluencerBanner10' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner11' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner12' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner13' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner14' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner15' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner16' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner17' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner18' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner19' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner20' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner21' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner22' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner23' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner24' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner25' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner26' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner27' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner28' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner29' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner30' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner31' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner32' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner33' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner34' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner35' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner36' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner37' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner38' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner39' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner40' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner41' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner42' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner43' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner44' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner45' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner46' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner47' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner48' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner49' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner50' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner51' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner52' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner53' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner54' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner55' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner56' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner57' }, + { role: 'Creator Banner', bannerId: 'InfluencerBanner58' }, + // OT Banners + { role: 'OT1 Banner', bannerId: 'OT1Banner' }, + { role: 'OT2 Banner', bannerId: 'OT2Banner' }, + { role: 'OT3 Banner', bannerId: 'OT3Banner' }, + { role: 'OT4 Banner', bannerId: 'OT4Banner' }, + { role: 'OT5 Banner', bannerId: 'OT5Banner' }, + { role: 'OT6 Banner', bannerId: 'OT6Banner' }, + { role: 'OT7 Banner', bannerId: 'OT7Banner' }, + { role: 'OT8 Banner', bannerId: 'OT8Banner' }, + { role: 'OT9 Banner', bannerId: 'OT9Banner' }, + { role: 'OT10 Banner', bannerId: 'OT10Banner' }, + { role: 'OT11 Banner', bannerId: 'OT11Banner' }, + // STW Founders + { role: 'STW Founders', id: 'cid_095_athena_commando_m_founder' }, + { role: 'Ultimate STW Founders', id: 'tiertoken_collectionbook_ultimate', stw: true }, +] + +// ───────────────────────────────────────────────────────────────────────────── +// EPIC OAUTH CONSTANTS +// ───────────────────────────────────────────────────────────────────────────── +const SWITCH_TOKEN = 'OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDAxYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3'; +const IOS_TOKEN = 'M2Y2OWU1NmM3NjQ5NDkyYzhjYzI5ZjFhZjA4YThhMTI6YjUxZWU5Y2IxMjIzNGY1MGE2OWVmYTY3ZWY1MzgxMmU='; +const EPIC_OAUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token'; +const EPIC_OAUTH_PROD03 = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token'; +const EPIC_EXCHANGE = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange'; +const EPIC_DEVICE_AUTH = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/deviceAuthorization'; + +const pending = new Map(); +const verifiedUsers = new Map(); // userId -> { guildId, assignedRoles: string[], epicName: string } +const LOG_CHANNEL_ID = '1517695849952706642'; + +// ───────────────────────────────────────────────────────────────────────────── +// SHARED HELPERS +// ───────────────────────────────────────────────────────────────────────────── +async function fetchWithTimeout(url, ms = 12000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { return await fetch(url, { signal: ctrl.signal }); } + finally { clearTimeout(timer); } +} + +// ─── get icon URL — build directly from known CDN pattern, no API roundtrip ── +async function fetchIconUrl(cosmeticId) { + // Direct CDN URL — fortnite-api.com images are always at this pattern + const url = `https://fortnite-api.com/images/cosmetics/br/${cosmeticId.toLowerCase()}/icon.png`; + const res = await fetchWithTimeout(url, 8000); + if (res.ok) return url; + // Fallback to API lookup if direct URL 404s + const apiRes = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`, 8000); + if (!apiRes.ok) throw new Error(`API ${apiRes.status} for ${cosmeticId}`); + const json = await apiRes.json(); + if (!json.data?.images?.icon) throw new Error(`No icon for ${cosmeticId}`); + return json.data.images.icon; +} + +async function downloadAndProcess(imageUrl) { + const res = await fetchWithTimeout(imageUrl, 15000); + if (!res.ok) throw new Error(`Download failed ${res.status}`); + const raw = Buffer.from(await res.arrayBuffer()); + return sharp(raw) + .resize(128, 128, { fit: 'cover', position: 'centre', kernel: 'lanczos3' }) + .sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + +async function createEmojiSafe(guild, buffer, name, reason) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { + return await guild.emojis.create({ attachment: buffer, name, reason }); + } catch (err) { + const isRL = err.status === 429 || err.httpStatus === 429 || + (err.message || '').toLowerCase().includes('rate limit'); + if (isRL) { + const wait = ((err.retryAfter || 5) * 1000) + 1000; + console.warn(` ⏳ Rate limited "${name}", waiting ${Math.ceil(wait/1000)}s...`); + await new Promise(r => setTimeout(r, wait)); + } else { + throw err; + } + } + } + throw new Error(`Gave up on emoji "${name}"`); +} + +// ─── Epic helpers ───────────────────────────────────────────────────────────── +async function getSwitchAccessToken() { + const res = await fetch(EPIC_OAUTH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `basic ${SWITCH_TOKEN}` }, + body: 'grant_type=client_credentials', + }); + return (await res.json()).access_token; +} + +async function createDeviceCode(token) { + const res = await fetch(EPIC_DEVICE_AUTH, { + method: 'POST', + headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return res.json(); +} + +async function waitForLogin(deviceCode, maxMs = 300000) { + const start = Date.now(); + while (Date.now() - start < maxMs) { + await new Promise(r => setTimeout(r, 10000)); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${SWITCH_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=device_code&device_code=${deviceCode}`, + }); + const data = await res.json(); + if (res.status === 200 && data.access_token) return data; + const err = data.errorCode || ''; + if (err.includes('not_found') || err.includes('expired')) return null; + } + return null; +} + +async function exchangeForIosToken(accessToken) { + const exRes = await fetch(EPIC_EXCHANGE, { headers: { 'Authorization': `bearer ${accessToken}` } }); + const exData = await exRes.json(); + const res = await fetch(EPIC_OAUTH_PROD03, { + method: 'POST', + headers: { 'Authorization': `basic ${IOS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=exchange_code&exchange_code=${exData.code}`, + }); + return res.json(); +} + +async function getProfile(accountId, accessToken, profileId) { + const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=${profileId}&rvn=-1`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Authorization': `bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + return res.json(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SKIN CHECKER IMAGE RENDERER (RiftCheckerBot style) +// ───────────────────────────────────────────────────────────────────────────── +const { createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'); +const path = require('path'); + +// Register Fortnite font (Burbank Big Condensed Black) + Tahoma +GlobalFonts.registerFromPath( + path.join(__dirname, 'BurbankBigCondensed-Black.ttf'), 'Burbank' +); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahoma.ttf', 'Tahoma'); +GlobalFonts.registerFromPath('C:/Windows/Fonts/tahomabd.ttf', 'TahomaB'); + +// Custom background URL — set with !setbg , cleared with !clearbg +let customBgUrl = null; + +const ICON = 96; +const COLS = 12; // wider rows = shorter images +const LABEL = 26; +const CELL_H = ICON + LABEL; +const PAD = 14; +const HDR_H = 72; +const FTR_H = 36; + +const RC = { + common: '#c8c8c8', uncommon: '#60aa3a', rare: '#4990e2', + epic: '#874bd2', legendary: '#d3782e', mythic: '#ffd700', + icon: '#54ceec', marvel: '#e63c3c', dark: '#641464', + frozen: '#94dffc', lava: '#ca4d1d', shadow: '#505050', + gaming: '#1a8e48', slurp: '#00e9b0', +}; + +const OG_STYLE_ICONS = { + 'cid_029_athena_commando_f_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png', + 'cid_030_athena_commando_m_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png', + 'cid_028_athena_commando_f': 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 'cid_701_athena_commando_m_bananaagent': 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png', + 'cid_116_athena_commando_m_carbideblack': 'https://fortnite-api.com/images/cosmetics/br/cid_116_athena_commando_m_carbideblack/variants/parts/stage1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Cache the logo so we only fetch once per session +let _logoBuffer = null; +async function getLogoBuffer() { + if (_logoBuffer) return _logoBuffer; + try { + const res = await fetchWithTimeout( + 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + 8000 + ); + if (res.ok) _logoBuffer = Buffer.from(await res.arrayBuffer()); + } catch { _logoBuffer = null; } + return _logoBuffer; +} + +const CACHE_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cache'); +const RARITY_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'styles', 'rift', 'rarity'); +const ICON_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cosmetic_icons'); +const fs = require('fs'); + +// Cosmetics that have special OG variant images in local cache +// key = lowercase cosmetic ID, value = local cache filename +const LOCAL_OG_ICONS = { + 'cid_029_athena_commando_f_halloween': 'pink_ghoul.png', // Pink Ghoul + 'cid_030_athena_commando_m_halloween': 'purple_skull.png', // Purple Skull + 'cid_028_athena_commando_f': 'og_rene.png', // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': 'og_aat.png', // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': 'omega_max.png', // Max Omega + 'cid_701_athena_commando_m_bananaagent': 'gold_peely.png', // Gold Agent Peely +}; + +// Remote OG variant URLs for cosmetics not in local cache +const REMOTE_OG_ICONS = { + 'pickaxe_lockjaw': 'https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png', + 'glider_id_001': 'https://fortnite-api.com/images/cosmetics/br/glider_id_001/variants/particle/particle1.png', + 'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png', + 'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png', + 'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png', + 'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png', +}; + +// Load a rarity background image from local cache +const rarityImageCache = {}; +async function getRarityImage(rarity) { + const key = (rarity || 'common').toLowerCase(); + if (rarityImageCache[key]) return rarityImageCache[key]; + const filePath = path.join(RARITY_DIR, `${key}.png`); + if (fs.existsSync(filePath)) { + try { + const img = await loadImage(fs.readFileSync(filePath)); + rarityImageCache[key] = img; + return img; + } catch { /* fall through */ } + } + // fallback to common + const fallback = path.join(RARITY_DIR, 'common.png'); + if (fs.existsSync(fallback)) { + const img = await loadImage(fs.readFileSync(fallback)); + rarityImageCache['common'] = img; + return img; + } + return null; +} + +// Get cosmetic icon — local cache first, then remote +async function fetchSkinImageBuffer(cosmeticId, useOgStyle = false) { + const id = cosmeticId.toLowerCase(); + + // 1. Try local OG variant if requested + if (useOgStyle && LOCAL_OG_ICONS[id]) { + const localPath = path.join(CACHE_DIR, LOCAL_OG_ICONS[id]); + if (fs.existsSync(localPath)) return fs.readFileSync(localPath); + } + + // 2. Try remote OG variant if requested + if (useOgStyle && REMOTE_OG_ICONS[id]) { + try { + const res = await fetchWithTimeout(REMOTE_OG_ICONS[id], 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + } + + // 3. Check if this is a banner ID — fetch from banner API + const bannerPrefixes = ['ot','influencerbanner','foundertier','brs','survival','endurance','wargames','achievement','otherbanner','skirmish','worldcup','horde','starlight','winter','newsletter']; + if (bannerPrefixes.some(p => id.startsWith(p))) { + // Try local RiftCheckerBot cache first + try { + const files = fs.readdirSync(CACHE_DIR); + const match = files.find(f => f.toLowerCase() === id + '.png' || f.toLowerCase() === cosmeticId + '.png'); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + // Fetch from banner API + try { + const bMeta = await getBannerMeta(); + const iconUrl = bMeta[id]?.icon; + if (iconUrl) { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } + } catch { /* fall through */ } + return null; + } + + // 4. Try local cache with exact cosmetic ID match + try { + const files = fs.readdirSync(CACHE_DIR); + const baseName = cosmeticId + '.png'; + const match = files.find(f => f.toLowerCase() === baseName.toLowerCase()); + if (match) return fs.readFileSync(path.join(CACHE_DIR, match)); + } catch { /* fall through */ } + + // 5. Fortnite API CDN + const iconUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/icon.png`; + try { + const res = await fetchWithTimeout(iconUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* fall through */ } + + // 6. Fallback: smallIcon + const smallUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/smallicon.png`; + try { + const res = await fetchWithTimeout(smallUrl, 8000); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + } catch { /* give up */ } + + return null; +} + +async function renderLockerImage(items, countLabel, subtitle, submittedBy = null, ogStyleOwned = null) { + if (!items || items.length === 0) return null; + + const LOGO_SIZE = HDR_H - 8; + const rows = Math.ceil(items.length / COLS); + const cols = Math.min(items.length, COLS); + const W = Math.max(cols * ICON + PAD * 2, 400); + const H = HDR_H + rows * CELL_H + PAD + FTR_H; + + const canvas = createCanvas(W, H); + const ctx = canvas.getContext('2d'); + + // ── Background ─────────────────────────────────────────────────────────── + if (customBgUrl) { + try { + const res = await fetchWithTimeout(customBgUrl, 10000); + if (res.ok) { + let rawBuf = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get('content-type') || ''; + const isGif = customBgUrl.toLowerCase().endsWith('.gif') || contentType.includes('gif'); + // GIF backgrounds are flattened to their first frame for the generated PNG. + const processed = await sharp(rawBuf, isGif ? { pages: 1 } : {}) + .resize(W, H, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + ctx.drawImage(await loadImage(processed), 0, 0, W, H); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(0, 0, W, H); + } else { throw new Error('bg fetch failed'); } + } catch { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + } else { + const bg = ctx.createLinearGradient(0, 0, 0, H); + bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; + for (let c = 0; c <= cols; c++) { + ctx.beginPath(); ctx.moveTo(PAD + c * ICON, HDR_H); ctx.lineTo(PAD + c * ICON, HDR_H + rows * CELL_H); ctx.stroke(); + } + for (let r = 0; r <= rows; r++) { + ctx.beginPath(); ctx.moveTo(PAD, HDR_H + r * CELL_H); ctx.lineTo(PAD + cols * ICON, HDR_H + r * CELL_H); ctx.stroke(); + } + + // ── Logo ───────────────────────────────────────────────────────────────── + const logoBuf = await getLogoBuffer(); + let logoEndX = PAD; + if (logoBuf) { + try { + const logoResized = await sharp(logoBuf).resize(LOGO_SIZE, LOGO_SIZE, { fit: 'cover' }).png().toBuffer(); + const logoImg = await loadImage(logoResized); + const cx = PAD + LOGO_SIZE / 2, cy = 4 + LOGO_SIZE / 2, lr = LOGO_SIZE / 2; + ctx.save(); + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.clip(); + ctx.drawImage(logoImg, PAD, 4, LOGO_SIZE, LOGO_SIZE); + ctx.restore(); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.stroke(); + logoEndX = PAD + LOGO_SIZE + 10; + } catch { logoEndX = PAD; } + } + + // ── Header text ─────────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 26px TahomaB'; + ctx.fillText(countLabel, logoEndX, 40); + ctx.fillStyle = '#aaaaaa'; + ctx.font = '13px Tahoma'; + ctx.fillText(subtitle.toUpperCase(), logoEndX, 60); + + // ── Cosmetic grid ───────────────────────────────────────────────────────── + let idx = 0; + for (const item of items) { + const col = idx % COLS; + const row = Math.floor(idx / COLS); + const x = PAD + col * ICON; + const y = HDR_H + row * CELL_H; + const id = item.id.toLowerCase(); + + // Determine if we should show OG style for this item + const hasOgRequirement = !!OG_STYLE_REQUIREMENTS[id]; + const ownsOgStyle = ogStyleOwned ? ogStyleOwned.has(id) : false; + const useOg = hasOgRequirement && ownsOgStyle; + + // Effective rarity: mythic if OG style owned, otherwise use metaMap rarity + const effectiveRarity = useOg ? 'mythic' : (item.rarity || 'common'); + + // ── Draw rarity background from local cache ─────────────────────────── + const rarityFilePath = path.join(RARITY_DIR, `${effectiveRarity}.png`); + if (fs.existsSync(rarityFilePath)) { + try { + const rarBuf = await sharp(fs.readFileSync(rarityFilePath)) + .resize(ICON, ICON, { fit: 'fill' }).png().toBuffer(); + ctx.drawImage(await loadImage(rarBuf), x, y, ICON, ICON); + } catch { /* skip */ } + } else { + ctx.fillStyle = (RC[effectiveRarity] || RC.common) + '28'; + ctx.fillRect(x, y, ICON, ICON); + } + + // ── Gradient border around the cell ────────────────────────────────── + const borderColor = RC[effectiveRarity] || RC.common; + // Top edge gradient (left to right) + const topGrad = ctx.createLinearGradient(x, y, x + ICON, y); + topGrad.addColorStop(0, borderColor + '00'); + topGrad.addColorStop(0.3, borderColor + 'ff'); + topGrad.addColorStop(0.7, borderColor + 'ff'); + topGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = topGrad; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ICON, y); ctx.stroke(); + + // Bottom edge gradient + const botGrad = ctx.createLinearGradient(x, y + ICON, x + ICON, y + ICON); + botGrad.addColorStop(0, borderColor + '00'); + botGrad.addColorStop(0.3, borderColor + 'ff'); + botGrad.addColorStop(0.7, borderColor + 'ff'); + botGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = botGrad; + ctx.beginPath(); ctx.moveTo(x, y + ICON); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // Left edge gradient (top to bottom) + const leftGrad = ctx.createLinearGradient(x, y, x, y + ICON); + leftGrad.addColorStop(0, borderColor + '00'); + leftGrad.addColorStop(0.3, borderColor + 'ff'); + leftGrad.addColorStop(0.7, borderColor + 'ff'); + leftGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = leftGrad; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + ICON); ctx.stroke(); + + // Right edge gradient + const rightGrad = ctx.createLinearGradient(x + ICON, y, x + ICON, y + ICON); + rightGrad.addColorStop(0, borderColor + '00'); + rightGrad.addColorStop(0.3, borderColor + 'ff'); + rightGrad.addColorStop(0.7, borderColor + 'ff'); + rightGrad.addColorStop(1, borderColor + '00'); + ctx.strokeStyle = rightGrad; + ctx.beginPath(); ctx.moveTo(x + ICON, y); ctx.lineTo(x + ICON, y + ICON); ctx.stroke(); + + // ── Draw cosmetic icon ──────────────────────────────────────────────── + const buf = await fetchSkinImageBuffer(item.id, useOg); + if (buf) { + try { + const resized = await sharp(buf).resize(ICON - 6, ICON - 6, { fit: 'cover' }).png().toBuffer(); + ctx.drawImage(await loadImage(resized), x + 3, y + 3, ICON - 6, ICON - 6); + } catch { /* skip */ } + } + + // ── Name label ──────────────────────────────────────────────────────── + ctx.fillStyle = '#ffffff'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'center'; + const maxW = ICON - 4; + let name = item.name || ''; + while (name.length > 3 && ctx.measureText(name).width > maxW) name = name.slice(0, -1); + if (name !== item.name) name = name.slice(0, -1) + '..'; + ctx.fillText(name, x + ICON / 2, y + ICON + 17, maxW); + ctx.textAlign = 'left'; + idx++; + } + + // ── Footer ──────────────────────────────────────────────────────────────── + const footerY = H - FTR_H; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, footerY, W, FTR_H); + ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, footerY); ctx.lineTo(W, footerY); ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px TahomaB'; + ctx.fillText(submittedBy ? `Submitted by: ${submittedBy}` : 'Made by jitterbuxg', PAD, footerY + 14); + ctx.fillStyle = '#ffd700'; + ctx.font = '10px Tahoma'; + ctx.fillText('.gg/collecting', PAD, footerY + 28); + + ctx.fillStyle = '#888888'; + ctx.font = '10px Tahoma'; + ctx.textAlign = 'right'; + ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), W - PAD, footerY + 21); + ctx.textAlign = 'left'; + + return canvas.toBuffer('image/png'); +} + +async function sendLockerImageEmbed(message, { title, description, imageBuffer, fileName }) { + const attachment = new AttachmentBuilder(imageBuffer, { name: fileName }); + const safeTitle = String(title || 'Locker Check').length > 250 + ? `${String(title).slice(0, 247)}...` + : String(title || 'Locker Check'); + const embed = new EmbedBuilder() + .setColor(0xFFD700) + .setTitle(safeTitle) + .setDescription(description) + .setImage(`attachment://${fileName}`) + .setTimestamp(); + + await message.author.send({ embeds: [embed], files: [attachment] }); +} + +// Banner metadata cache — fetched once from fortnite-api.com/v1/banners +let bannerMetaCache = null; +async function getBannerMeta() { + if (bannerMetaCache) return bannerMetaCache; + try { + const res = await fetchWithTimeout('https://fortnite-api.com/v1/banners', 10000); + if (res.ok) { + const json = await res.json(); + const map = {}; + for (const b of (json.data || [])) { + map[b.id.toLowerCase()] = { + name: b.devName || b.id, + icon: b.images?.icon || b.images?.smallIcon, + rarity: 'common', + }; + } + bannerMetaCache = map; + return map; + } + } catch { /* ignore */ } + return {}; +} + +const BASE_EXCLUSIVE_BANNER_IDS = [ + // OT Banners + 'ot1banner','ot2banner','ot3banner','ot4banner','ot5banner', + 'ot6banner','ot7banner','ot8banner','ot9banner','ot10banner','ot11banner', + // Alpha tester / rare + 'otherbanner51', + // World Cup / FNCS / Skirmish + 'brworldcup2019','brs9worldcup2019','brs23_fncs_drops','s11fncs', + 'brskirmishbushbandits','brskirmishdustydogs','brskirmishfortknights', + 'brskirmishluckyllamas','brskirmishriftraiders', + // Season level 100 + 'brs8level100','brs9lvl100','brs10level100', +]; + +function looksLikeBannerId(rawId) { + const id = String(rawId || '').trim().toLowerCase(); + if (!id) return false; + return ( + id.includes('banner') || + id.startsWith('ot') || + id.startsWith('brs') || + id.startsWith('brseason') || + id.startsWith('brworldcup') || + id.startsWith('brskirmish') || + id.startsWith('endurance') || + id.startsWith('wargames') || + id.startsWith('survival') || + id.startsWith('achievement') || + id.startsWith('starlight') || + id.startsWith('winter2018') || + id.startsWith('newsletter') || + id.startsWith('horde') + ); +} + +function getExclusiveBannerIds() { + const ids = new Set(BASE_EXCLUSIVE_BANNER_IDS); + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) ids.add(req.bannerId.toLowerCase()); + } + + return ids; +} + +function normalizeLooseName(value) { + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ''); +} + +function readMasterListBannerNames() { + const names = new Set(); + const listPaths = [ + path.join(__dirname, 'Fortnite_Master_Exclusive_List.txt'), + path.join(__dirname, 'RiftCheckerBot-main', 'Fortnite_Master_Exclusive_List.txt'), + ]; + + for (const listPath of listPaths) { + try { + const text = fs.readFileSync(listPath, 'utf8'); + const entryMatches = text.matchAll(/-\s*([^-\r\n=]*?\bBanner)\b/g); + for (const match of entryMatches) { + const clean = match[1].replace(/^Missing\s+/i, '').trim(); + const normalized = normalizeLooseName(clean); + if ( + normalized && + normalized !== 'banner' && + normalized !== 'banners' && + normalized !== 'missingbanner' && + !normalized.includes('manysearch') && + !normalized.includes('founderspickaxes') + ) { + names.add(normalized); + } + } + if (/Founder.s Banners/i.test(text)) names.add(normalizeLooseName("Founder's Banner")); + } catch { /* list is optional */ } + } + + return names; +} + +function addMasterListBannerIds(ids, bannerMeta = {}) { + const wantedNames = readMasterListBannerNames(); + if (wantedNames.size === 0) return ids; + + for (const [id, info] of Object.entries(bannerMeta || {})) { + const normalizedName = normalizeLooseName(info?.name || id); + if (!normalizedName) continue; + for (const wanted of wantedNames) { + if (normalizedName === wanted || normalizedName.includes(wanted)) { + ids.add(id.toLowerCase()); + } + } + } + + return ids; +} + +function getOwnedBannerIdsFromCommonCore(ccItems) { + const owned = new Set(); + for (const item of Object.values(ccItems || {})) { + const candidates = [ + item.templateId, + item.attributes?.banner_id, + item.attributes?.bannerId, + item.attributes?.banner_icon, + item.attributes?.bannerIcon, + ]; + + for (const raw of candidates) { + const value = String(raw || '').toLowerCase(); + if (!value) continue; + const id = value.includes(':') ? value.split(':').pop() : value; + if (looksLikeBannerId(id) || value.includes('homebasebannericon') || value.includes('bannertoken')) { + owned.add(id); + } + } + } + return owned; +} + +function commonCoreOwnsId(ccItems, wantedId) { + const needle = wantedId.toLowerCase(); + if (getOwnedBannerIdsFromCommonCore(ccItems).has(needle)) return true; + return Object.values(ccItems || {}).some(item => { + const templateId = (item.templateId || '').toLowerCase(); + const itemId = templateId.split(':').pop(); + return itemId === needle || templateId.includes(needle); + }); +} + +function collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap = null) { + const items = []; + const seen = new Set(); + const bannerIds = addMasterListBannerIds(getExclusiveBannerIds(), bannerMeta); + const ownedBannerIds = getOwnedBannerIdsFromCommonCore(ccItems); + + for (const id of bannerIds) { + if (seen.has(id) || (!ownedBannerIds.has(id) && !commonCoreOwnsId(ccItems, id))) continue; + seen.add(id); + + const info = bannerMeta[id] || {}; + const name = info.name || id; + items.push({ id, name, rarity: 'mythic' }); + if (metaMap) metaMap[id] = { name, rarity: 'mythic' }; + } + + return items; +} + +// Batch fetch cosmetic metadata (name + rarity) for up to 50 IDs at once +async function fetchCosmeticMeta(ids) { + const map = {}; + if (!ids || ids.length === 0) return map; + for (let i = 0; i < ids.length; i += 50) { + const chunk = ids.slice(i, i + 50); + try { + const qs = chunk.map(id => `id=${encodeURIComponent(id)}`).join('&'); + const res = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/search/ids?language=en&${qs}`, 10000); + if (!res.ok) continue; + const json = await res.json(); + for (const item of (json.data || [])) { + map[item.id.toLowerCase()] = { name: item.name, rarity: item.rarity?.value || 'common' }; + } + } catch { /* skip chunk on error */ } + } + return map; +} + +// Cosmetics that have OG style requirements - id -> one or more owned style tags +const OG_STYLE_REQUIREMENTS = { + 'cid_029_athena_commando_f_halloween': ['Mat3', 'Material.Mat3'], // Pink Ghoul + 'cid_030_athena_commando_m_halloween': ['Mat1', 'ClothingColor.Mat1'], // Purple Skull + 'cid_028_athena_commando_f': ['Mat3', 'Material.Mat3'], // Black & Gold Renegade Raider + 'cid_017_athena_commando_m': ['Stage2', 'Progressive.Stage2'], // OG Aerial Assault Trooper + 'cid_116_athena_commando_m_carbideblack': ['Stage5', 'Progressive.Stage5'], // Max Omega + 'cid_701_athena_commando_m_bananaagent': ['Stage4', 'Progressive.Stage4'], // Gold Agent Peely + 'pickaxe_lockjaw': ['Stage2', 'Progressive.Stage2'], // Black & Gold Raider's Revenge + 'glider_id_001': ['Stage2', 'Particle.Particle1', 'Particle1'], // OG Aerial Assault One +}; + +function requiredStyleTags(reqOrTags) { + if (!reqOrTags) return []; + if (Array.isArray(reqOrTags)) return reqOrTags; + if (Array.isArray(reqOrTags.styleTags)) return reqOrTags.styleTags; + return reqOrTags.styleTag ? [reqOrTags.styleTag] : []; +} + +function ownsAnyStyleTag(ownedStyleSet, tags) { + const wanted = requiredStyleTags(tags).map(tag => String(tag).toLowerCase()); + if (wanted.length === 0) return true; + if (!ownedStyleSet) return false; + const normalizeTag = tag => String(tag || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const wantedNorm = wanted.map(normalizeTag); + const owned = [...ownedStyleSet].map(tag => String(tag).toLowerCase()); + const ownedNorm = owned.map(normalizeTag); + return wanted.some((tag, idx) => { + const normalized = wantedNorm[idx]; + return owned.some(ownedTag => ownedTag === tag || ownedTag.endsWith(`.${tag}`)) || + ownedNorm.some(ownedTag => ownedTag === normalized || ownedTag.endsWith(normalized)); + }); +} + +function qualifiesForRequirement(req, ownedMap) { + const id = req.id.toLowerCase(); + if (!ownedMap[id]) return false; + return ownsAnyStyleTag(ownedMap[id], req); +} + +function uniqueIds(ids) { + return [...new Set(ids.filter(Boolean))]; +} + +function addStyleTags(targetSet, value) { + if (!value) return; + if (Array.isArray(value)) { + for (const tag of value) addStyleTags(targetSet, tag); + return; + } + targetSet.add(String(value)); +} + +function buildOwnedMap(athenaData) { + const owned = {}; // id -> Set of owned style tags + const ogStyleOwned = new Set(); + const outfits = []; + const pickaxes = []; + const emotes = []; + const emoticons = []; // AthenaEmoji type (in-game emoticons, NOT emotes) + const backblings = []; + const gliders = []; + const sprays = []; + const items = athenaData?.profileChanges?.[0]?.profile?.items || {}; + for (const item of Object.values(items)) { + const tpl = (item.templateId || '').toLowerCase(); + if (!tpl.startsWith('athena') && !tpl.startsWith('character')) continue; + const id = tpl.split(':')[1] || ''; + if (!owned[id]) owned[id] = new Set(); + for (const v of (item.attributes?.variants || [])) { + addStyleTags(owned[id], v.owned); + addStyleTags(owned[id], v.active); + } + + if (tpl.startsWith('athenacharacter:') || tpl.startsWith('character:')) outfits.push(id); + else if (tpl.startsWith('athenapickaxe:')) pickaxes.push(id); + else if (tpl.startsWith('athenadance:')) { + if (id.startsWith('spray') || id.startsWith('spid_')) sprays.push(id); + else emotes.push(id); + } + else if (tpl.startsWith('athenaemoji:')) emoticons.push(id); + else if (tpl.startsWith('athenabackpack:')) backblings.push(id); + else if (tpl.startsWith('athenaglider:')) gliders.push(id); + else if (tpl.startsWith('athenasprays:') || tpl.startsWith('athenaspray:')) sprays.push(id); + } + // Populate ogStyleOwned after all variants are collected + for (const id of Object.keys(owned)) { + if (OG_STYLE_REQUIREMENTS[id] && ownsAnyStyleTag(owned[id], OG_STYLE_REQUIREMENTS[id])) { + ogStyleOwned.add(id); + } + } + return { + owned, + ogStyleOwned, + outfits: uniqueIds(outfits), + pickaxes: uniqueIds(pickaxes), + emotes: uniqueIds(emotes), + emoticons: uniqueIds(emoticons), + backblings: uniqueIds(backblings), + gliders: uniqueIds(gliders), + sprays: uniqueIds(sprays), + }; +} + +function getRolesForLocker(ownedMap, ccItems) { + const earned = []; + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId) { + const has = Object.values(ccItems).some(i => + (i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase()) + ); + if (has) earned.push(req.role); + continue; + } + if (req.stw) { + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + if (ccIds.some(cc => cc.includes(req.id.toLowerCase()))) earned.push(req.role); + continue; + } + if (qualifiesForRequirement(req, ownedMap)) earned.push(req.role); + } + const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase()); + const hasFounder = ccIds.some(cc => cc.includes('founder')); + const hasUltimate = ccIds.some(cc => cc.includes('tiertoken_collectionbook_ultimate')); + if (hasUltimate) { earned.push('Ultimate STW Founders'); earned.push('STW Founders'); } + else if (hasFounder) earned.push('STW Founders'); + return [...new Set(earned)]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// BOT READY +// ───────────────────────────────────────────────────────────────────────────── +client.once('ready', async () => { + console.log(`✅ Logged in as ${client.user.tag}`); + console.log('Commands: !setuproles | !addicons | !skipemoji | !stopemojis | !setroleicons | !removeoldemojis | !verify'); + + // Set bot avatar to Renegade Raider Black & Gold style + try { + const imgRes = await fetch('https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png'); + const imgBuf = Buffer.from(await imgRes.arrayBuffer()); + await client.user.setAvatar(imgBuf); + console.log('✅ Bot avatar set to Renegade Raider Black & Gold.'); + } catch (err) { + console.warn('Could not set avatar (may already be set or rate limited):', err.message); + } + + // Register /commands slash command to guild for instant availability + const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const slashCommands = [ + new SlashCommandBuilder() + .setName('commands') + .setDescription('List all bot commands') + .toJSON(), + ]; + try { + await rest.put(Routes.applicationGuildCommands(client.user.id, process.env.EMOJI_GUILD_ID), { body: slashCommands }); + console.log('✅ Slash commands registered to guild.'); + } catch (err) { + console.error('Failed to register slash commands:', err.message); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// /commands slash command +// ───────────────────────────────────────────────────────────────────────────── +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand() || interaction.commandName !== 'commands') return; + + const embed = new EmbedBuilder() + .setColor(0x1a1a2e) + .setAuthor({ + name: 'jitterbuxg', + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Bot Commands') + .setThumbnail('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .addFields( + { + name: 'Admin Setup', + value: [ + '`!setuproles` — Create all Fortnite roles', + '`!addicons` — Upload skin icons as server emojis', + '`!setroleicons` — Apply emojis as role icons', + '`!removeoldemojis` — Wipe all emojis for a fresh upload', + '`!setbg ` — Set custom background (supports GIFs)', + '`!clearbg` — Reset to default background', + ].join('\n'), + inline: false, + }, + { + name: 'During !addicons', + value: '`!skipemoji` — Skip current · `!stopemojis` — Cancel session', + inline: false, + }, + { + name: 'Verification', + value: '`!verify` — Link your Epic account and auto-assign roles based on your locker', + inline: false, + }, + { + name: 'What !verify checks', + value: [ + '**OG Skins** — Renegade Raider (B&G), Ghoul Trooper (Pink), Skull Trooper (Purple), + more', + '**Exclusives** — Galaxy, Ikonik, Glow, Honor Guard, Double Helix, Wildcat, + more', + '**Pickaxes** — Raider\'s Revenge (B&G), Axe of Champions, AC/DC, + more', + '**Emotes** — Fresh, Floss, Take the L, Orange Justice, + more', + '**Banners** — OT1–OT11, Creator Banners, STW Founders, + more', + ].join('\n'), + inline: false, + } + ) + .setImage('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png') + .setFooter({ text: 'Made by jitterbuxg • .gg/collecting' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: false }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setbg / !clearbg — custom background for skin checker images +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + if (message.content.startsWith('!setbg ')) { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + const url = message.content.slice(7).trim(); + if (!url.startsWith('http')) return message.reply('Please provide a valid image URL.'); + customBgUrl = url; + return message.reply(`Background set. All future skin checker images will use this background.`); + } + if (message.content === '!clearbg') { + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + customBgUrl = null; + return message.reply('Background cleared. Back to default dark gradient.'); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setuproles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setuproles') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + await message.reply(`Creating up to ${roles.length} roles...`); + let created = 0, skipped = 0; + const existing = message.guild.roles.cache.map(r => r.name.toLowerCase()); + + for (const role of roles) { + if (existing.includes(role.name.toLowerCase())) { skipped++; continue; } + try { + await message.guild.roles.create({ name: role.name, color: role.color, reason: 'Bulk role setup' }); + console.log(`Created: ${role.name}`); + created++; + await new Promise(r => setTimeout(r, 300)); + } catch (err) { + console.error(`Failed "${role.name}": ${err.message}`); + } + } + message.channel.send(`Done! Created **${created}** roles, skipped **${skipped}** existing.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !removeoldemojis +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!removeoldemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Removing ALL emojis from **${emojiGuild.name}**...`); + console.log(`🎯 Clearing all emojis from: ${emojiGuild.name} (${emojiGuild.id})`); + await emojiGuild.emojis.fetch(); + + const allEmojis = [...emojiGuild.emojis.cache.values()]; + console.log(`📋 Found ${allEmojis.length} emojis to delete`); + let removed = 0; + + for (const emoji of allEmojis) { + try { + await emoji.delete('Clearing all emojis for fresh upload'); + console.log(`🗑 Deleted: ${emoji.name}`); + removed++; + await new Promise(r => setTimeout(r, 400)); + } catch (err) { + console.error(`Failed to delete ${emoji.name}: ${err.message}`); + } + } + message.channel.send(`Removed **${removed}** emojis from **${emojiGuild.name}**. Run !addicons to re-upload.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !addicons + !skipemoji + !stopemojis +// ───────────────────────────────────────────────────────────────────────────── + +// Shared state for the active addicons session +let emojiSession = null; // { resolve, name, channel } + +// !skipemoji — skip the currently stuck emoji +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!skipemoji') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply(`Skipping **${emojiSession.name}**...`); + emojiSession.resolve('skip'); +}); + +// !stopemojis — stop the whole session +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!stopemojis') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; + if (!emojiSession) return message.reply('No emoji upload is currently in progress.'); + message.reply('Stopping emoji upload session.'); + emojiSession.resolve('stop'); +}); + +// Lock to prevent !addicons running multiple times simultaneously +let addIconsRunning = false; + +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!addicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + if (addIconsRunning) + return message.reply('An emoji upload is already in progress. Wait for it to finish or use `!stopemojis` to cancel.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply(`Uploading ${roleIconMap.length} skin icons to **${emojiGuild.name}**.\nType \`!skipemoji\` to skip a stuck one, or \`!stopemojis\` to cancel.`); + await emojiGuild.emojis.fetch(); + const existing = new Set(emojiGuild.emojis.cache.map(e => e.name.toLowerCase())); + let added = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + if (existing.has(entry.emoji.toLowerCase())) { + console.log(`⏭ Exists: ${entry.emoji}`); + skipped++; + continue; + } + + // Set up skip/stop listener for this entry + let sessionResolve; + const skipPromise = new Promise(res => { sessionResolve = res; }); + emojiSession = { resolve: sessionResolve, name: entry.name, channel: message.channel }; + + process.stdout.write(`📥 ${entry.name} — fetching... `); + try { + const iconUrl = entry.overrideUrl || entry.bannerUrl || await fetchIconUrl(entry.id); + process.stdout.write(`processing... `); + const buffer = await downloadAndProcess(iconUrl); + process.stdout.write(`uploading... `); + + // Race the upload against a skip/stop signal + const result = await Promise.race([ + createEmojiSafe(emojiGuild, buffer, entry.emoji, `Skin icon: ${entry.name}`) + .then(() => 'done'), + skipPromise, + ]); + + if (result === 'stop') { + console.log('🛑 Stopped by user.'); + emojiSession = null; + return message.channel.send(`Stopped. Added **${added}**, skipped **${skipped}**, failed **${failed}**.`); + } + if (result === 'skip') { + console.log(`⏭ Skipped: ${entry.name}`); + skipped++; + emojiSession = null; + continue; + } + + console.log(`✅`); + added++; + } catch (err) { + console.log(`❌ ${err.message}`); + failed++; + } + + emojiSession = null; + await new Promise(r => setTimeout(r, 2000)); + } + + emojiSession = null; + message.channel.send(`Done. Added **${added}** emojis to **${emojiGuild.name}**. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !setroleicons +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!setroleicons') return; + if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) + return message.reply('Need Administrator permission.'); + + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env'); + + await message.reply('Applying emojis to roles...'); + await emojiGuild.emojis.fetch(); + await message.guild.roles.fetch(); + let updated = 0, skipped = 0, failed = 0; + + for (const entry of roleIconMap) { + const role = message.guild.roles.cache.find(r => r.name.toLowerCase() === entry.name.toLowerCase()); + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === entry.emoji.toLowerCase()); + if (!role) { console.log(`⏭ Role not found: ${entry.name}`); skipped++; continue; } + if (!emoji) { console.log(`⚠ Emoji missing: ${entry.emoji} — run !addicons first`); skipped++; continue; } + try { + await role.setIcon(emoji); + console.log(`✅ ${entry.name} → :${entry.emoji}:`); + updated++; + } catch (err) { + console.error(`❌ "${entry.name}": ${err.message}`); + failed++; + } + await new Promise(r => setTimeout(r, 500)); + } + message.channel.send(`Done. Applied icons to **${updated}** roles. Skipped **${skipped}**. Failed **${failed}**.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !unverify — remove verification and strip roles +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!unverify') return; + + const userId = message.author.id; + + if (!verifiedUsers.has(userId)) + return message.reply('You are not currently verified. Use `!verify` to link your account.'); + + const prev = verifiedUsers.get(userId); + const guild = client.guilds.cache.get(prev.guildId) || message.guild; + + if (guild) { + try { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) { + for (const roleName of prev.assignedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (role && member.roles.cache.has(role.id)) { + await member.roles.remove(role, 'User unverified').catch(() => null); + } + } + } + } catch (err) { + console.error('Unverify role removal error:', err.message); + } + } + + verifiedUsers.delete(userId); + message.reply(`Your verification as **${prev.epicName}** has been removed and your roles have been stripped. You can now use \`!verify\` to link a different account.`); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// !verify +// ───────────────────────────────────────────────────────────────────────────── +client.on('messageCreate', async (message) => { + if (message.author.bot || message.content !== '!verify') return; + + const userId = message.author.id; + + // Support DMs — if in DM, we need to find the guild separately + let guild = message.guild; + if (!guild) { + // Try to find the main guild from EMOJI_GUILD_ID + guild = client.guilds.cache.get(process.env.EMOJI_GUILD_ID) || client.guilds.cache.first(); + if (!guild) return message.reply('Could not find the server. Please use !verify in the server.'); + } + + // Block if already verified + if (verifiedUsers.has(userId)) { + const prev = verifiedUsers.get(userId); + return message.reply(`You are already verified as **${prev.epicName}**. Use \`!unverify\` to unlink your account and verify a different one.`); + } + + if (pending.has(userId)) + return message.reply('You already have a verification in progress. Check your DMs.'); + + await message.reply('A login link has been sent to your DMs.'); + + try { + const switchToken = await getSwitchAccessToken(); + const deviceData = await createDeviceCode(switchToken); + if (!deviceData.user_code || !deviceData.device_code) + return message.author.send('Failed to generate a login link. Try again later.'); + + const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle('Fortnite Account Verification') + .setDescription( + `**[Click here to verify →](${loginUrl})**\n\n` + + `Code: \`${deviceData.user_code}\`\n\n` + + `Expires in **5 minutes**. The bot will check your locker and assign your roles automatically.` + ) + .setFooter({ text: 'Read-only — the bot only checks your cosmetics.' }); + + await message.author.send({ embeds: [embed] }); + pending.set(userId, { guildId: guild.id, channelId: message.channel.id }); + + const tokenData = await waitForLogin(deviceData.device_code); + pending.delete(userId); + + if (!tokenData) + return message.author.send('Login expired. Use !verify to try again.'); + + const iosData = await exchangeForIosToken(tokenData.access_token); + if (!iosData.access_token) + return message.author.send('Authentication failed. Please try again.'); + + const { account_id: accountId, displayName = 'Unknown', access_token: accessToken } = iosData; + await message.author.send(`Logged in as **${displayName}**. Fetching locker...`); + + const [athenaData, commonCoreData] = await Promise.all([ + getProfile(accountId, accessToken, 'athena'), + getProfile(accountId, accessToken, 'common_core'), + ]); + + if (!athenaData?.profileChanges) + return message.author.send('Failed to read your locker. Please try again.'); + + const { owned: ownedMap, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays } = buildOwnedMap(athenaData); + const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {}; + const earnedRoles = getRolesForLocker(ownedMap, ccItems); + + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) + return message.author.send('Could not find you in the server. Make sure you are still a member.'); + + const assigned = [], notFound = []; + for (const roleName of earnedRoles) { + const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase()); + if (!role) { notFound.push(roleName); continue; } + if (!member.roles.cache.has(role.id)) + await member.roles.add(role, 'Fortnite locker verification').catch(() => null); + assigned.push(roleName); + } + + // Store verified state so they can't re-verify without !unverify + verifiedUsers.set(userId, { guildId: guild.id, assignedRoles: assigned, epicName: displayName }); + + const verifyEmbed = new EmbedBuilder() + .setColor(0x2B2D31) + .setTitle(`${displayName}'s Verification`) + .setDescription( + assigned.length === 0 + ? 'No exclusive roles found.' + : assigned.map(r => `— ${r}`).join('\n') + ) + .addFields( + { name: 'Total Outfits', value: String(outfits.length), inline: true }, + { name: 'Total Pickaxes', value: String(pickaxes.length), inline: true }, + ) + .setFooter({ text: 'Made by jitterbuxg' }); + + await message.author.send({ embeds: [verifyEmbed] }); + + // Send verification log to designated channel + try { + const logChannel = await client.channels.fetch(LOG_CHANNEL_ID).catch(() => null); + if (logChannel) { + // Fetch server emojis to use next to role names + const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null); + await emojiGuild?.emojis.fetch().catch(() => null); + + const getServerEmoji = (roleName) => { + if (!emojiGuild) return ''; + // Map role names to emoji names we uploaded + const emojiNameMap = { + 'Galaxy': 'Galaxy', 'Ikonik': 'Ikonik', 'Glow': 'Glow', + 'OG Renegade Raider': 'RenegadeRaider', 'OG Ghoul Trooper': 'GhoulTrooper', + 'OG Skull Trooper': 'SkullTrooper', 'OG Aerial Assault Trooper': 'AerialAssaultTrooper', + 'Black Knight': 'BlackKnight', 'Double Helix': 'DoubleHelix', + 'Honor Guard': 'HonorGuard', 'Wonder': 'Wonder', 'Eon': 'Eon', + 'Dark Vertex': 'DarkVertex', 'Neo Versa': 'NeoVersa', + 'Royale Bomber': 'RoyaleBomber', 'Wildcat': 'Wildcat', + 'Stealth Reflex': 'StealthReflex', 'Reflex': 'Reflex', + "Raider's Revenge": 'RaidersRevenge', 'Stellar Axe': 'StellarAxe', + 'AC/DC': 'ACDC', 'Merry Mint Axe': 'MerryMintAxe', + 'Axe of Champions': 'AxeOfChampions', 'Blade of Champions': 'BladeOfChampions', + 'Fully Golden Peely': 'FullyGoldenPeely', + }; + const emojiName = emojiNameMap[roleName]; + if (!emojiName) return ''; + const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === emojiName.toLowerCase()); + return emoji ? `${emoji} ` : ''; + }; + + const logEmbed = new EmbedBuilder() + .setColor(assigned.length > 0 ? 0xFFD700 : 0x444444) + .setAuthor({ + name: displayName, + iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png', + }) + .setTitle('Account Verified') + .setDescription( + assigned.length === 0 + ? 'No exclusive cosmetics found on this account.' + : assigned.map(r => `${getServerEmoji(r)}${r}`).join('\n') + ) + .addFields( + { name: 'Discord', value: message.author.username, inline: true }, + { name: 'Epic Account', value: displayName, inline: true }, + { name: 'Roles Given', value: String(assigned.length), inline: true }, + ) + .setFooter({ text: '.gg/collecting • Made by jitterbuxg' }) + .setTimestamp(); + await logChannel.send({ embeds: [logEmbed] }); + } + } catch (err) { + console.error('Log channel error:', err.message); + } + + // ── 1. Fetch metadata for all owned cosmetics ───────────────────────── + const allIds = [...outfits, ...pickaxes]; + const metaMap = await fetchCosmeticMeta(allIds); + + // Override rarity to mythic for known exclusives + for (const req of ROLE_REQUIREMENTS) { + if (!req.bannerId) { + const id = req.id.toLowerCase(); + if (qualifiesForRequirement(req, ownedMap) && metaMap[id]) { + metaMap[id].rarity = 'mythic'; + if (requiredStyleTags(req).length > 0) metaMap[id].name = req.role; + } + } + } + + // ── 2. Exclusives rendered image ────────────────────────────────────── + try { + const exclusiveItems = []; + const exclusiveSeen = new Set(); + const addExclusiveItem = (item) => { + const key = item.id.toLowerCase(); + if (exclusiveSeen.has(key)) return; + exclusiveSeen.add(key); + exclusiveItems.push(item); + }; + + // Exclusive cosmetics (skins, pickaxes, emotes, emoticons) + for (const req of ROLE_REQUIREMENTS) { + if (req.bannerId || req.stw) continue; + const id = req.id.toLowerCase(); + if (!qualifiesForRequirement(req, ownedMap)) continue; + const meta = metaMap[id] || {}; + addExclusiveItem({ id, name: req.role || meta.name || id, rarity: 'mythic' }); + } + + // Exclusive & STW banners - pulled from role requirements and bundled master list + const bannerMeta = await getBannerMeta(); + for (const item of collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap)) { + addExclusiveItem(item); + } + + if (exclusiveItems.length > 0) { + await message.author.send('Building your exclusive cosmetics image...'); + const exclusiveImg = await renderLockerImage( + exclusiveItems, + `${exclusiveItems.length} (0)`, + 'EXCLUSIVES', + message.author.username, + ogStyleOwned + ); + if (exclusiveImg) { + const top5 = exclusiveItems.slice(0, 5).map(i => i.name).join(' + '); + const suffix = exclusiveItems.length > 5 ? ` + ${exclusiveItems.length - 5} more` : ''; + await sendLockerImageEmbed(message, { + title: `${top5}${suffix}`, + description: `${outfits.length} skin count`, + imageBuffer: exclusiveImg, + fileName: 'exclusives.png', + }); + } + } + } catch (err) { + console.error('Exclusive image error:', err.message); + } + + // ── 3. Full locker — all items, sorted by rarity, one image per category ── + try { + // Rarity sort order (matches RiftCheckerBot) + const RARITY_ORDER = ['mythic','legendary','dark','lava','slurp','starwars','marvel','frozen','gaminglegends','shadow','icon','dc','epic','rare','uncommon','common']; + const sortByRarity = (ids) => [...ids].sort((a, b) => { + const ra = RARITY_ORDER.indexOf(metaMap[a.toLowerCase()]?.rarity || 'common'); + const rb = RARITY_ORDER.indexOf(metaMap[b.toLowerCase()]?.rarity || 'common'); + return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb); + }); + + // Collect owned exclusive banners from role requirements, bundled IDs, and the master list. + const bannerMeta = await getBannerMeta(); + const ownedExclusiveBanners = collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap); + const ownedExclusiveBannerIds = ownedExclusiveBanners.map(item => item.id); + + // Fetch metadata for all cosmetic categories + const allCategoryIds = [...outfits, ...pickaxes, ...emotes, ...emoticons, ...backblings, ...gliders, ...sprays]; + const allMeta = await fetchCosmeticMeta(allCategoryIds); + for (const [k, v] of Object.entries(allMeta)) metaMap[k] = metaMap[k] || v; + + const categories = [ + { ids: sortByRarity(outfits), label: 'OUTFITS', subtitle: `${outfits.length} outfits` }, + { ids: sortByRarity(pickaxes), label: 'PICKAXES', subtitle: `${pickaxes.length} pickaxes` }, + { ids: sortByRarity(emotes), label: 'EMOTES', subtitle: `${emotes.length} emotes` }, + { ids: sortByRarity(emoticons), label: 'EMOTICONS', subtitle: `${emoticons.length} emoticons` }, + { ids: sortByRarity(backblings), label: 'BACK BLINGS', subtitle: `${backblings.length} back blings` }, + { ids: sortByRarity(gliders), label: 'GLIDERS', subtitle: `${gliders.length} gliders` }, + { ids: sortByRarity(sprays), label: 'SPRAYS', subtitle: `${sprays.length} sprays` }, + ...(ownedExclusiveBannerIds.length > 0 + ? [{ ids: ownedExclusiveBannerIds, label: 'BANNERS', subtitle: `${ownedExclusiveBannerIds.length} banners` }] + : []), + ].filter(c => c.ids.length > 0); + + if (categories.length === 0) { + await message.author.send('No cosmetics found in locker.'); + return; + } + + await message.author.send(`Building your full locker (${categories.length} categories)...`); + + for (const cat of categories) { + const items = cat.ids.map(id => ({ + id, + name: metaMap[id.toLowerCase()]?.name || id, + rarity: metaMap[id.toLowerCase()]?.rarity || 'common', + })); + + const img = await renderLockerImage( + items, + `${items.length} (0)`, + cat.label, + message.author.username, + ogStyleOwned + ); + + if (img) { + await sendLockerImageEmbed(message, { + title: `${displayName} - ${cat.label}`, + description: cat.subtitle, + imageBuffer: img, + fileName: `${cat.label.toLowerCase().replace(/\s/g, '_')}.png`, + }); + } + + await new Promise(r => setTimeout(r, 500)); + } + + } catch (err) { + console.error('Locker image error:', err.message); + } + + } catch (err) { + pending.delete(userId); + console.error('Verify error:', err); + message.author.send('Unexpected error. Try again.').catch(() => null); + } +}); + +client.login(process.env.TOKEN);