const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require("discord.js"); const { mClient } = require("../index"); const { getCookieBalance, getUnixTime } = require("./cookieEconomy"); /* * ============================================================ * CONFIG * ============================================================ */ const DATABASE_NAME = process.env.M_DB; const COLLECTIONS = { stats: "stats_profiles", achievements: "items_achievements", badges: "guild_badges", achievementConfig: "config_achievements" }; /* * ============================================================ * COLLECTION ACCESS * ============================================================ */ 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 ) }; } /* * ============================================================ * INITIALIZE * ============================================================ */ 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 } ); } /* * ============================================================ * DEFAULT STATS * ============================================================ */ 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 } }; } /* * ============================================================ * ENSURE STATS DOCUMENT * ============================================================ */ async function ensureStatsDocument( userId, guildId ) { const { stats } = getProfileCollections(); await stats.updateOne( { userId, guildId }, { $setOnInsert: getDefaultStats( userId, guildId ) }, { upsert: true } ); } /* * ============================================================ * ENSURE ACHIEVEMENT DOCUMENT * ============================================================ */ async function ensureAchievementDocument( userId, guildId ) { const { achievements } = getProfileCollections(); await achievements.updateOne( { userId, guildId }, { $setOnInsert: { userId, guildId, unlocked: {} } }, { upsert: true } ); } /* * ============================================================ * GET PROFILE STATS * ============================================================ */ async function getProfileStats( userId, guildId ) { const { stats } = getProfileCollections(); const existing = await stats.findOne({ userId, guildId }); return existing ?? getDefaultStats( userId, guildId ); } /* * ============================================================ * TRACK PROFILE EVENT * ============================================================ */ 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 ) { /* * Lifetime gain. */ case "cookie_gain": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesObtainedTotal": event.amount } } ); break; /* * Lifetime loss. */ case "cookie_loss": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesLostTotal": event.amount } } ); break; /* * Voluntary giving. */ case "cookies_given": await stats.updateOne( { userId, guildId }, { $inc: { "economy.cookiesGivenTotal": event.amount } } ); break; /* * Successful player theft. */ case "steal_success": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenTotal": event.amount }, $addToSet: { "unique.playersStolenFrom": event.targetUserId } } ); break; /* * Victim side. */ case "stolen_from_me": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenFromMeTotal": event.amount }, $addToSet: { "unique.playersStolenBy": event.thiefUserId } } ); break; /* * Successful Bot Vault Heist. */ case "vault_steal": await stats.updateOne( { userId, guildId }, { $inc: { "economy.vaultStolenTotal": event.amount } } ); break; /* * Game result. */ case "game_result": await trackGameResult( userId, guildId, event ); break; default: return; } /* * Always evaluate from fresh DB state. */ const updatedStats = await getProfileStats( userId, guildId ); await evaluateAchievements( interaction, userId, updatedStats ); await evaluateBadges( interaction, userId, updatedStats, event ); return updatedStats; } /* * ============================================================ * GAME RESULT TRACKING * ============================================================ */ 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 = {}; /* * ======================================================== * WIN * ======================================================== */ if ( result === "win" ) { increment[ "games.total.won" ] = 1; increment[ `games.${game}.won` ] = 1; increment[ "streaks.currentWin" ] = 1; set[ "streaks.currentLoss" ] = 0; } /* * ======================================================== * LOSS * ======================================================== */ else if ( result === "loss" ) { increment[ "games.total.lost" ] = 1; increment[ `games.${game}.lost` ] = 1; increment[ "streaks.currentLoss" ] = 1; set[ "streaks.currentWin" ] = 0; } /* * ======================================================== * PUSH * ======================================================== */ else if ( result === "push" ) { increment[ "games.total.pushed" ] = 1; increment[ `games.${game}.pushed` ] = 1; /* * Push leaves streaks unchanged. */ } else { return; } /* * ======================================================== * REALIZED NET COOKIE RESULT * ======================================================== * * Positive: * * cookiesWon += profit * * Negative: * * cookiesLost += abs(profit) * * Push: * * normally profit = 0 */ 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 } : {}) } ); /* * Update best streaks. */ 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 } } ); } /* * ============================================================ * ACHIEVEMENT EVALUATION * ============================================================ */ async function evaluateAchievements( interaction, userId, statsDocument ) { const guildId = interaction.guildId; const { achievements, achievementConfig } = getProfileCollections(); await ensureAchievementDocument( userId, guildId ); /* * Only active achievements count toward * normal evaluation and collector progress. */ const definitions = await achievementConfig .find({ active: { $ne: false } }) .toArray(); let unlockedDoc = await achievements.findOne({ userId, guildId }); let unlocked = unlockedDoc?.unlocked ?? {}; /* * ======================================================== * NORMAL ACHIEVEMENTS FIRST * ======================================================== * * Collector achievements are evaluated afterwards, * because unlocking one normal achievement can itself * push the user over 50% or 100%. */ 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(); } } /* * Refresh after normal unlocks. */ unlockedDoc = await achievements.findOne({ userId, guildId }); unlocked = unlockedDoc?.unlocked ?? {}; /* * ======================================================== * COLLECTOR ACHIEVEMENTS * ======================================================== */ const collectorDefinitions = definitions.filter( definition => definition.measure === "achievementPercentage" ); /* * Collector achievements should NOT count themselves * toward the denominator. * * Otherwise adding "50%" and "100%" achievements changes * the percentage they are measuring. */ 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(); } } } /* * ============================================================ * UNLOCK ACHIEVEMENT * ============================================================ */ 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; } /* * ============================================================ * READ STAT VALUE * ============================================================ */ 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 ); } /* * ============================================================ * ACHIEVEMENT NOTIFICATION * ============================================================ */ 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 { /* * Unlock stays persisted even if the Discord * interaction can no longer accept follow-ups. */ } } /* * ============================================================ * BADGE EVALUATION * ============================================================ * * Existing badges remain supported here. * * We can expand this next with: * * - first Slots jackpot * - first natural Blackjack * - first Roulette Zero * - first 5 / 10 / 15 unique steals * - Achievement Hunter * - Completionist */ async function evaluateBadges( interaction, userId, statsDocument, event = {} ) { const guildId = interaction.guildId; if (!guildId) { return; } /* * ======================================================== * ECONOMY BADGES * ======================================================== */ 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" ); } /* * ======================================================== * VAULT BADGE * ======================================================== */ if ( ( statsDocument ?.economy ?.vaultStolenTotal ?? 0 ) >= 100000 ) { await tryClaimBadge( interaction, userId, "first_100k_vault_stolen" ); } /* * ======================================================== * UNIQUE PLAYER THEFT BADGES * ======================================================== */ 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" ); } /* * ======================================================== * SLOT JACKPOT * ======================================================== */ if ( event.type === "game_result" && event.game === "slots" && event.jackpot === true ) { await tryClaimBadge( interaction, userId, "first_slots_jackpot" ); } /* * ======================================================== * NATURAL BLACKJACK * ======================================================== */ if ( event.type === "game_result" && event.game === "blackjack" && event.natural === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_blackjack_natural" ); } /* * ======================================================== * BLACKJACK ALL IN * ======================================================== */ if ( event.type === "game_result" && event.game === "blackjack" && event.allIn === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_blackjack_allin_win" ); } /* * ======================================================== * ROULETTE ZERO * ======================================================== */ if ( event.type === "game_result" && event.game === "roulette" && event.zero === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_roulette_zero" ); } /* * ======================================================== * ROULETTE ALL IN * ======================================================== */ if ( event.type === "game_result" && event.game === "roulette" && event.allIn === true && event.result === "win" ) { await tryClaimBadge( interaction, userId, "first_roulette_allin_win" ); } /* * ======================================================== * ACHIEVEMENT COLLECTOR BADGES * ======================================================== */ await evaluateCollectorBadges( interaction, userId ); } async function evaluateCollectorBadges( interaction, userId ) { const guildId = interaction.guildId; const { achievements, achievementConfig } = getProfileCollections(); /* * Don't count Achievement Hunter / Completionist * themselves toward the percentage. */ 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; /* * First player to 50%. */ if ( percentage >= 0.5 ) { await tryClaimBadge( interaction, userId, "first_achievement_half", { unlockedAchievements: unlockedCount, availableAchievements: activeAchievements.length } ); } /* * First player to 100%. */ if ( percentage >= 1 ) { await tryClaimBadge( interaction, userId, "first_achievement_all", { unlockedAchievements: unlockedCount, availableAchievements: activeAchievements.length } ); } } /* * ============================================================ * CLAIM BADGE * ============================================================ */ async function tryClaimBadge( interaction, userId, badgeId, metadata = null ) { const { badges } = getProfileCollections(); const guildId = interaction.guildId; if ( !guildId ) { return false; } /* * Build the fields that should be written * only when this badge is actually claimed. */ const update = { ownerId: userId, unlockedAt: getUnixTime() }; /* * Optional historical context. * * Example for Completionist: * * { * unlockedAchievements: 64, * availableAchievements: 64 * } */ if ( metadata && typeof metadata === "object" ) { update.metadata = metadata; } /* * Atomic claim. * * Only an unclaimed badge can be updated. * * If two users qualify at essentially the * same time, MongoDB lets only one update * ownerId from null. */ const badge = await badges.findOneAndUpdate( { guildId, badgeId, ownerId: null }, { $set: update }, { returnDocument: "after" } ); /* * Badge does not exist, was already claimed, * or this user did not win the atomic claim. */ if ( !badge || badge.ownerId !== userId ) { return false; } /* * ======================================================== * PUBLIC ANNOUNCEMENT * ======================================================== */ 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.*" ]; /* * Add collector history if applicable. */ 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) { /* * The badge claim itself is already persisted. * * A Discord send failure should never undo it. */ console.error( `[Badge] Failed to announce ${badgeId} for ${userId}:`, error ); } return true; } /* * ============================================================ * PROFILE OVERVIEW * ============================================================ */ 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}` }); } /* * ============================================================ * BADGES PAGE * ============================================================ */ 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}` }); } /* * ============================================================ * ACHIEVEMENTS PAGE * ============================================================ */ async function buildAchievementsEmbed( guildId, user ) { 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 ?? {}; const lines = definitions.map( achievement => { const complete = Boolean( unlocked[ achievement.achievementId ] ); return [ complete ? "✅" : "⬜", " ", achievement.emoji, " **", achievement.name, "** — ", achievement.requirement ].join(""); } ); return new EmbedBuilder() .setTitle( `🏆 ${user.globalName ?? user.username}'s Achievements` ) .setDescription( lines.join("\n") || "No achievements configured." ) .setFooter({ text: `profile:${user.id}` }); } /* * ============================================================ * PROFILE BUTTONS * ============================================================ */ 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 ) ) ]; } /* * ============================================================ * PROFILE TARGET * ============================================================ */ function getProfileTargetId( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; const [ type, userId ] = footer?.split(":") ?? []; if ( type !== "profile" ) { return null; } return userId; } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { ensureProfileSystem, trackProfileEvent, getProfileStats, buildProfileOverview, buildBadgesEmbed, buildAchievementsEmbed, buildProfileButtons, getProfileTargetId };