diff --git a/buttons/crumbSaucer/slots.js b/buttons/crumbSaucer/slots.js index 6c2d63d..75bef41 100644 --- a/buttons/crumbSaucer/slots.js +++ b/buttons/crumbSaucer/slots.js @@ -1,7 +1,7 @@ const { showSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/crumbSaucer/slots10.js b/buttons/crumbSaucer/slots10.js index 0655654..476ab7e 100644 --- a/buttons/crumbSaucer/slots10.js +++ b/buttons/crumbSaucer/slots10.js @@ -1,7 +1,7 @@ const { playSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/crumbSaucer/slots100.js b/buttons/crumbSaucer/slots100.js index 057b733..899abbb 100644 --- a/buttons/crumbSaucer/slots100.js +++ b/buttons/crumbSaucer/slots100.js @@ -1,7 +1,7 @@ const { playSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/crumbSaucer/slots1000.js b/buttons/crumbSaucer/slots1000.js index defe80d..7eadf58 100644 --- a/buttons/crumbSaucer/slots1000.js +++ b/buttons/crumbSaucer/slots1000.js @@ -1,7 +1,7 @@ const { playSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/crumbSaucer/slots50.js b/buttons/crumbSaucer/slots50.js index cfc11f9..e652558 100644 --- a/buttons/crumbSaucer/slots50.js +++ b/buttons/crumbSaucer/slots50.js @@ -1,7 +1,7 @@ const { playSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/crumbSaucer/slots500.js b/buttons/crumbSaucer/slots500.js index c0f4ba8..c93b26e 100644 --- a/buttons/crumbSaucer/slots500.js +++ b/buttons/crumbSaucer/slots500.js @@ -1,7 +1,7 @@ const { playSlots } = require( - "../../utils/crumbSaucer" + "../../utils/crumbSlots.js" ); diff --git a/buttons/profile/achievements.js b/buttons/profile/achievements.js new file mode 100644 index 0000000..d9f5038 --- /dev/null +++ b/buttons/profile/achievements.js @@ -0,0 +1,22 @@ +const { + buildAchievementsEmbed, + buildProfileButtons, + getProfileTargetId +} = require("../../utils/profileSystem"); + +module.exports = { + name: "profileAchievements", + + async execute(interaction) { + const userId = getProfileTargetId(interaction); + if (!userId) return; + + const user = await interaction.client.users.fetch(userId); + const embed = await buildAchievementsEmbed(interaction.guildId, user); + + return interaction.update({ + embeds: [embed], + components: buildProfileButtons() + }); + } +}; diff --git a/buttons/profile/badges.js b/buttons/profile/badges.js new file mode 100644 index 0000000..e87af86 --- /dev/null +++ b/buttons/profile/badges.js @@ -0,0 +1,22 @@ +const { + buildBadgesEmbed, + buildProfileButtons, + getProfileTargetId +} = require("../../utils/profileSystem"); + +module.exports = { + name: "profileBadges", + + async execute(interaction) { + const userId = getProfileTargetId(interaction); + if (!userId) return; + + const user = await interaction.client.users.fetch(userId); + const embed = await buildBadgesEmbed(interaction.guildId, user); + + return interaction.update({ + embeds: [embed], + components: buildProfileButtons() + }); + } +}; diff --git a/buttons/profile/overview.js b/buttons/profile/overview.js new file mode 100644 index 0000000..23049ee --- /dev/null +++ b/buttons/profile/overview.js @@ -0,0 +1,22 @@ +const { + buildProfileOverview, + buildProfileButtons, + getProfileTargetId +} = require("../../utils/profileSystem"); + +module.exports = { + name: "profileOverview", + + async execute(interaction) { + const userId = getProfileTargetId(interaction); + if (!userId) return; + + const user = await interaction.client.users.fetch(userId); + const embed = await buildProfileOverview(interaction.guildId, user); + + return interaction.update({ + embeds: [embed], + components: buildProfileButtons() + }); + } +}; diff --git a/commands/applications/profile.js b/commands/applications/profile.js new file mode 100644 index 0000000..a71f604 --- /dev/null +++ b/commands/applications/profile.js @@ -0,0 +1,34 @@ +const { SlashCommandBuilder } = require("discord.js"); + +const { + buildProfileOverview, + buildProfileButtons, + ensureGuildBadges +} = require("../../utils/profileSystem"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("profile") + .setDescription("View a player profile") + .addUserOption(option => + option + .setName("user") + .setDescription("Player to view") + ), + + async execute(interaction) { + const user = interaction.options.getUser("user") ?? interaction.user; + + await ensureGuildBadges(interaction.guildId); + + const embed = await buildProfileOverview( + interaction.guildId, + user + ); + + return interaction.reply({ + embeds: [embed], + components: buildProfileButtons() + }); + } +}; diff --git a/utils/crumbSlots.js b/utils/crumbSlots.js new file mode 100644 index 0000000..658bc3f --- /dev/null +++ b/utils/crumbSlots.js @@ -0,0 +1,399 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getCookieBalance, + transferCookies, + temporaryEphemeralReply, + randomItem +} = require("./cookieEconomy"); + +const { + trackProfileEvent +} = require("./profileSystem"); + +const SLOT_BETS = [10, 50, 100, 500, 1000]; + +const SLOT_SYMBOLS = [ + "๐Ÿ’", + "๐Ÿ‹", + "๐Ÿช", + "โญ", + "๐Ÿ’Ž" +]; + +const SLOT_OUTCOMES = [ + { weight: 300, type: "loss", multiplier: 0 }, + { weight: 390, type: "pair", multiplier: 0.5 }, + { weight: 150, type: "triple", symbol: "๐Ÿ‹", multiplier: 1.5 }, + { weight: 80, type: "triple", symbol: "๐Ÿ’", multiplier: 2 }, + { weight: 45, type: "triple", symbol: "๐Ÿช", multiplier: 3 }, + { weight: 25, type: "triple", symbol: "โญ", multiplier: 5 }, + { weight: 10, type: "triple", symbol: "๐Ÿ’Ž", multiplier: 10 } +]; + +async function showSlots(interaction) { + const ownerId = getSlotsSessionOwner(interaction); + + if (ownerId !== interaction.user.id) { + return temporaryEphemeralReply(interaction, { + content: "This isn't your Crumb Saucer session." + }); + } + + const balance = await getCookieBalance(interaction.user.id); + + const embed = new EmbedBuilder() + .setTitle("๐ŸŽฐ Crumb Saucer Slots") + .setDescription([ + "Welcome to the Cookie reels.", + "", + "Choose your wager below.", + "", + "๐Ÿ‹ **Triple Lemon** โ€” x1.5", + "๐Ÿ’ **Triple Cherry** โ€” x2", + "๐Ÿช **Triple Cookie** โ€” x3", + "โญ **Triple Star** โ€” x5", + "๐Ÿ’Ž **Jackpot** โ€” x10" + ].join("\n")) + .addFields({ + name: "Your Vault", + value: `**${balance.toLocaleString()} ๐Ÿช**` + }) + .setFooter({ + text: `crumbSaucerSlots:${interaction.user.id}` + }); + + return interaction.update({ + embeds: [embed], + components: buildSlotBetButtons() + }); +} + +async function playSlots(interaction, bet) { + const ownerId = getSlotsSessionOwner(interaction); + + if (ownerId !== interaction.user.id) { + return temporaryEphemeralReply(interaction, { + content: "These aren't your slots. Open your own Crumb Saucer session." + }); + } + + if (!SLOT_BETS.includes(bet)) { + return temporaryEphemeralReply(interaction, { + content: "That wager isn't available." + }); + } + + const playerId = interaction.user.id; + const vaultId = interaction.client.user.id; + + const [playerBalance, vaultBalance] = await Promise.all([ + getCookieBalance(playerId), + getCookieBalance(vaultId) + ]); + + if (playerBalance < bet) { + return temporaryEphemeralReply(interaction, { + content: `You need **${bet.toLocaleString()} Cookies** for that wager.` + }); + } + + const maxPayout = bet * 10; + + if (vaultBalance + bet < maxPayout) { + return temporaryEphemeralReply(interaction, { + content: "The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager." + }); + } + + try { + await transferCookies(playerId, vaultId, bet); + } catch (error) { + if (error.message === "INSUFFICIENT_COOKIES") { + return temporaryEphemeralReply(interaction, { + content: "Your Cookie balance changed before the spin could begin." + }); + } + throw error; + } + + await interaction.update({ + embeds: [ + buildSpinningEmbed( + bet, + ["โ”", "โ”", "โ”"], + "The reels are spinning..." + ) + ], + components: [] + }); + + await sleep(500); + + const firstPreview = randomItem(SLOT_SYMBOLS); + + await interaction.editReply({ + embeds: [ + buildSpinningEmbed( + bet, + [firstPreview, "โ”", "โ”"], + "The first reel stops..." + ) + ], + components: [] + }); + + await sleep(500); + + const secondPreview = randomItem(SLOT_SYMBOLS); + + await interaction.editReply({ + embeds: [ + buildSpinningEmbed( + bet, + [firstPreview, secondPreview, "โ”"], + "One more..." + ) + ], + components: [] + }); + + await sleep(600); + + const outcome = rollSlotOutcome(); + const result = generateOutcomeLine(outcome); + const payout = Math.floor(bet * outcome.multiplier); + + if (payout > 0) { + try { + await transferCookies(vaultId, playerId, payout); + } catch (error) { + console.error("CRITICAL: Crumb Saucer Slots payout failed:", error); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle("๐ŸŽฐ Crumb Saucer Error") + .setDescription( + "The reels stopped, but the payout system had a problem. Please contact an administrator." + ) + .setFooter({ + text: `crumbSaucerSlotsResult:${playerId}:${bet}` + }) + ], + components: buildResultButtons() + }); + } + } + + const [playerAfter, vaultAfter] = await Promise.all([ + getCookieBalance(playerId), + getCookieBalance(vaultId) + ]); + + const profit = payout - bet; + + await trackProfileEvent(interaction, playerId, { + type: "game_result", + game: "slots", + result: profit > 0 ? "win" : profit < 0 ? "loss" : "push" + }); + + if (profit > 0) { + await trackProfileEvent(interaction, playerId, { + type: "cookie_gain", + amount: profit + }); + } else if (profit < 0) { + await trackProfileEvent(interaction, playerId, { + type: "cookie_loss", + amount: Math.abs(profit) + }); + } + + const resultEmbed = new EmbedBuilder() + .setTitle("๐ŸŽฐ Crumb Saucer Slots") + .setDescription([ + "## " + result.join(" "), + "", + `### ${getSlotResultName(outcome)}` + ].join("\n")) + .addFields( + { name: "Bet", value: `${bet.toLocaleString()} ๐Ÿช`, inline: true }, + { name: "Multiplier", value: `x${outcome.multiplier}`, inline: true }, + { name: "Payout", value: `${payout.toLocaleString()} ๐Ÿช`, inline: true }, + { name: "Profit", value: `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} ๐Ÿช`, inline: true }, + { name: "Your Vault", value: `${playerAfter.toLocaleString()} ๐Ÿช`, inline: true }, + { name: "House Vault", value: `${vaultAfter.toLocaleString()} ๐Ÿช`, inline: true } + ) + .setFooter({ + text: `crumbSaucerSlotsResult:${playerId}:${bet}` + }); + + return interaction.editReply({ + embeds: [resultEmbed], + components: buildResultButtons() + }); +} + +async function runSlotsBack(interaction) { + const footer = interaction.message?.embeds?.[0]?.footer?.text; + const parts = footer?.split(":") ?? []; + + if (parts[0] !== "crumbSaucerSlotsResult") { + return temporaryEphemeralReply(interaction, { + content: "This Slots result can no longer be replayed." + }); + } + + const ownerId = parts[1]; + const bet = Number(parts[2]); + + if (ownerId !== interaction.user.id) { + return temporaryEphemeralReply(interaction, { + content: "These aren't your slots." + }); + } + + if (!SLOT_BETS.includes(bet)) { + return temporaryEphemeralReply(interaction, { + content: "The previous wager is no longer available." + }); + } + + return playSlots(interaction, bet); +} + +function buildSlotBetButtons() { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("crumbSaucerSlots10").setLabel("10").setEmoji("๐Ÿช").setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId("crumbSaucerSlots50").setLabel("50").setEmoji("๐Ÿช").setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId("crumbSaucerSlots100").setLabel("100").setEmoji("๐Ÿช").setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId("crumbSaucerSlots500").setLabel("500").setEmoji("๐Ÿช").setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId("crumbSaucerSlots1000").setLabel("1,000").setEmoji("๐Ÿช").setStyle(ButtonStyle.Danger) + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("crumbSaucerBack").setLabel("Back to Crumb Saucer").setEmoji("โ—€๏ธ").setStyle(ButtonStyle.Secondary) + ) + ]; +} + +function buildResultButtons() { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("crumbSaucerSlotsRunItBack").setLabel("Run It Back").setEmoji("๐Ÿ”").setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId("crumbSaucerSlots").setLabel("Change Bet").setEmoji("๐ŸŽฐ").setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId("crumbSaucerBack").setLabel("Crumb Saucer").setEmoji("โ—€๏ธ").setStyle(ButtonStyle.Secondary) + ) + ]; +} + +function rollSlotOutcome() { + const totalWeight = SLOT_OUTCOMES.reduce( + (total, outcome) => total + outcome.weight, + 0 + ); + + let roll = Math.random() * totalWeight; + + for (const outcome of SLOT_OUTCOMES) { + roll -= outcome.weight; + if (roll <= 0) return outcome; + } + + return SLOT_OUTCOMES[0]; +} + +function generateRandomLine() { + return [ + randomItem(SLOT_SYMBOLS), + randomItem(SLOT_SYMBOLS), + randomItem(SLOT_SYMBOLS) + ]; +} + +function generateOutcomeLine(outcome) { + if (outcome.type === "triple") { + return [ + outcome.symbol, + outcome.symbol, + outcome.symbol + ]; + } + + if (outcome.type === "pair") { + const symbol = randomItem(SLOT_SYMBOLS); + let other; + + do { + other = randomItem(SLOT_SYMBOLS); + } while (other === symbol); + + const result = [symbol, symbol, symbol]; + result[Math.floor(Math.random() * 3)] = other; + return result; + } + + let result; + + do { + result = generateRandomLine(); + } while (new Set(result).size < 3); + + return result; +} + +function buildSpinningEmbed(bet, symbols, status) { + return new EmbedBuilder() + .setTitle("๐ŸŽฐ Crumb Saucer Slots") + .setDescription([ + "## " + symbols.join(" "), + "", + `**${status}**` + ].join("\n")) + .addFields({ + name: "Bet", + value: `**${bet.toLocaleString()} ๐Ÿช**` + }); +} + +function getSlotResultName(outcome) { + if (outcome.type === "loss") return "No luck this time."; + if (outcome.type === "pair") return "Pair!"; + + switch (outcome.symbol) { + case "๐Ÿ‹": return "๐Ÿ‹ Triple Lemon!"; + case "๐Ÿ’": return "๐Ÿ’ Triple Cherry!"; + case "๐Ÿช": return "๐Ÿช Triple Cookie!"; + case "โญ": return "โญ Triple Star!"; + case "๐Ÿ’Ž": return "๐Ÿ’Ž JACKPOT! ๐Ÿ’Ž"; + default: return "Winner!"; + } +} + +function getSlotsSessionOwner(interaction) { + const footer = interaction.message?.embeds?.[0]?.footer?.text; + const parts = footer?.split(":") ?? []; + + if (parts[0] === "crumbSaucer") return parts[1]; + if (parts[0] === "crumbSaucerSlots") return parts[1]; + if (parts[0] === "crumbSaucerSlotsResult") return parts[1]; + + return null; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +module.exports = { + showSlots, + playSlots, + runSlotsBack +}; diff --git a/utils/profileSystem.js b/utils/profileSystem.js new file mode 100644 index 0000000..b6a10ca --- /dev/null +++ b/utils/profileSystem.js @@ -0,0 +1,464 @@ +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 DEFAULT_ACHIEVEMENTS = [ + { achievementId: "stolen_1000", name: "Light Fingers", description: "Steal 1,000 Cookies from other players.", requirement: "Steal 1,000 Cookies total.", category: "stealing", stat: "economy.stolenTotal", target: 1000, emoji: "๐Ÿงค" }, + { achievementId: "stolen_10000", name: "Career Criminal", description: "Steal 10,000 Cookies from other players.", requirement: "Steal 10,000 Cookies total.", category: "stealing", stat: "economy.stolenTotal", target: 10000, emoji: "๐Ÿ•ต๏ธ" }, + { achievementId: "stolen_100000", name: "Grand Theft Cookie", description: "Steal 100,000 Cookies from other players.", requirement: "Steal 100,000 Cookies total.", category: "stealing", stat: "economy.stolenTotal", target: 100000, emoji: "๐Ÿ’ฐ" }, + { achievementId: "stolen_unique_3", name: "Making the Rounds", description: "Successfully steal from 3 different players.", requirement: "Steal from 3 unique players.", category: "stealing", stat: "unique.playersStolenFrom", measure: "arrayLength", target: 3, emoji: "๐ŸŽฏ" }, + { achievementId: "stolen_unique_10", name: "Equal Opportunity Thief", description: "Successfully steal from 10 different players.", requirement: "Steal from 10 unique players.", category: "stealing", stat: "unique.playersStolenFrom", measure: "arrayLength", target: 10, emoji: "๐Ÿ—บ๏ธ" }, + { achievementId: "stolen_from_me_1000", name: "Easy Pickings", description: "Have 1,000 Cookies stolen from you.", requirement: "Lose 1,000 Cookies to other players stealing.", category: "victim", stat: "economy.stolenFromMeTotal", target: 1000, emoji: "๐Ÿ’ธ" }, + { achievementId: "stolen_from_me_10000", name: "Lock the Door", description: "Have 10,000 Cookies stolen from you.", requirement: "Lose 10,000 Cookies to other players stealing.", category: "victim", stat: "economy.stolenFromMeTotal", target: 10000, emoji: "๐Ÿšช" }, + { achievementId: "stolen_by_unique_3", name: "Marked", description: "Be successfully stolen from by 3 different players.", requirement: "Be stolen from by 3 unique players.", category: "victim", stat: "unique.playersStolenBy", measure: "arrayLength", target: 3, emoji: "๐Ÿ‘€" }, + { achievementId: "stolen_by_unique_10", name: "Criminal Magnet", description: "Be successfully stolen from by 10 different players.", requirement: "Be stolen from by 10 unique players.", category: "victim", stat: "unique.playersStolenBy", measure: "arrayLength", target: 10, emoji: "๐Ÿงฒ" }, + { achievementId: "obtained_10000", name: "Growing Stash", description: "Obtain 10,000 Cookies over your lifetime.", requirement: "Obtain 10,000 Cookies from all sources.", category: "economy", stat: "economy.cookiesObtainedTotal", target: 10000, emoji: "๐Ÿช" }, + { achievementId: "obtained_100000", name: "Cookie Magnate", description: "Obtain 100,000 Cookies over your lifetime.", requirement: "Obtain 100,000 Cookies from all sources.", category: "economy", stat: "economy.cookiesObtainedTotal", target: 100000, emoji: "๐Ÿ’ฐ" }, + { achievementId: "obtained_1000000", name: "Crumb Tycoon", description: "Obtain 1,000,000 Cookies over your lifetime.", requirement: "Obtain 1,000,000 Cookies from all sources.", category: "economy", stat: "economy.cookiesObtainedTotal", target: 1000000, emoji: "๐Ÿ‘‘" }, + { achievementId: "vault_stolen_1000", name: "Testing the Locks", description: "Steal 1,000 Cookies from the Bot Vault.", requirement: "Steal 1,000 Cookies from the Bot Vault.", category: "vault", stat: "economy.vaultStolenTotal", target: 1000, emoji: "๐Ÿฆ" }, + { achievementId: "vault_stolen_10000", name: "Vault Regular", description: "Steal 10,000 Cookies from the Bot Vault.", requirement: "Steal 10,000 Cookies from the Bot Vault.", category: "vault", stat: "economy.vaultStolenTotal", target: 10000, emoji: "๐Ÿฅท" }, + { achievementId: "lost_10000", name: "Expensive Hobby", description: "Lose 10,000 Cookies.", requirement: "Lose 10,000 Cookies total.", category: "economy", stat: "economy.cookiesLostTotal", target: 10000, emoji: "๐Ÿ”ฅ" }, + { achievementId: "lost_100000", name: "Financial Catastrophe", description: "Lose 100,000 Cookies.", requirement: "Lose 100,000 Cookies total.", category: "economy", stat: "economy.cookiesLostTotal", target: 100000, emoji: "๐Ÿ’€" }, + { achievementId: "win_streak_3", name: "On a Roll", description: "Win 3 games in a row.", requirement: "Reach a 3-game winning streak.", category: "games", stat: "streaks.bestWin", target: 3, emoji: "๐Ÿ€" }, + { achievementId: "win_streak_10", name: "Can't Stop Won't Stop", description: "Win 10 games in a row.", requirement: "Reach a 10-game winning streak.", category: "games", stat: "streaks.bestWin", target: 10, emoji: "๐Ÿ”ฅ" }, + { achievementId: "loss_streak_3", name: "Rough Night", description: "Lose 3 games in a row.", requirement: "Reach a 3-game losing streak.", category: "games", stat: "streaks.bestLoss", target: 3, emoji: "๐Ÿ˜ฌ" }, + { achievementId: "loss_streak_10", name: "Digging Deeper", description: "Lose 10 games in a row.", requirement: "Reach a 10-game losing streak.", category: "games", stat: "streaks.bestLoss", target: 10, emoji: "๐Ÿ•ณ๏ธ" } +]; + +const DEFAULT_BADGES = [ + { badgeId: "first_100k_cookies", name: "Cookie Pioneer", description: "First player to hold 100,000 Cookies.", emoji: "๐Ÿช", ownerId: null, unlockedAt: null }, + { badgeId: "first_1m_cookies", name: "Cookie Millionaire", description: "First player to hold 1,000,000 Cookies.", emoji: "๐Ÿ‘‘", ownerId: null, unlockedAt: null }, + { badgeId: "first_100k_vault_stolen", name: "Vault Breaker", description: "First player to steal 100,000 Cookies from the Bot Vault.", emoji: "๐Ÿฆ", ownerId: null, unlockedAt: null } +]; + +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 }); + + for (const achievement of DEFAULT_ACHIEVEMENTS) { + await achievementConfig.updateOne( + { achievementId: achievement.achievementId }, + { $setOnInsert: achievement }, + { upsert: true } + ); + } +} + +async function ensureGuildBadges(guildId) { + const { badges } = getProfileCollections(); + for (const badge of DEFAULT_BADGES) { + await badges.updateOne( + { guildId, badgeId: badge.badgeId }, + { $setOnInsert: { guildId, ...badge } }, + { upsert: 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 }, + blackjack: { played: 0, won: 0, lost: 0, pushed: 0 }, + roulette: { played: 0, won: 0, lost: 0, pushed: 0 }, + heist: { played: 0, won: 0, lost: 0, pushed: 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; + + const { stats } = getProfileCollections(); + await ensureStatsDocument(userId, guildId); + + 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); + 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 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; + } + + 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 { achievements, achievementConfig } = getProfileCollections(); + const guildId = interaction.guildId; + await ensureAchievementDocument(userId, guildId); + + const definitions = await achievementConfig.find({}).toArray(); + const unlockedDoc = await achievements.findOne({ userId, guildId }); + const unlocked = unlockedDoc?.unlocked ?? {}; + + for (const definition of definitions) { + if (unlocked[definition.achievementId]) continue; + const currentValue = readStatValue(statsDocument, definition); + if (currentValue < definition.target) continue; + + const field = `unlocked.${definition.achievementId}`; + const result = await achievements.updateOne( + { userId, guildId, [field]: { $exists: false } }, + { $set: { [field]: getUnixTime() } } + ); + + if (result.modifiedCount === 1) { + await sendAchievementNotification(interaction, definition); + } + } +} + +async function evaluateBadges(interaction, userId, statsDocument) { + const guildId = interaction.guildId; + await ensureGuildBadges(guildId); + + 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"); + } +} + +async function tryClaimBadge(interaction, userId, badgeId) { + const { badges } = getProfileCollections(); + const guildId = interaction.guildId; + + const badge = await badges.findOneAndUpdate( + { guildId, badgeId, ownerId: null }, + { $set: { ownerId: userId, unlockedAt: getUnixTime() } }, + { returnDocument: "after" } + ); + + if (!badge || badge.ownerId !== userId) return; + + try { + await interaction.channel.send({ + embeds: [ + new EmbedBuilder() + .setTitle("๐Ÿ… Unique Badge Claimed!") + .setDescription([ + `${badge.emoji} **${badge.name}**`, + "", + `<@${userId}> claimed a guild-unique badge!`, + "", + badge.description, + "", + "*This badge can no longer be earned by another player.*" + ].join("\n")) + ] + }); + } catch {} +} + +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 {} +} + +function readStatValue(document, definition) { + 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 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(), + 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) { + await ensureGuildBadges(guildId); + const { badges } = getProfileCollections(); + const documents = await badges.find({ guildId }).toArray(); + + const owned = documents.filter(badge => badge.ownerId === user.id); + const other = 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(b => `${b.emoji} **${b.name}**\n${b.description}`).join("\n\n") + : "*No unique badges yet.*", + "", + "### Claimed by Others", + other.length + ? other.map(b => `${b.emoji} **${b.name}** โ€” <@${b.ownerId}>`).join("\n") + : "*None yet.*", + "", + "### Still Available", + unclaimed.length + ? unclaimed.map(b => `${b.emoji} **${b.name}**\n${b.description}`).join("\n\n") + : "*All unique badges have been claimed.*" + ].join("\n")) + .setFooter({ text: `profile:${user.id}` }); +} + +async function buildAchievementsEmbed(guildId, user) { + const { achievements, achievementConfig } = getProfileCollections(); + + const [unlockedDoc, definitions] = await Promise.all([ + achievements.findOne({ userId: user.id, guildId }), + achievementConfig.find({}).sort({ category: 1, target: 1 }).toArray() + ]); + + const unlocked = unlockedDoc?.unlocked ?? {}; + const lines = definitions.map(a => + `${unlocked[a.achievementId] ? "โœ…" : "โฌœ"} ${a.emoji} **${a.name}** โ€” ${a.requirement}` + ); + + return new EmbedBuilder() + .setTitle(`๐Ÿ† ${user.globalName ?? user.username}'s Achievements`) + .setDescription(lines.join("\n") || "No achievements configured.") + .setFooter({ text: `profile:${user.id}` }); +} + +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, + ensureGuildBadges, + trackProfileEvent, + getProfileStats, + buildProfileOverview, + buildBadgesEmbed, + buildAchievementsEmbed, + buildProfileButtons, + getProfileTargetId +};