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" }; const PROFILE_TIMEZONE = process.env.PROFILE_TIMEZONE ?? "Europe/Berlin"; const WEEKDAYS = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; function getActivityDateContext( date = new Date() ) { const formatter = new Intl.DateTimeFormat( "en-CA", { timeZone: PROFILE_TIMEZONE, year: "numeric", month: "2-digit", day: "2-digit", weekday: "long", hour: "2-digit", hourCycle: "h23" } ); const parts = Object.fromEntries( formatter .formatToParts(date) .filter( part => part.type !== "literal" ) .map( part => [ part.type, part.value ] ) ); const dateKey = `${parts.year}-${parts.month}-${parts.day}`; const weekday = String( parts.weekday ).toLowerCase(); const hour = Number( parts.hour ); return { dateKey, yesterdayKey: getPreviousDateKey( dateKey ), weekday, hour, weekKey: getIsoWeekKey( dateKey ), timeBucket: getTimeBucket( hour ) }; } function getPreviousDateKey( dateKey ) { const date = new Date( `${dateKey}T00:00:00Z` ); date.setUTCDate( date.getUTCDate() - 1 ); return date .toISOString() .slice( 0, 10 ); } function getIsoWeekKey( dateKey ) { const date = new Date( `${dateKey}T00:00:00Z` ); const day = date.getUTCDay() || 7; date.setUTCDate( date.getUTCDate() + 4 - day ); const year = date.getUTCFullYear(); const yearStart = new Date( Date.UTC( year, 0, 1 ) ); const week = Math.ceil( ( ( date - yearStart ) / 86400000 + 1 ) / 7 ); return ( `${year}-W${String(week).padStart(2, "0")}` ); } function getTimeBucket( hour ) { if ( hour >= 0 && hour <= 5 ) { return "overnight"; } if ( hour >= 6 && hour <= 11 ) { return "morning"; } if ( hour >= 12 && hour <= 17 ) { return "afternoon"; } return "evening"; } async function trackGameActivity( userId, guildId ) { const { stats } = getProfileCollections(); const { dateKey, yesterdayKey, weekday, weekKey, timeBucket } = getActivityDateContext(); /* * Every completed game increments its weekday * and time-bucket counters. */ await stats.updateOne( { userId, guildId }, { $inc: { [`activity.gamesByWeekday.${weekday}`]: 1, [`activity.gamesByTimeBucket.${timeBucket}`]: 1 } } ); /* * Calendar-day state is only updated once per * player per local calendar day. * * The filter also protects against two games * resolving at nearly the same moment. */ await stats.updateOne( { userId, guildId, "activity.lastGameDate": { $ne: dateKey } }, [ { $set: { "activity.currentDailyStreak": { $cond: [ { $eq: [ "$activity.lastGameDate", yesterdayKey ] }, { $add: [ { $ifNull: [ "$activity.currentDailyStreak", 0 ] }, 1 ] }, 1 ] }, "activity.currentWeekDays": { $cond: [ { $eq: [ "$activity.currentWeekKey", weekKey ] }, { $setUnion: [ { $ifNull: [ "$activity.currentWeekDays", [] ] }, [ weekday ] ] }, [ weekday ] ] }, "activity.currentWeekKey": weekKey, "activity.lastActiveDate": dateKey, "activity.lastGameDate": dateKey } }, { $set: { "activity.bestDailyStreak": { $max: [ { $ifNull: [ "$activity.bestDailyStreak", 0 ] }, { $ifNull: [ "$activity.currentDailyStreak", 0 ] } ] } } } ] ); } 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 } ); } async function evaluateSocialRelationshipFlags( userId, guildId, event ) { const { stats } = getProfileCollections(); const document = await stats.findOne({ userId, guildId }); if (!document) { return; } const social = document.social ?? {}; const set = {}; /* * ======================================================== * ET TU, CRUMB? * ======================================================== * * Successfully steal from somebody who has * previously gifted Cookies to you. */ if ( event.type === "steal_success" && event.targetUserId && Array.isArray( social.receivedFrom ) && social.receivedFrom.includes( event.targetUserId ) ) { set[ "special.etTuCrumb" ] = 1; } /* * ======================================================== * NO HARD FEELINGS * ======================================================== * * Give Cookies to somebody who has previously * successfully stolen from you. */ if ( event.type === "cookies_given" && event.targetUserId && Array.isArray( social.successfullyStolenBy ) && social.successfullyStolenBy.includes( event.targetUserId ) ) { set[ "special.noHardFeelings" ] = 1; } /* * ======================================================== * COOKIE LAUNDERING * ======================================================== * * Give Cookies to somebody you have previously * successfully stolen from. */ if ( event.type === "cookies_given" && event.targetUserId && Array.isArray( social.successfullyStolenFrom ) && social.successfullyStolenFrom.includes( event.targetUserId ) ) { set[ "special.cookieLaundering" ] = 1; } if ( Object.keys( set ).length === 0 ) { return; } await stats.updateOne( { userId, guildId }, { $set: set } ); } 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, currentRunItBackStreak: 0, bestRunItBackStreak: 0 }, blackjack: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0, currentRunItBackStreak: 0, bestRunItBackStreak: 0 }, roulette: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0, currentRunItBackStreak: 0, bestRunItBackStreak: 0 }, heist: { played: 0, won: 0, lost: 0, pushed: 0, cookiesWon: 0, cookiesLost: 0 } }, streaks: { currentWin: 0, bestWin: 0, currentLoss: 0, bestLoss: 0 }, social: { giftedTo: [], receivedFrom: [], successfullyStolenFrom: [], successfullyStolenBy: [] }, special: { allInRunItBack: 0, bankruptcyCount: 0, bankruptcyRecoveryBest: 0, etTuCrumb: 0, noHardFeelings: 0, cookieLaundering: 0 }, activity: { currentDailyStreak: 0, bestDailyStreak: 0, lastActiveDate: null, lastGameDate: null, currentWeekKey: null, currentWeekDays: [], gamesByWeekday: { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 0, sunday: 0 }, gamesByTimeBucket: { overnight: 0, morning: 0, afternoon: 0, evening: 0 } }, daily: { dateKey: null, gamesPlayed: [], gamesWon: [], gamesLost: [], collectedCookies: false, gambledAfterCollect: false, wonAnyGame: false, lostAnyGame: false, gaveCookies: false, stoleCookies: false }, profileSchemaVersion: 2 }; } 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 }, ...( event.source === "give" && event.senderUserId ? { $addToSet: { "social.receivedFrom": event.senderUserId }, $set: { "special.lastGiftSenderId": event.senderUserId } } : {} ) } ); break; case "bankruptcy": await stats.updateOne( { userId, guildId }, { $inc: { "special.bankruptcyCount": 1 }, $set: { "special.bankruptcyRecoveryBest": 0 } } ); 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 }, ...(event.targetUserId ? { $addToSet: { "social.giftedTo": event.targetUserId }, $set: { "special.lastGiftTargetId": event.targetUserId } } : {}) } ); break; case "steal_success": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenTotal": event.amount }, $addToSet: { "unique.playersStolenFrom": event.targetUserId, "social.successfullyStolenFrom": event.targetUserId }, $set: { "special.lastSuccessfulStealTargetId": event.targetUserId } } ); break; case "stolen_from_me": await stats.updateOne( { userId, guildId }, { $inc: { "economy.stolenFromMeTotal": event.amount }, $addToSet: { "unique.playersStolenBy": event.thiefUserId, "social.successfullyStolenBy": event.thiefUserId }, $set: { "special.lastSuccessfulThiefId": event.thiefUserId } } ); break; case "vault_steal": await stats.updateOne( { userId, guildId }, { $inc: { "economy.vaultStolenTotal": event.amount } } ); break; case "game_result": await trackGameActivity( userId, guildId ); await trackGameResult( userId, guildId, event ); break; case "game_start": await trackGameStart( userId, guildId, event ); break; default: return; } if ( [ "steal_success", "cookies_given" ].includes( event.type ) ) { await evaluateSocialRelationshipFlags( userId, guildId, event ); } if ( [ "cookie_gain", "cookie_loss", "game_result", "bankruptcy" ].includes( event.type ) ) { await updateBankruptcyRecovery( userId, guildId ); } if ( event.type === "game_result" ) { await trackDailyGameState( userId, guildId, event ); } await trackDailyNarrativeState( userId, guildId, event ); const updatedStats = await getProfileStats( userId, guildId ); await evaluateAchievements( interaction, userId, updatedStats ); await evaluateEventAchievements( interaction, userId, event ); await evaluateBadges( interaction, userId, updatedStats, event ); return updatedStats; } async function recordGameOutcome( interaction, userId, { game, result, profit = 0, ...metadata } ) { return trackProfileEvent( interaction, userId, { type: "game_result", game, result, profit, ...metadata } ); } async function trackGameStart( userId, guildId, event ) { const { stats } = getProfileCollections(); const game = String( event.game ?? "" ).toLowerCase(); if (!["slots", "roulette", "blackjack"].includes(game)) { return; } const source = String( event.source ?? "normal" ).toLowerCase(); const currentPath = `games.${game}.currentRunItBackStreak`; const bestPath = `games.${game}.bestRunItBackStreak`; if (source !== "run_it_back") { await stats.updateOne( { userId, guildId }, { $set: { [currentPath]: 0 } } ); return; } const updated = await stats.findOneAndUpdate( { userId, guildId }, { $inc: { [currentPath]: 1 } }, { returnDocument: "after" } ); const currentStreak = updated?.games?.[game]?.currentRunItBackStreak ?? 0; const set = {}; const previousWager = Number( event.previousWager ?? event.previousBet ?? 0 ); const currentWager = Number( event.wager ?? event.bet ?? 0 ); const chainedAllIn = event.previousAllIn === true && previousWager > 0 && currentWager === previousWager; if (chainedAllIn) { set["special.allInRunItBack"] = 1; } await stats.updateOne( { userId, guildId }, { $max: { [bestPath]: currentStreak }, ...(Object.keys(set).length ? { $set: set } : {}) } ); } 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; increment[ "economy.cookiesObtainedTotal" ] = profit; } else if (profit < 0) { increment[ `games.${game}.cookiesLost` ] = Math.abs(profit); increment[ "economy.cookiesLostTotal" ] = 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 => ![ "achievementPercentage", "event" ].includes( definition.measure ) ); for (const definition of normalDefinitions) { if (unlocked[definition.achievementId]) { continue; } if ( !achievementRequirementsMet( statsDocument, definition ) ) { 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 => isStatBackfillableAchievement( definition ) ); 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, userId, 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, userId, achievement ) { const embed = new EmbedBuilder() .setTitle( "🏆 Achievement Unlocked!" ) .setDescription( [ `<@${userId}> earned an achievement!`, "", `${achievement.emoji ?? "🏆"} **${achievement.name}**`, "", achievement.description ?? achievement.requirement ?? "Achievement unlocked." ].join("\n") ) .setTimestamp(); try { /* * If the interaction has already been acknowledged, * followUp is safe. */ if ( interaction.replied || interaction.deferred ) { return await interaction.followUp({ embeds: [embed] }); } /* * Otherwise don't consume the interaction's primary * reply. The game/button handler still needs it. * * Send the public achievement announcement directly * to the channel instead. */ if (interaction.channel) { return await interaction.channel.send({ embeds: [embed] }); } } catch (error) { console.error( `[Achievement] Failed to announce ${achievement.achievementId} for ${userId}:`, error ); } return null; } 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" ) { const tripletBadges = { "🍋": "first_slots_triple_lemon", "🍒": "first_slots_triple_cherry", "🍪": "first_slots_triple_cookie", "⭐": "first_slots_triple_star", "💎": "first_slots_jackpot" }; const badgeId = tripletBadges[event.triplet]; if (badgeId) { await tryClaimBadge( interaction, userId, badgeId ); } } 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" ); } if ( event.type === "game_start" && event.source === "run_it_back" && event.previousAllIn === true && Number( event.previousWager ?? event.previousBet ?? 0 ) > 0 && Number( event.wager ?? event.bet ?? 0 ) === Number( event.previousWager ?? event.previousBet ?? 0 ) ) { await tryClaimBadge( interaction, userId, "first_all_in_run_it_back" ); } 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 } }) .toArray() ).filter( achievement => isStatBackfillableAchievement( achievement ) ); 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, sessionOwnerId = user.id ) { 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: buildProfileFooter( sessionOwnerId, user.id, "overview", 0, 1 ) }); } async function buildStatsEmbed( guildId, user, sessionOwnerId = user.id ) { const stats = await getProfileStats( user.id, guildId ); const gameLines = [ ["🎰 Slots", stats.games?.slots], ["🃏 Blackjack", stats.games?.blackjack], ["🎡 Roulette", stats.games?.roulette], ["🏦 Heist", stats.games?.heist] ].map(([name, game = {}]) => { const played = Number(game.played ?? 0); const won = Number(game.won ?? 0); const lost = Number(game.lost ?? 0); const pushed = Number(game.pushed ?? 0); const cookiesWon = Number(game.cookiesWon ?? 0); const cookiesLost = Number(game.cookiesLost ?? 0); const decidedGames = won + lost; const winRate = decidedGames > 0 ? (won / decidedGames) * 100 : 0; const net = cookiesWon - cookiesLost; return [ `**${name}**`, `Played **${played}** • W **${won}** • L **${lost}** • Push **${pushed}**`, `Win rate: **${winRate.toFixed(1)}%**`, `Won **${cookiesWon.toLocaleString()} 🍪** • Lost **${cookiesLost.toLocaleString()} 🍪**`, `Net: **${net >= 0 ? "+" : ""}${net.toLocaleString()} 🍪**` ].join("\n"); }); const economy = stats.economy ?? {}; const unique = stats.unique ?? {}; const streaks = stats.streaks ?? {}; const activity = stats.activity ?? {}; const gamesByWeekday = activity.gamesByWeekday ?? {}; const gamesByTimeBucket = activity.gamesByTimeBucket ?? {}; return new EmbedBuilder() .setTitle( `📊 ${user.globalName ?? user.username}'s Stats` ) .setThumbnail( user.displayAvatarURL({ size: 256 }) ) .addFields( { name: "🍪 Economy", value: [ `Obtained: **${Number(economy.cookiesObtainedTotal ?? 0).toLocaleString()} 🍪**`, `Lost: **${Number(economy.cookiesLostTotal ?? 0).toLocaleString()} 🍪**`, `Given: **${Number(economy.cookiesGivenTotal ?? 0).toLocaleString()} 🍪**`, `Stolen: **${Number(economy.stolenTotal ?? 0).toLocaleString()} 🍪**`, `Stolen from you: **${Number(economy.stolenFromMeTotal ?? 0).toLocaleString()} 🍪**`, `Vault stolen: **${Number(economy.vaultStolenTotal ?? 0).toLocaleString()} 🍪**` ].join("\n") }, { name: "🥷 Unique Players", value: [ `Stolen from: **${unique.playersStolenFrom?.length ?? 0}**`, `Stolen by: **${unique.playersStolenBy?.length ?? 0}**` ].join("\n"), inline: true }, { name: "🔥 Streaks", value: [ `Current wins: **${streaks.currentWin ?? 0}**`, `Best wins: **${streaks.bestWin ?? 0}**`, `Current losses: **${streaks.currentLoss ?? 0}**`, `Best losses: **${streaks.bestLoss ?? 0}**` ].join("\n"), inline: true }, { name: "📅 Activity", value: [ `Daily streak: **${activity.currentDailyStreak ?? 0} days**`, `Best streak: **${activity.bestDailyStreak ?? 0} days**`, "", `Mon **${gamesByWeekday.monday ?? 0}** • Tue **${gamesByWeekday.tuesday ?? 0}** • Wed **${gamesByWeekday.wednesday ?? 0}**`, `Thu **${gamesByWeekday.thursday ?? 0}** • Fri **${gamesByWeekday.friday ?? 0}**`, `Sat **${gamesByWeekday.saturday ?? 0}** • Sun **${gamesByWeekday.sunday ?? 0}**`, "", `🌙 Overnight **${gamesByTimeBucket.overnight ?? 0}**`, `🌅 Morning **${gamesByTimeBucket.morning ?? 0}**`, `☀️ Afternoon **${gamesByTimeBucket.afternoon ?? 0}**`, `🌆 Evening **${gamesByTimeBucket.evening ?? 0}**` ].join("\n") }, { name: "🎮 Per Game", value: gameLines.join("\n\n") } ) .setFooter({ text: buildProfileFooter( sessionOwnerId, user.id, "stats", 0, 1 ) }); } async function buildBadgesEmbed( guildId, user, sessionOwnerId = user.id, page = 0 ) { const { badges } = getProfileCollections(); const documents = await badges.find({ guildId }).sort({ category: 1, name: 1 }).toArray(); const owned = documents.filter( badge => badge.ownerId === user.id ); const pages = []; pages.push({ title: "Owned", content: owned.length ? owned.map( badge => formatBadgeLine( badge, user.id, true ) ).join("\n\n") : "*No unique badges yet.*" }); const categoryOrder = [ "economy", "stealing", "slots", "blackjack", "roulette", "heist", "collector" ]; const categories = [...new Set( documents.map( badge => badge.category ?? "other" ) )].sort( (a, b) => { const ai = categoryOrder.indexOf(a); const bi = categoryOrder.indexOf(b); if ( ai === -1 && bi === -1 ) { return a.localeCompare(b); } if (ai === -1) { return 1; } if (bi === -1) { return -1; } return ai - bi; } ); for (const category of categories) { const categoryBadges = documents.filter( badge => (badge.category ?? "other") === category ); if (!categoryBadges.length) { continue; } const chunks = paginateLines( categoryBadges.map( badge => formatBadgeLine( badge, user.id, false ) ), 3200, "\n\n" ); for ( let index = 0; index < chunks.length; index++ ) { pages.push({ title: `${formatCategoryName(category)}${chunks.length > 1 ? ` ${index + 1}/${chunks.length}` : ""}`, content: chunks[index] }); } } const safePage = clampPage( page, pages.length ); const selected = pages[safePage] ?? { title: "Badges", content: "No badges configured." }; return new EmbedBuilder() .setTitle( `🏅 ${user.globalName ?? user.username}'s Badges` ) .setDescription( [ `### ${selected.title}`, selected.content ].join("\n") ) .setFooter({ text: buildProfileFooter( sessionOwnerId, user.id, "badges", safePage, pages.length ) }); } async function buildAchievementsEmbed( guildId, user, sessionOwnerId = user.id, page = 0 ) { const { achievements, achievementConfig } = getProfileCollections(); const [ unlockedDoc, definitions, statsDocument ] = await Promise.all([ achievements.findOne({ userId: user.id, guildId }), achievementConfig .find({ active: { $ne: false } }) .sort({ category: 1, target: 1, name: 1 }) .toArray(), getProfileStats( user.id, guildId ) ]); const unlocked = unlockedDoc?.unlocked ?? {}; const collectibleDefinitions = definitions.filter( definition => isStatBackfillableAchievement( definition ) ); const unlockedCollectible = collectibleDefinitions.filter( definition => Boolean( unlocked[ definition.achievementId ] ) ).length; const collectorPercentage = collectibleDefinitions.length > 0 ? unlockedCollectible / collectibleDefinitions.length : 0; const lines = []; let lastCategory = null; for (const achievement of definitions) { const complete = Boolean( unlocked[ achievement.achievementId ] ); const category = achievement.category ?? "other"; if (category !== lastCategory) { if (lines.length) { lines.push(""); } lines.push( `### ${formatCategoryName(category)}` ); lastCategory = category; } if ( achievement.secret === true && !complete ) { lines.push( "⬜ ❓ **???** — Hidden achievement" ); continue; } let currentValue; let targetValue = Number(achievement.target ?? 0); let progressText = ""; if ( achievement.measure === "achievementPercentage" ) { currentValue = collectorPercentage; progressText = ` • **${Math.min(100, currentValue * 100).toFixed(0)}% / ${Math.min(100, targetValue * 100).toFixed(0)}%**`; } else if ( achievement.measure === "all" || achievement.measure === "any" ) { const { completed, total } = getCompositeProgress( statsDocument, achievement ); progressText = ` • **${completed} / ${total}**`; } else if (achievement.stat) { currentValue = readStatValue( statsDocument, achievement ); progressText = ` • **${Number(currentValue).toLocaleString()} / ${targetValue.toLocaleString()}**`; } const requirement = achievement.requirement ?? achievement.description ?? "Requirement unavailable."; lines.push( [ complete ? "✅" : "⬜", " ", achievement.emoji ?? "🏆", " **", achievement.name, "** — ", requirement, complete ? "" : progressText ].join("") ); } const pages = paginateLines( lines, 3400, "\n" ); if (!pages.length) { pages.push( "No achievements configured." ); } const safePage = clampPage( page, pages.length ); const unlockedCount = definitions.filter( achievement => Boolean( unlocked[ achievement.achievementId ] ) ).length; const completion = definitions.length > 0 ? (unlockedCount / definitions.length) * 100 : 0; return new EmbedBuilder() .setTitle( `🏆 ${user.globalName ?? user.username}'s Achievements` ) .setDescription( pages[safePage] ) .addFields({ name: "Progress", value: `**${unlockedCount} / ${definitions.length}** unlocked • **${completion.toFixed(1)}%** complete` }) .setFooter({ text: buildProfileFooter( sessionOwnerId, user.id, "achievements", safePage, pages.length ) }); } function buildProfileButtons( section = "overview", page = 0, pageCount = 1 ) { const rows = [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "profileOverview" ) .setLabel( "Overview" ) .setEmoji( "👤" ) .setStyle( section === "overview" ? ButtonStyle.Primary : ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "profileStats" ) .setLabel( "Stats" ) .setEmoji( "📊" ) .setStyle( section === "stats" ? ButtonStyle.Primary : ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "profileBadges" ) .setLabel( "Badges" ) .setEmoji( "🏅" ) .setStyle( section === "badges" ? ButtonStyle.Primary : ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "profileAchievements" ) .setLabel( "Achievements" ) .setEmoji( "🏆" ) .setStyle( section === "achievements" ? ButtonStyle.Primary : ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( "profileClose" ) .setLabel( "Close" ) .setEmoji( "✖️" ) .setStyle( ButtonStyle.Danger ) ) ]; if ( [ "badges", "achievements" ].includes(section) && pageCount > 1 ) { const prefix = section === "badges" ? "profileBadges" : "profileAchievements"; rows.push( new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( `${prefix}Left` ) .setLabel( "Previous" ) .setEmoji( "◀️" ) .setDisabled( page <= 0 ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( `${prefix}Page` ) .setLabel( `${page + 1} / ${pageCount}` ) .setDisabled( true ) .setStyle( ButtonStyle.Secondary ), new ButtonBuilder() .setCustomId( `${prefix}Right` ) .setLabel( "Next" ) .setEmoji( "▶️" ) .setDisabled( page >= pageCount - 1 ) .setStyle( ButtonStyle.Secondary ) ) ); } return rows; } function buildProfileFooter( sessionOwnerId, profileUserId, section, page, pageCount ) { return [ "profile", sessionOwnerId, profileUserId, section, page, pageCount ].join(":"); } function getProfileContext( interaction ) { const footer = interaction.message ?.embeds?.[0] ?.footer ?.text; return getProfileContextFromFooter( footer ); } function getProfileContextFromFooter( footer ) { const [ type, sessionOwnerId, profileUserId, section = "overview", rawPage = "0", rawPageCount = "1" ] = footer?.split(":") ?? []; if ( type !== "profile" || !sessionOwnerId || !profileUserId ) { return null; } return { sessionOwnerId, profileUserId, section, page: Number(rawPage) || 0, pageCount: Math.max( 1, Number(rawPageCount) || 1 ) }; } function getProfileTargetId( interaction ) { return getProfileContext( interaction )?.profileUserId ?? null; } async function requireProfileOwner( interaction ) { const context = getProfileContext( interaction ); if (!context) { await replyProfileError( interaction, "This profile session is no longer valid." ); return null; } if ( context.sessionOwnerId !== interaction.user.id ) { await replyProfileError( interaction, "This isn't your profile session." ); return null; } return context; } async function replyProfileError( interaction, content ) { const payload = { content, flags: MessageFlags.Ephemeral }; if ( interaction.replied || interaction.deferred ) { return interaction.followUp( payload ); } return interaction.reply( payload ); } function paginateLines( lines, maxLength = 3400, separator = "\n" ) { const pages = []; let current = []; for (const line of lines) { const candidate = [...current, line].join( separator ); if ( candidate.length > maxLength && current.length ) { pages.push( current.join( separator ) ); current = [line]; } else { current.push(line); } } if (current.length) { pages.push( current.join( separator ) ); } return pages; } function clampPage( page, pageCount ) { return Math.max( 0, Math.min( Number(page) || 0, Math.max( 0, pageCount - 1 ) ) ); } function formatCategoryName( category ) { const labels = { economy: "🍪 Economy", stealing: "🥷 Stealing", slots: "🎰 Slots", blackjack: "🃏 Blackjack", roulette: "🎡 Roulette", heist: "🏦 Heist", collector: "🏆 Collector", other: "🏅 Other", activity: "📅 Activity", vault: "🏦 Vault", victim: "🎯 Victim", }; return labels[category] ?? `🏅 ${category.charAt(0).toUpperCase()}${category.slice(1)}`; } function formatBadgeLine( badge, profileUserId, ownedPage ) { const emoji = badge.emoji ?? "🏅"; const description = badge.description ?? "No description available."; if (ownedPage) { return `${emoji} **${badge.name}**\n${description}`; } let status; if ( badge.ownerId === profileUserId ) { status = "✅ **Owned by this player**"; } else if (badge.ownerId) { status = `🔒 Claimed by <@${badge.ownerId}>`; } else { status = "🟢 **Available**"; } return [ `${emoji} **${badge.name}**`, description, status ].join("\n"); } function readStatPath( document, stat ) { if (!stat) { return undefined; } return String(stat) .split(".") .reduce( (current, key) => current?.[key], document ); } function evaluateCondition( document, condition ) { if ( !condition || !condition.stat ) { return false; } const actual = readStatPath( document, condition.stat ); const expected = condition.value; const operator = String( condition.operator ?? "gte" ).toLowerCase(); switch (operator) { case "eq": return ( actual === expected ); case "neq": return ( actual !== expected ); case "gt": return ( Number(actual ?? 0) > Number(expected ?? 0) ); case "gte": return ( Number(actual ?? 0) >= Number(expected ?? 0) ); case "lt": return ( Number(actual ?? 0) < Number(expected ?? 0) ); case "lte": return ( Number(actual ?? 0) <= Number(expected ?? 0) ); case "includes": return ( Array.isArray(actual) && actual.includes( expected ) ); case "not_includes": return ( Array.isArray(actual) && !actual.includes( expected ) ); case "truthy": return Boolean( actual ); case "falsy": return !Boolean( actual ); default: return false; } } function evaluateCompositeAchievement( document, definition ) { const conditions = Array.isArray( definition.conditions ) ? definition.conditions : []; if (!conditions.length) { return false; } if ( definition.measure === "all" ) { return conditions.every( condition => evaluateCondition( document, condition ) ); } if ( definition.measure === "any" ) { return conditions.some( condition => evaluateCondition( document, condition ) ); } return false; } function isStatBackfillableAchievement( definition ) { if (!definition) { return false; } if ( definition.measure === "achievementPercentage" || definition.measure === "event" ) { return false; } if ( definition.measure === "all" || definition.measure === "any" ) { return ( Array.isArray( definition.conditions ) && definition.conditions.length > 0 ); } return Boolean( definition.stat ); } function achievementRequirementsMet( document, definition ) { if (!definition) { return false; } if ( definition.measure === "all" || definition.measure === "any" ) { return evaluateCompositeAchievement( document, definition ); } if (!definition.stat) { return false; } const currentValue = readStatValue( document, definition ); return ( currentValue >= Number( definition.target ?? 0 ) ); } function getCompositeProgress( statsDocument, definition ) { const conditions = Array.isArray( definition.conditions ) ? definition.conditions : []; if (!conditions.length) { return { completed: 0, total: 0 }; } const completed = conditions.filter( condition => evaluateCondition( statsDocument, condition ) ).length; return { completed, total: conditions.length }; } function buildEventContext( interaction, event ) { const activity = getActivityDateContext(); return { ...event, localDate: activity.dateKey, localWeekday: activity.weekday, localHour: activity.hour, localWeekKey: activity.weekKey, localTimeBucket: activity.timeBucket, isWeekend: activity.weekday === "saturday" || activity.weekday === "sunday", isWeekday: ![ "saturday", "sunday" ].includes( activity.weekday ) }; } function readEventPath( event, path ) { if (!path) { return undefined; } return String(path) .split(".") .reduce( (current, key) => current?.[key], event ); } function evaluateEventCondition( event, condition ) { if ( !condition || !condition.field ) { return false; } const actual = readEventPath( event, condition.field ); const expected = condition.value; const operator = String( condition.operator ?? "eq" ).toLowerCase(); switch (operator) { case "eq": return ( actual === expected ); case "neq": return ( actual !== expected ); case "gt": return ( Number(actual ?? 0) > Number(expected ?? 0) ); case "gte": return ( Number(actual ?? 0) >= Number(expected ?? 0) ); case "lt": return ( Number(actual ?? 0) < Number(expected ?? 0) ); case "lte": return ( Number(actual ?? 0) <= Number(expected ?? 0) ); case "in": return ( Array.isArray( expected ) && expected.includes( actual ) ); case "not_in": return ( Array.isArray( expected ) && !expected.includes( actual ) ); case "truthy": return Boolean( actual ); case "falsy": return !Boolean( actual ); default: return false; } } function evaluateEventAchievement( event, definition ) { const conditions = Array.isArray( definition.eventConditions ) ? definition.eventConditions : []; if (!conditions.length) { return false; } const mode = String( definition.eventMode ?? "all" ).toLowerCase(); if (mode === "any") { return conditions.some( condition => evaluateEventCondition( event, condition ) ); } return conditions.every( condition => evaluateEventCondition( event, condition ) ); } async function evaluateEventAchievements( interaction, userId, event ) { const guildId = interaction.guildId; if ( !guildId || !event?.type ) { return; } const { achievements, achievementConfig } = getProfileCollections(); await ensureAchievementDocument( userId, guildId ); const definitions = await achievementConfig .find({ active: { $ne: false }, measure: "event" }) .toArray(); if (!definitions.length) { return; } const achievementDocument = await achievements.findOne({ userId, guildId }); const unlocked = achievementDocument ?.unlocked ?? {}; const eventContext = buildEventContext( interaction, event ); for ( const definition of definitions ) { if ( unlocked[ definition.achievementId ] ) { continue; } /* * Optional fast filter. * * This avoids evaluating Roulette conditions * against a Cookie Bakery event, for example. */ if ( definition.eventType && definition.eventType !== eventContext.type ) { continue; } if ( !evaluateEventAchievement( eventContext, definition ) ) { continue; } await unlockAchievement( interaction, userId, guildId, definition ); } } async function updateBankruptcyRecovery( userId, guildId ) { const { stats } = getProfileCollections(); const document = await stats.findOne( { userId, guildId }, { projection: { "special.bankruptcyCount": 1 } } ); if ( Number( document ?.special ?.bankruptcyCount ?? 0 ) <= 0 ) { return; } const balance = await getCookieBalance( userId ); await stats.updateOne( { userId, guildId }, { $max: { "special.bankruptcyRecoveryBest": balance } } ); } async function trackDailyGameState( userId, guildId, event ) { if ( event.type !== "game_result" ) { return; } const game = String( event.game ?? "" ).toLowerCase(); const result = String( event.result ?? "" ).toLowerCase(); if ( ![ "slots", "blackjack", "roulette", "heist" ].includes( game ) ) { return; } const { stats } = getProfileCollections(); const { dateKey } = getActivityDateContext(); const document = await stats.findOne( { userId, guildId }, { projection: { daily: 1 } } ); const currentDateKey = document ?.daily ?.dateKey ?? null; if ( currentDateKey !== dateKey ) { await stats.updateOne( { userId, guildId }, { $set: { "daily.dateKey": dateKey, "daily.gamesPlayed": [game], "daily.gamesWon": result === "win" ? [game] : [], "daily.gamesLost": result === "loss" ? [game] : [], "daily.collectedCookies": false, "daily.gambledAfterCollect": false, "daily.wonAnyGame": result === "win", "daily.lostAnyGame": result === "loss", "daily.gaveCookies": false, "daily.stoleCookies": false } } ); return; } const addToSet = { "daily.gamesPlayed": game }; const set = {}; if ( result === "win" ) { addToSet[ "daily.gamesWon" ] = game; set[ "daily.wonAnyGame" ] = true; } if ( result === "loss" ) { addToSet[ "daily.gamesLost" ] = game; set[ "daily.lostAnyGame" ] = true; } await stats.updateOne( { userId, guildId }, { $addToSet: addToSet, ...( Object.keys( set ).length ? { $set: set } : {} ) } ); } async function trackDailyNarrativeState( userId, guildId, event ) { const { stats } = getProfileCollections(); const { dateKey } = getActivityDateContext(); const document = await stats.findOne( { userId, guildId }, { projection: { daily: 1 } } ); const currentDateKey = document ?.daily ?.dateKey ?? null; if ( currentDateKey !== dateKey ) { await stats.updateOne( { userId, guildId }, { $set: { "daily.dateKey": dateKey, "daily.gamesPlayed": [], "daily.gamesWon": [], "daily.gamesLost": [], "daily.collectedCookies": false, "daily.gambledAfterCollect": false, "daily.wonAnyGame": false, "daily.lostAnyGame": false, "daily.gaveCookies": false, "daily.stoleCookies": false } } ); } const set = {}; if ( event.type === "cookie_gain" && event.source === "collect" ) { set[ "daily.collectedCookies" ] = true; } if ( event.type === "cookies_given" ) { set[ "daily.gaveCookies" ] = true; } if ( event.type === "steal_success" ) { set[ "daily.stoleCookies" ] = true; } if ( event.type === "game_start" && document ?.daily ?.collectedCookies === true ) { set[ "daily.gambledAfterCollect" ] = true; } if ( Object.keys( set ).length === 0 ) { return; } await stats.updateOne( { userId, guildId }, { $set: set } ); } module.exports = { ensureProfileSystem, trackProfileEvent, recordGameOutcome, getProfileStats, getProfileCollections, getDefaultStats, getCompositeProgress, updateBankruptcyRecovery, readStatValue, readStatPath, evaluateCondition, evaluateCompositeAchievement, achievementRequirementsMet, isStatBackfillableAchievement, getActivityDateContext, trackGameActivity, buildProfileOverview, buildStatsEmbed, buildBadgesEmbed, buildAchievementsEmbed, buildProfileButtons, getProfileContext, getProfileContextFromFooter, getProfileTargetId, requireProfileOwner, buildEventContext, readEventPath, evaluateEventCondition, evaluateEventAchievement, evaluateEventAchievements, evaluateSocialRelationshipFlags, trackDailyGameState, trackDailyNarrativeState, };