const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require("discord.js"); const { mClient } = require("../index"); const { getCookieBalance, getUnixTime } = require("./cookieEconomy"); const DATABASE_NAME = process.env.M_DB; const COLLECTIONS = { stats: "stats_profiles", achievements: "items_achievements", badges: "guild_badges", achievementConfig: "config_achievements" }; function getProfileCollections() { if (!DATABASE_NAME) { throw new Error("M_DB is not configured in .env"); } const db = mClient.db(DATABASE_NAME); return { db, stats: db.collection(COLLECTIONS.stats), achievements: db.collection(COLLECTIONS.achievements), badges: db.collection(COLLECTIONS.badges), achievementConfig: db.collection(COLLECTIONS.achievementConfig) }; } async function ensureProfileSystem() { const { stats, achievements, badges, achievementConfig } = getProfileCollections(); await stats.createIndex( { userId: 1, guildId: 1 }, { unique: true } ); await achievements.createIndex( { userId: 1, guildId: 1 }, { unique: true } ); await badges.createIndex( { guildId: 1, badgeId: 1 }, { unique: true } ); await achievementConfig.createIndex( { achievementId: 1 }, { unique: true } ); } function getDefaultStats(userId, guildId) { return { userId, guildId, economy: { cookiesObtainedTotal: 0, cookiesLostTotal: 0, cookiesGivenTotal: 0, stolenTotal: 0, stolenFromMeTotal: 0, vaultStolenTotal: 0 }, unique: { playersStolenFrom: [], playersStolenBy: [] }, games: { total: { played: 0, won: 0, lost: 0, pushed: 0 }, slots: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0 }, blackjack: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0 }, roulette: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0 }, heist: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0 } }, streaks: { currentWin: 0, bestWin: 0, currentLoss: 0, bestLoss: 0 } }; } async function ensureStatsDocument(userId, guildId) { const { stats } = getProfileCollections(); await stats.updateOne( { userId, guildId }, { $setOnInsert: getDefaultStats( userId, guildId ) }, { upsert: true } ); } async function ensureAchievementDocument(userId, guildId) { const { achievements } = getProfileCollections(); await achievements.updateOne( { userId, guildId }, { $setOnInsert: { userId, guildId, unlocked: {} } }, { upsert: true } ); } async function getProfileStats(userId, guildId) { const { stats } = getProfileCollections(); return await stats.findOne({ userId, guildId }) ?? getDefaultStats( userId, guildId ); } async function trackProfileEvent( interaction, userId, event ) { const guildId = interaction.guildId; if ( !guildId || !userId || !event?.type ) { return; } await ensureStatsDocument( userId, guildId ); const { stats } = getProfileCollections(); switch (event.type) { case "cookie_gain": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesObtainedTotal": event.amount } } ); break; case "cookie_loss": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesLostTotal": event.amount } } ); break; case "cookies_given": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesGivenTotal": event.amount } } ); break; case "steal_success": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenTotal": event.amount }, $addToSet: { "unique.playersStolenFrom": event.targetUserId } } ); break; case "stolen_from_me": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenFromMeTotal": event.amount }, $addToSet: { "unique.playersStolenBy": event.thiefUserId } } ); break; case "vault_steal": await stats.updateOne( { userId, guildId }, { $inc: { "economy.vaultStolenTotal": event.amount } } ); break; case "game_result": await trackGameResult( userId, guildId, event ); break; default: return; } const updatedStats = await getProfileStats( userId, guildId ); await evaluateAchievements( interaction, userId, updatedStats ); await evaluateBadges( interaction, userId, updatedStats, event ); return updatedStats; } async function trackGameResult( userId, guildId, event ) { const { stats } = getProfileCollections(); const game = String( event.game ?? "" ).toLowerCase(); if ( ![ "slots", "blackjack", "roulette", "heist" ].includes(game) ) { return; } const result = String( event.result ?? "" ).toLowerCase(); const profit = Number( event.profit ?? 0 ); const increment = { "games.total.played": 1, [`games.${game}.played`]: 1 }; const set = {}; if (result === "win") { increment["games.total.won"] = 1; increment[`games.${game}.won`] = 1; increment["streaks.currentWin"] = 1; set["streaks.currentLoss"] = 0; } else if (result === "loss") { increment["games.total.lost"] = 1; increment[`games.${game}.lost`] = 1; increment["streaks.currentLoss"] = 1; set["streaks.currentWin"] = 0; } else if (result === "push") { increment["games.total.pushed"] = 1; increment[`games.${game}.pushed`] = 1; } else { return; } if (Number.isFinite(profit)) { if (profit > 0) { increment[ `games.${game}.cookiesWon` ] = profit; } else if (profit < 0) { increment[ `games.${game}.cookiesLost` ] = Math.abs(profit); } } await stats.updateOne( { userId, guildId }, { $inc: increment, ...(Object.keys(set).length ? { $set: set } : {}) } ); const current = await stats.findOne({ userId, guildId }); await stats.updateOne( { userId, guildId }, { $max: { "streaks.bestWin": current?.streaks ?.currentWin ?? 0, "streaks.bestLoss": current?.streaks ?.currentLoss ?? 0 } } ); } async function evaluateAchievements( interaction, userId, statsDocument ) { const guildId = interaction.guildId; const { achievements, achievementConfig } = getProfileCollections(); await ensureAchievementDocument( userId, guildId ); const definitions = await achievementConfig .find({ active: { $ne: false } }) .toArray(); let unlockedDoc = await achievements.findOne({ userId, guildId }); let unlocked = unlockedDoc?.unlocked ?? {}; const normalDefinitions = definitions.filter( definition => definition.measure !== "achievementPercentage" ); for (const definition of normalDefinitions) { if (unlocked[definition.achievementId]) { continue; } const currentValue = readStatValue( statsDocument, definition ); if ( currentValue < Number(definition.target) ) { continue; } const unlockedNow = await unlockAchievement( interaction, userId, guildId, definition ); if (unlockedNow) { unlocked[ definition.achievementId ] = getUnixTime(); } } unlockedDoc = await achievements.findOne({ userId, guildId }); unlocked = unlockedDoc?.unlocked ?? {}; const collectorDefinitions = definitions.filter( definition => definition.measure === "achievementPercentage" ); const collectibleDefinitions = definitions.filter( definition => definition.measure !== "achievementPercentage" ); const totalCollectible = collectibleDefinitions.length; const unlockedCollectible = collectibleDefinitions.filter( definition => Boolean( unlocked[ definition.achievementId ] ) ).length; const achievementPercentage = totalCollectible > 0 ? unlockedCollectible / totalCollectible : 0; for (const definition of collectorDefinitions) { if (unlocked[definition.achievementId]) { continue; } if ( achievementPercentage < Number(definition.target) ) { continue; } const unlockedNow = await unlockAchievement( interaction, userId, guildId, definition ); if (unlockedNow) { unlocked[ definition.achievementId ] = getUnixTime(); } } } async function unlockAchievement( interaction, userId, guildId, definition ) { const { achievements } = getProfileCollections(); const field = `unlocked.${definition.achievementId}`; const result = await achievements.updateOne( { userId, guildId, [field]: { $exists: false } }, { $set: { [field]: getUnixTime() } } ); if (result.modifiedCount !== 1) { return false; } await sendAchievementNotification( interaction, definition ); return true; } function readStatValue( document, definition ) { if (!definition.stat) { return 0; } const value = definition.stat .split(".") .reduce( (current, key) => current?.[key], document ); if ( definition.measure === "arrayLength" ) { return Array.isArray(value) ? value.length : 0; } return Number(value ?? 0); } async function sendAchievementNotification( interaction, achievement ) { try { await interaction.followUp({ embeds: [ new EmbedBuilder() .setTitle( "🏆 Achievement Unlocked!" ) .setDescription( [ `${achievement.emoji} **${achievement.name}**`, "", achievement.description ].join("\n") ) ], flags: MessageFlags.Ephemeral }); } catch {} } async function evaluateBadges( interaction, userId, statsDocument, event = {} ) { const guildId = interaction.guildId; if (!guildId) { return; } const balance = await getCookieBalance( userId ); if (balance >= 100000) { await tryClaimBadge( interaction, userId, "first_100k_cookies" ); } if (balance >= 1000000) { await tryClaimBadge( interaction, userId, "first_1m_cookies" ); } if ( ( statsDocument ?.economy ?.vaultStolenTotal ?? 0 ) >= 100000 ) { await tryClaimBadge( interaction, userId, "first_100k_vault_stolen" ); } const uniquePlayersStolenFrom = statsDocument ?.unique ?.playersStolenFrom ?? []; const uniqueStealCount = uniquePlayersStolenFrom.length; if (uniqueStealCount >= 5) { await tryClaimBadge( interaction, userId, "first_unique_steals_5" ); } if (uniqueStealCount >= 10) { await tryClaimBadge( interaction, userId, "first_unique_steals_10" ); } if (uniqueStealCount >= 15) { await tryClaimBadge( interaction, userId, "first_unique_steals_15" ); } if ( event.type === "game_result" && event.game === "slots" && event.jackpot === true ) { await tryClaimBadge( interaction, userId, "first_slots_jackpot" ); } if ( event.type === "game_result" && event.game === "blackjack" && event.natural === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_blackjack_natural" ); } if ( event.type === "game_result" && event.game === "blackjack" && event.allIn === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_blackjack_allin_win" ); } if ( event.type === "game_result" && event.game === "roulette" && event.zero === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_roulette_zero" ); } if ( event.type === "game_result" && event.game === "roulette" && event.allIn === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_roulette_allin_win" ); } await evaluateCollectorBadges( interaction, userId ); } async function evaluateCollectorBadges( interaction, userId ) { const guildId = interaction.guildId; const { achievements, achievementConfig } = getProfileCollections(); const activeAchievements = await achievementConfig .find({ active: { $ne: false }, measure: { $ne: "achievementPercentage" } }) .toArray(); if ( activeAchievements.length === 0 ) { return; } const achievementDocument = await achievements.findOne({ userId, guildId }); const unlocked = achievementDocument?.unlocked ?? {}; const unlockedCount = activeAchievements.filter( achievement => Boolean( unlocked[ achievement.achievementId ] ) ).length; const percentage = unlockedCount / activeAchievements.length; if (percentage >= 0.5) { await tryClaimBadge( interaction, userId, "first_achievement_half", { unlockedAchievements: unlockedCount, availableAchievements: activeAchievements.length } ); } if (percentage >= 1) { await tryClaimBadge( interaction, userId, "first_achievement_all", { unlockedAchievements: unlockedCount, availableAchievements: activeAchievements.length } ); } } async function tryClaimBadge( interaction, userId, badgeId, metadata = null ) { const { badges } = getProfileCollections(); const guildId = interaction.guildId; if (!guildId) { return false; } const update = { ownerId: userId, unlockedAt: getUnixTime() }; if ( metadata && typeof metadata === "object" ) { update.metadata = metadata; } const badge = await badges.findOneAndUpdate( { guildId, badgeId, ownerId: null }, { $set: update }, { returnDocument: "after" } ); if ( !badge || badge.ownerId !== userId ) { return false; } const description = [ `${badge.emoji ?? "🏅"} **${badge.name ?? badge.badgeId}**`, "", `<@${userId}> claimed a guild-unique badge!`, "", badge.description ?? "A unique badge has been claimed.", "", "*This badge can no longer be earned by another player.*" ]; if ( badge.metadata ?.unlockedAchievements !== undefined && badge.metadata ?.availableAchievements !== undefined ) { description.splice( description.length - 1, 0, `Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`, "" ); } try { await interaction.channel.send({ embeds: [ new EmbedBuilder() .setTitle( "🏅 Unique Badge Claimed!" ) .setDescription( description.join("\n") ) .setFooter({ text: `badge:${badge.badgeId}` }) .setTimestamp() ] }); } catch (error) { console.error( `[Badge] Failed to announce ${badgeId} for ${userId}:`, error ); } return true; } async function buildProfileOverview( guildId, user ) { const stats = await getProfileStats( user.id, guildId ); const balance = await getCookieBalance( user.id ); const { achievements, badges, achievementConfig } = getProfileCollections(); const [ achievementDoc, achievementCount, ownedBadges ] = await Promise.all([ achievements.findOne({ userId: user.id, guildId }), achievementConfig.countDocuments({ active: { $ne: false } }), badges.find({ guildId, ownerId: user.id }).toArray() ]); const unlockedCount = Object.keys( achievementDoc?.unlocked ?? {} ).length; return new EmbedBuilder() .setTitle( `👤 ${user.globalName ?? user.username}'s Profile` ) .setThumbnail( user.displayAvatarURL({ size: 256 }) ) .addFields( { name: "🍪 Cookies", value: balance.toLocaleString(), inline: true }, { name: "🏆 Achievements", value: `${unlockedCount} / ${achievementCount}`, inline: true }, { name: "🏅 Unique Badges", value: `${ownedBadges.length}`, inline: true }, { name: "🎮 Games", value: [ `Played: **${stats.games.total.played}**`, `Won: **${stats.games.total.won}**`, `Lost: **${stats.games.total.lost}**`, `Pushes: **${stats.games.total.pushed}**` ].join("\n"), inline: true }, { name: "🔥 Best Streaks", value: [ `Wins: **${stats.streaks.bestWin}**`, `Losses: **${stats.streaks.bestLoss}**` ].join("\n"), inline: true } ) .setFooter({ text: `profile:${user.id}` }); } async function buildBadgesEmbed( guildId, user ) { const { badges } = getProfileCollections(); const documents = await badges.find({ guildId }).toArray(); const owned = documents.filter( badge => badge.ownerId === user.id ); const claimedByOthers = documents.filter( badge => badge.ownerId && badge.ownerId !== user.id ); const unclaimed = documents.filter( badge => !badge.ownerId ); return new EmbedBuilder() .setTitle( `🏅 ${user.globalName ?? user.username}'s Badges` ) .setDescription( [ "### Owned", owned.length ? owned.map( badge => `${badge.emoji} **${badge.name}**\n${badge.description}` ).join("\n\n") : "*No unique badges yet.*", "", "### Claimed by Others", claimedByOthers.length ? claimedByOthers.map( badge => `${badge.emoji} **${badge.name}** — <@${badge.ownerId}>` ).join("\n") : "*None yet.*", "", "### Still Available", unclaimed.length ? unclaimed.map( badge => `${badge.emoji} **${badge.name}**\n${badge.description}` ).join("\n\n") : "*All unique badges have been claimed.*" ].join("\n") ) .setFooter({ text: `profile:${user.id}` }); } async function buildAchievementsEmbed( guildId, user, page = 0 ) { const { achievements, achievementConfig } = getProfileCollections(); const [ unlockedDoc, definitions ] = await Promise.all([ achievements.findOne({ userId: user.id, guildId }), achievementConfig .find({ active: { $ne: false } }) .sort({ category: 1, target: 1 }) .toArray() ]); const unlocked = unlockedDoc?.unlocked ?? {}; /* * Keep pages comfortably below Discord's * 4096-character description limit. * * Using a character limit instead of a fixed * number of achievements also protects us * against achievements with long names or * requirements. */ const MAX_PAGE_LENGTH = 3500; const lines = definitions.map( achievement => { const complete = Boolean( unlocked[ achievement .achievementId ] ); return [ complete ? "✅" : "⬜", " ", achievement.emoji ?? "🏆", " **", achievement.name, "** — ", achievement.requirement ?? achievement.description ?? "Requirement unavailable." ].join(""); } ); /* * Build pages dynamically. */ const pages = []; let currentPage = []; for ( const line of lines ) { const test = [ ...currentPage, line ].join( "\n" ); if ( test.length > MAX_PAGE_LENGTH && currentPage.length > 0 ) { pages.push( currentPage.join( "\n" ) ); currentPage = [ line ]; } else { currentPage.push( line ); } } if ( currentPage.length > 0 ) { pages.push( currentPage.join( "\n" ) ); } /* * No achievements configured. */ if ( pages.length === 0 ) { pages.push( "No achievements configured." ); } /* * Clamp page so old buttons can't request * a page that no longer exists after DB * changes. */ const safePage = Math.max( 0, Math.min( Number(page) || 0, pages.length - 1 ) ); const unlockedCount = definitions.filter( achievement => Boolean( unlocked[ achievement .achievementId ] ) ).length; return new EmbedBuilder() .setTitle( `🏆 ${user.globalName ?? user.username}'s Achievements` ) .setDescription( pages[ safePage ] ) .addFields({ name: "Progress", value: `**${unlockedCount} / ${definitions.length}** achievements unlocked` }) .setFooter({ text: `profile:${user.id}:achievements:${safePage}:${pages.length}` }); } function buildProfileButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "profileOverview" ) .setLabel( "Overview" ) .setEmoji( "👤" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "profileBadges" ) .setLabel( "Badges" ) .setEmoji( "🏅" ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "profileAchievements" ) .setLabel( "Achievements" ) .setEmoji( "🏆" ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "abort" ) .setLabel( "Close" ) .setEmoji( "✖️" ) .setStyle( ButtonStyle.Secondary ) ) ]; } function getProfileTargetId( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const [ type, userId ] = footer?.split(":") ?? []; return type === "profile" ? userId : null; } module.exports = { ensureProfileSystem, trackProfileEvent, getProfileStats, buildProfileOverview, buildBadgesEmbed, buildAchievementsEmbed, buildProfileButtons, getProfileTargetId };