diff --git a/buttons/profile/stats.js b/buttons/profile/stats.js new file mode 100644 index 0000000..11f5485 --- /dev/null +++ b/buttons/profile/stats.js @@ -0,0 +1,22 @@ +const { + buildStatsEmbed, + buildProfileButtons, + getProfileTargetId +} = require("../../utils/profileSystem"); + +module.exports = { + name: "profileStats", + + async execute(interaction) { + const userId = getProfileTargetId(interaction); + if (!userId) return; + + const user = await interaction.client.users.fetch(userId); + const embed = await buildStatsEmbed(interaction.guildId, user); + + return interaction.update({ + embeds: [embed], + components: buildProfileButtons() + }); + } +}; diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index 267db82..089bdb7 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -18,6 +18,10 @@ const { formatPercentage } = require("../../utils/cookieEconomy"); +const { + trackProfileEvent +} = require("../../utils/profileSystem"); + const COLLECT_COOLDOWN = 60 * 60; @@ -214,6 +218,15 @@ async function collect(interaction) { amount ); + await trackProfileEvent( + interaction, + userId, + { + type: "cookie_gain", + amount + } + ); + const nextCollect = await setCooldown( @@ -456,6 +469,44 @@ async function steal(interaction) { } + await trackProfileEvent( + interaction, + user.id, + { + type: "steal_success", + amount, + targetUserId: target.id + } + ); + + await trackProfileEvent( + interaction, + user.id, + { + type: "cookie_gain", + amount + } + ); + + await trackProfileEvent( + interaction, + target.id, + { + type: "stolen_from_me", + amount, + thiefUserId: user.id + } + ); + + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_loss", + amount + } + ); + const [ yourBalanceAfter, targetBalanceAfter @@ -605,6 +656,24 @@ async function steal(interaction) { } + await trackProfileEvent( + interaction, + user.id, + { + type: "cookie_loss", + amount: penalty + } + ); + + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_gain", + amount: penalty + } + ); + const [ yourBalanceAfter, targetBalanceAfter @@ -851,6 +920,24 @@ async function give(interaction) { } + await trackProfileEvent( + interaction, + user.id, + { + type: "cookies_given", + amount + } + ); + + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_gain", + amount + } + ); + const [ yourBalance, theirBalance diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index 3873b6a..b77b7a4 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -14,6 +14,10 @@ const { formatPercentage } = require("./cookieEconomy"); +const { + trackProfileEvent +} = require("./profileSystem"); + /* * ============================================================ @@ -394,6 +398,46 @@ async function resolveCookieHeist( 100; + await trackProfileEvent( + interaction, + playerId, + { + type: + "game_result", + + game: + "heist", + + result: + "win" + } + ); + + + await trackProfileEvent( + interaction, + playerId, + { + type: + "vault_steal", + + amount + } + ); + + + await trackProfileEvent( + interaction, + playerId, + { + type: + "cookie_gain", + + amount + } + ); + + const embed = new EmbedBuilder() .setTitle( @@ -605,6 +649,35 @@ async function resolveCookieHeist( 100; + await trackProfileEvent( + interaction, + playerId, + { + type: + "game_result", + + game: + "heist", + + result: + "loss" + } + ); + + + await trackProfileEvent( + interaction, + playerId, + { + type: + "cookie_loss", + + amount: + penalty + } + ); + + const embed = new EmbedBuilder() .setTitle( diff --git a/utils/crumbBlackjack.js b/utils/crumbBlackjack.js index 97a30e6..d5ea814 100644 --- a/utils/crumbBlackjack.js +++ b/utils/crumbBlackjack.js @@ -17,6 +17,10 @@ const { getUnixTime } = require("./cookieEconomy"); +const { + trackProfileEvent +} = require("./profileSystem"); + /* * ============================================================ @@ -1277,7 +1281,7 @@ async function finishBlackjack( .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.bet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet ?? game.bet}` }) ], @@ -1350,6 +1354,88 @@ async function finishBlackjack( game.bet; + let profileResult; + + if ( + result === + "Push" + ) { + profileResult = + "push"; + } + + else if ( + [ + "Win", + "DealerBust", + "Blackjack" + ].includes( + result + ) + ) { + profileResult = + "win"; + } + + else { + profileResult = + "loss"; + } + + + await trackProfileEvent( + interaction, + game.userId, + { + type: + "game_result", + + game: + "blackjack", + + result: + profileResult + } + ); + + + if ( + profit > + 0 + ) { + await trackProfileEvent( + interaction, + game.userId, + { + type: + "cookie_gain", + + amount: + profit + } + ); + } + + else if ( + profit < + 0 + ) { + await trackProfileEvent( + interaction, + game.userId, + { + type: + "cookie_loss", + + amount: + Math.abs( + profit + ) + } + ); + } + + const description = [ "**Dealer**", formatCards( @@ -1439,7 +1525,7 @@ async function finishBlackjack( .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.bet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet ?? game.bet}` }); diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index 327b37c..a3e2e80 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -11,6 +11,10 @@ const { temporaryEphemeralReply } = require("./cookieEconomy"); +const { + trackProfileEvent +} = require("./profileSystem"); + /* * ============================================================ @@ -852,6 +856,63 @@ async function playRoulette( bet; + await trackProfileEvent( + interaction, + playerId, + { + type: + "game_result", + + game: + "roulette", + + 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( diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js index 226925e..de1d9cc 100644 --- a/utils/crumbSaucer.js +++ b/utils/crumbSaucer.js @@ -7,1467 +7,130 @@ const { const { getCookieBalance, - transferCookies, - temporaryEphemeralReply, - randomItem + temporaryEphemeralReply } = require("./cookieEconomy"); - -/* - * ============================================================ - * SLOT CONFIG - * ============================================================ - */ - -const SLOT_BETS = [ - 10, - 50, - 100, - 500, - 1000 -]; - - -const SLOT_SYMBOLS = [ - "๐Ÿ’", - "๐Ÿ‹", - "๐Ÿช", - "โญ", - "๐Ÿ’Ž" -]; - - -/* - * Weighted outcomes. - */ - -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 - } -]; - - -/* - * ============================================================ - * MAIN MENU - * ============================================================ - */ - -async function showCrumbSaucer( - interaction -) { - /* - * If this is being called from a button, - * verify that the player owns the current - * Crumb Saucer / game session. - * - * This prevents another user from pressing - * "Crumb Saucer" / "Back" on somebody else's - * game message and taking over their session. - */ - - if ( - interaction.isButton() - ) { - const ownerId = - getCrumbSaucerSessionOwner( - interaction - ); - - - if ( - ownerId !== - interaction.user.id - ) { - return temporaryEphemeralReply( - interaction, - { - content: - "This isn't your Crumb Saucer session." - } - ); +async function showCrumbSaucer(interaction) { + if (interaction.isButton()) { + const ownerId = getCrumbSaucerSessionOwner(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 = buildMainMenuEmbed(interaction.user, balance); + const components = buildMainMenuButtons(); - const balance = - await getCookieBalance( - interaction.user.id - ); - - - const embed = - buildMainMenuEmbed( - interaction.user, - balance - ); - - - const components = - buildMainMenuButtons(); - - - /* - * Button: - * update existing menu/game message. - */ - - if ( - interaction.isButton() - ) { + if (interaction.isButton()) { return interaction.update({ - embeds: [ - embed - ], - + embeds: [embed], components }); } - - /* - * Slash command: - * create a new Crumb Saucer. - */ - return interaction.reply({ - embeds: [ - embed - ], - + embeds: [embed], components }); } - -/* - * ============================================================ - * SLOT MENU - * ============================================================ - */ - -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() - }); -} - - -/* - * ============================================================ - * PLAY SLOTS - * ============================================================ - */ - -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.` - } - ); - } - - - /* - * Worst-case payout is x10. - * - * Once the house receives the player's bet, - * it needs another 9x available to cover - * the complete x10 payout. - */ - - if ( - vaultBalance < - bet * 9 - ) { - return temporaryEphemeralReply( - interaction, - { - content: - "The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager." - } - ); - } - - - /* - * Take wager first. - */ - - 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; - } - - - /* - * Lock the buttons while spinning. - */ - - await interaction.update({ - embeds: [ - buildSpinningEmbed( - bet, - generateRandomGrid(), - "๐ŸŽฐ Spinning..." - ) - ], - - components: [] - }); - - - await sleep( - 700 - ); - - - await interaction.editReply({ - embeds: [ - buildSpinningEmbed( - bet, - generateRandomGrid(), - "๐ŸŽฐ The reels are moving..." - ) - ], - - components: [] - }); - - - await sleep( - 700 - ); - - - await interaction.editReply({ - embeds: [ - buildSpinningEmbed( - bet, - generateRandomGrid(), - "๐ŸŽฐ Almost..." - ) - ], - - components: [] - }); - - - await sleep( - 700 - ); - - - /* - * Actual outcome is determined only now. - */ - - const outcome = - rollSlotOutcome(); - - - const grid = - generateOutcomeGrid( - outcome - ); - - - const payout = - Math.floor( - bet * - outcome.multiplier - ); - - - /* - * House pays winnings. - */ - - if ( - payout > - 0 - ) { - try { - await transferCookies( - vaultId, - playerId, - payout - ); - - } catch (error) { - console.error( - "CRITICAL: Crumb Saucer 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; - - - const resultEmbed = - new EmbedBuilder() - .setTitle( - "๐ŸŽฐ Crumb Saucer Slots" - ) - - .setDescription( - [ - buildSlotDisplay( - grid - ), - "", - `### ${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 - } - ) - - /* - * Store owner + previous wager. - * - * Run It Back can now replay the exact - * same wager without opening the bet menu. - */ - - .setFooter({ - text: - `crumbSaucerSlotsResult:${playerId}:${bet}` - }); - - - return interaction.editReply({ - embeds: [ - resultEmbed - ], - - components: - buildResultButtons() - }); -} - - -/* - * ============================================================ - * RUN IT BACK - * ============================================================ - */ - -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 - ); -} - - -/* - * ============================================================ - * MAIN MENU EMBED - * ============================================================ - */ - -function buildMainMenuEmbed( - user, - balance -) { +function buildMainMenuEmbed(user, balance) { return new EmbedBuilder() - .setTitle( - "๐ŸŽฐ The Crumb Saucer" - ) - - .setDescription( - [ - `Welcome, ${user}!`, - "", - "Where Cookies are won, lost,", - "and occasionally confiscated by ShaiBot.", - "", - "### ๐ŸŽฐ Slots", - "Spin the reels and chase the Cookie jackpot.", - "", - "### ๐Ÿฆ Vault Heist", - "Attempt to breach ShaiBot's Cookie Vault.", - "", - "### ๐Ÿƒ Blackjack", - "Beat the dealer without going over 21.", - "", - "### ๐ŸŽก Roulette", - "Bet on Red, Black, Odd, Even, or Zero.", - "", - "*More attractions coming soon... make a lala offering or buy me a white monster*" - ].join("\n") - ) - + .setTitle("๐ŸŽฐ The Crumb Saucer") + .setDescription([ + `Welcome, ${user}!`, + "", + "Where Cookies are won, lost,", + "and occasionally confiscated by ShaiBot.", + "", + "### ๐ŸŽฐ Slots", + "Spin the reels and chase the Cookie jackpot.", + "", + "### ๐Ÿฆ Vault Heist", + "Attempt to breach ShaiBot's Cookie Vault.", + "", + "### ๐Ÿƒ Blackjack", + "Beat the dealer without going over 21.", + "", + "### ๐ŸŽก Roulette", + "Bet on Red, Black, Odd, Even, or Zero.", + "", + "*More attractions coming soon... make a lala offering or buy me a white monster*" + ].join("\n")) .addFields({ - name: - "Your Vault", - - value: - `**${balance.toLocaleString()} ๐Ÿช**` + name: "Your Vault", + value: `**${balance.toLocaleString()} ๐Ÿช**` }) - .setFooter({ - text: - `crumbSaucer:${user.id}` + text: `crumbSaucer:${user.id}` }); } - -/* - * ============================================================ - * MAIN MENU BUTTONS - * ============================================================ - */ - function buildMainMenuButtons() { return [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId( - "crumbSaucerSlots" - ) - .setLabel( - "Slots" - ) - .setEmoji( - "๐ŸŽฐ" - ) - .setStyle( - ButtonStyle.Primary - ), - - new ButtonBuilder() - .setCustomId( - "crumbSaucerBlackjack" - ) - .setLabel( - "Blackjack" - ) - .setEmoji( - "๐Ÿƒ" - ) - .setStyle( - ButtonStyle.Success - ), - - new ButtonBuilder() - .setCustomId( - "crumbSaucerRoulette" - ) - .setLabel( - "Roulette" - ) - .setEmoji( - "๐ŸŽก" - ) - .setStyle( - ButtonStyle.Primary - ), - - new ButtonBuilder() - .setCustomId( - "crumbSaucerHeist" - ) - .setLabel( - "Vault Heist" - ) - .setEmoji( - "๐Ÿฆ" - ) - .setStyle( - ButtonStyle.Danger - ) - ) - ]; -} - - -/* - * ============================================================ - * SLOT BET BUTTONS - * ============================================================ - */ - -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 - ) - ) - ]; -} - - -/* - * ============================================================ - * SLOT RESULT BUTTONS - * ============================================================ - */ - -function buildResultButtons() { - return [ - new ActionRowBuilder() - .addComponents( - /* - * Exact same wager immediately. - */ - - new ButtonBuilder() - .setCustomId( - "crumbSaucerSlotsRunItBack" - ) - .setLabel( - "Run It Back" - ) - .setEmoji( - "๐Ÿ”" - ) - .setStyle( - ButtonStyle.Success - ), - - /* - * Return to wager selection. - */ - - new ButtonBuilder() - .setCustomId( - "crumbSaucerSlots" - ) - .setLabel( - "Change Bet" - ) - .setEmoji( - "๐ŸŽฐ" - ) - .setStyle( - ButtonStyle.Primary - ), - - /* - * Main Saucer. - */ - - new ButtonBuilder() - .setCustomId( - "crumbSaucerBack" - ) - .setLabel( - "Crumb Saucer" - ) - .setEmoji( - "โ—€๏ธ" - ) - .setStyle( - ButtonStyle.Secondary - ) - ) - ]; -} - - -/* - * ============================================================ - * SLOT LOGIC - * ============================================================ - */ - -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 generateRandomGrid() { - return Array.from( - { - length: 3 - }, - - () => - Array.from( - { - length: 3 - }, - - () => - randomItem( - SLOT_SYMBOLS - ) - ) - ); -} - - -function generateOutcomeGrid( - outcome -) { - const grid = - generateRandomGrid(); - - - if ( - outcome.type === - "triple" - ) { - grid[1] = [ - outcome.symbol, - outcome.symbol, - outcome.symbol - ]; - - - return grid; - } - - - if ( - outcome.type === - "pair" - ) { - const symbol = - randomItem( - SLOT_SYMBOLS - ); - - - let other; - - - do { - other = - randomItem( - SLOT_SYMBOLS - ); - - } while ( - other === - symbol - ); - - - const oddPosition = - Math.floor( - Math.random() * - 3 - ); - - - grid[1] = [ - symbol, - symbol, - symbol - ]; - - - grid[1][ - oddPosition - ] = - other; - - - return grid; - } - - - let line; - - - do { - line = [ - randomItem( - SLOT_SYMBOLS - ), - - randomItem( - SLOT_SYMBOLS - ), - - randomItem( - SLOT_SYMBOLS - ) - ]; - - } while ( - new Set( - line - ).size < - 3 - ); - - - grid[1] = - line; - - - return grid; -} - - -function buildSlotDisplay( - grid -) { - return [ - "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—", - `โ•‘ ${grid[0][0]} โ”‚ ${grid[0][1]} โ”‚ ${grid[0][2]} โ•‘`, - `โ•‘ ${grid[1][0]} โ”‚ ${grid[1][1]} โ”‚ ${grid[1][2]} โ•‘`, - `โ•‘ ${grid[2][0]} โ”‚ ${grid[2][1]} โ”‚ ${grid[2][2]} โ•‘`, - "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" - ].join("\n"); -} - - -function buildSpinningEmbed( - bet, - grid, - status -) { - return new EmbedBuilder() - .setTitle( - "๐ŸŽฐ Crumb Saucer Slots" + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("crumbSaucerSlots") + .setLabel("Slots") + .setEmoji("๐ŸŽฐ") + .setStyle(ButtonStyle.Primary), + + new ButtonBuilder() + .setCustomId("crumbSaucerBlackjack") + .setLabel("Blackjack") + .setEmoji("๐Ÿƒ") + .setStyle(ButtonStyle.Success), + + new ButtonBuilder() + .setCustomId("crumbSaucerRoulette") + .setLabel("Roulette") + .setEmoji("๐ŸŽก") + .setStyle(ButtonStyle.Primary), + + new ButtonBuilder() + .setCustomId("crumbSaucerHeist") + .setLabel("Vault Heist") + .setEmoji("๐Ÿฆ") + .setStyle(ButtonStyle.Danger) + ), + + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("abort") + .setLabel("Close") + .setEmoji("โœ–๏ธ") + .setStyle(ButtonStyle.Secondary) ) - - .setDescription( - [ - buildSlotDisplay( - grid - ), - "", - `**${status}**` - ].join("\n") - ) - - .addFields({ - name: - "Bet", - - value: - `**${bet.toLocaleString()} ๐Ÿช**` - }); + ]; } +function getCrumbSaucerSessionOwner(interaction) { + const footer = interaction.message?.embeds?.[0]?.footer?.text; + const parts = footer?.split(":") ?? []; -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!"; - } -} - - -/* - * ============================================================ - * SESSION OWNER RESOLUTION - * ============================================================ - * - * This intentionally understands every Crumb Saucer - * screen that can navigate back to the main menu. - * - * That prevents another player from pressing the - * Back / Crumb Saucer button on somebody else's - * public interaction. - */ - -function getCrumbSaucerSessionOwner( - interaction -) { - const footer = - interaction.message - ?.embeds?.[0] - ?.footer - ?.text; - - - const parts = - footer?.split(":") - ?? - []; - - - switch ( - parts[0] - ) { - /* - * Main menu - */ + switch (parts[0]) { case "crumbSaucer": - - /* - * Slots wager menu - */ case "crumbSaucerSlots": - - /* - * Slots completed result - */ case "crumbSaucerSlotsResult": - - /* - * Blackjack wager/result menu - */ case "crumbSaucerBlackjack": - - case "crumbSaucerBlackjackResult": - - /* - * Roulette - */ + case "crumbBlackjackResult": case "crumbRouletteMenu": - case "crumbRouletteBet": - case "crumbRouletteResult": - - /* - * Heist - */ - case "crumbSaucerHeist": - case "cookieHeist": - return parts[1]; - - /* - * Active Blackjack footer: - * - * crumbBlackjack:: - */ - case "crumbBlackjack": - return parts[2]; - default: - return null; } } - -/* - * ============================================================ - * SLOT OWNER RESOLUTION - * ============================================================ - */ - -function getSlotsSessionOwner( - interaction -) { - const footer = - interaction.message - ?.embeds?.[0] - ?.footer - ?.text; - - - const parts = - footer?.split(":") - ?? - []; - - - /* - * Main Crumb Saucer. - */ - - if ( - parts[0] === - "crumbSaucer" - ) { - return parts[1]; - } - - - /* - * Slots wager menu. - */ - - if ( - parts[0] === - "crumbSaucerSlots" - ) { - return parts[1]; - } - - - /* - * Completed Slots result. - */ - - if ( - parts[0] === - "crumbSaucerSlotsResult" - ) { - return parts[1]; - } - - - return null; -} - - -/* - * ============================================================ - * SLEEP - * ============================================================ - */ - -function sleep( - ms -) { - return new Promise( - resolve => - setTimeout( - resolve, - ms - ) - ); -} - - -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { - showCrumbSaucer, - - showSlots, - playSlots, - runSlotsBack -}; \ No newline at end of file + showCrumbSaucer +}; diff --git a/utils/profileSystem.js b/utils/profileSystem.js index b6a10ca..eff62b1 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -21,6 +21,8 @@ const COLLECTIONS = { achievementConfig: "config_achievements" }; +let profileSystemInitPromise = null; + 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: "๐Ÿ•ต๏ธ" }, @@ -41,7 +43,15 @@ const DEFAULT_ACHIEVEMENTS = [ { 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: "๐Ÿ•ณ๏ธ" } + { 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: "๐Ÿ•ณ๏ธ" }, + { achievementId: "given_10000", name: "Sharing Is Caring", description: "Give 10,000 Cookies to other players.", requirement: "Give 10,000 Cookies to other players total.", category: "economy", stat: "economy.cookiesGivenTotal", target: 10000, emoji: "๐ŸŽ" }, + { achievementId: "given_50000", name: "Community Baker", description: "Give 50,000 Cookies to other players.", requirement: "Give 50,000 Cookies to other players total.", category: "economy", stat: "economy.cookiesGivenTotal", target: 50000, emoji: "๐Ÿค" }, + { achievementId: "games_won_10", name: "House Regular", description: "Win 10 Crumb Saucer games.", requirement: "Win 10 games total.", category: "games", stat: "games.total.won", target: 10, emoji: "๐ŸŽฒ" }, + { achievementId: "games_won_50", name: "House Favorite", description: "Win 50 Crumb Saucer games.", requirement: "Win 50 games total.", category: "games", stat: "games.total.won", target: 50, emoji: "๐Ÿ†" }, + { achievementId: "slots_won_10", name: "Reel Deal", description: "Win 10 Slots spins.", requirement: "Win 10 Slots games.", category: "games", stat: "games.slots.won", target: 10, emoji: "๐ŸŽฐ" }, + { achievementId: "blackjack_won_10", name: "Card Counter-ish", description: "Win 10 Blackjack hands.", requirement: "Win 10 Blackjack games.", category: "games", stat: "games.blackjack.won", target: 10, emoji: "๐Ÿƒ" }, + { achievementId: "roulette_won_10", name: "Wheel Watcher", description: "Win 10 Roulette bets.", requirement: "Win 10 Roulette games.", category: "games", stat: "games.roulette.won", target: 10, emoji: "๐ŸŽก" }, + { achievementId: "heist_won_10", name: "Professional", description: "Complete 10 successful Vault Heists.", requirement: "Win 10 Vault Heists.", category: "games", stat: "games.heist.won", target: 10, emoji: "๐Ÿฆ" } ]; const DEFAULT_BADGES = [ @@ -63,22 +73,34 @@ function getProfileCollections() { } 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 }); + if (profileSystemInitPromise) return profileSystemInitPromise; - for (const achievement of DEFAULT_ACHIEVEMENTS) { - await achievementConfig.updateOne( - { achievementId: achievement.achievementId }, - { $setOnInsert: achievement }, - { upsert: true } - ); + profileSystemInitPromise = (async () => { + 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 } + ); + } + })(); + + try { + return await profileSystemInitPromise; + } catch (error) { + profileSystemInitPromise = null; + throw error; } } async function ensureGuildBadges(guildId) { + await ensureProfileSystem(); const { badges } = getProfileCollections(); for (const badge of DEFAULT_BADGES) { await badges.updateOne( @@ -145,6 +167,7 @@ async function getProfileStats(userId, guildId) { } async function trackProfileEvent(interaction, userId, event) { + await ensureProfileSystem(); const guildId = interaction.guildId; if (!guildId || !userId || !event?.type) return; @@ -190,7 +213,7 @@ async function trackProfileEvent(interaction, userId, event) { } const updatedStats = await getProfileStats(userId, guildId); - await evaluateAchievements(interaction, userId, updatedStats); + await evaluateAchievements(interaction, userId, updatedStats, event.type); await evaluateBadges(interaction, userId, updatedStats); return updatedStats; } @@ -244,12 +267,15 @@ async function trackGameResult(userId, guildId, event) { ); } -async function evaluateAchievements(interaction, userId, statsDocument) { +async function evaluateAchievements(interaction, userId, statsDocument, eventType = null) { const { achievements, achievementConfig } = getProfileCollections(); const guildId = interaction.guildId; await ensureAchievementDocument(userId, guildId); - const definitions = await achievementConfig.find({}).toArray(); + const relevantStats = getRelevantAchievementStats(eventType); + const definitions = await achievementConfig.find( + relevantStats ? { stat: { $in: relevantStats } } : {} + ).toArray(); const unlockedDoc = await achievements.findOne({ userId, guildId }); const unlocked = unlockedDoc?.unlocked ?? {}; @@ -265,7 +291,7 @@ async function evaluateAchievements(interaction, userId, statsDocument) { ); if (result.modifiedCount === 1) { - await sendAchievementNotification(interaction, definition); + await sendAchievementNotification(interaction, userId, definition); } } } @@ -314,23 +340,38 @@ async function tryClaimBadge(interaction, userId, badgeId) { } catch {} } -async function sendAchievementNotification(interaction, achievement) { +async function sendAchievementNotification(interaction, userId, achievement) { try { - await interaction.followUp({ + await interaction.channel.send({ embeds: [ new EmbedBuilder() .setTitle("๐Ÿ† Achievement Unlocked!") .setDescription([ + `<@${userId}>`, + "", `${achievement.emoji} **${achievement.name}**`, "", achievement.description ].join("\n")) - ], - flags: MessageFlags.Ephemeral + ] }); } catch {} } + +function getRelevantAchievementStats(eventType) { + switch (eventType) { + case "cookie_gain": return ["economy.cookiesObtainedTotal"]; + case "cookie_loss": return ["economy.cookiesLostTotal"]; + case "cookies_given": return ["economy.cookiesGivenTotal"]; + case "steal_success": return ["economy.stolenTotal", "unique.playersStolenFrom"]; + case "stolen_from_me": return ["economy.stolenFromMeTotal", "unique.playersStolenBy"]; + case "vault_steal": return ["economy.vaultStolenTotal"]; + case "game_result": return ["streaks.bestWin", "streaks.bestLoss", "games.total.won", "games.slots.won", "games.blackjack.won", "games.roulette.won", "games.heist.won"]; + default: return null; + } +} + function readStatValue(document, definition) { const value = definition.stat .split(".") @@ -344,6 +385,7 @@ function readStatValue(document, definition) { } async function buildProfileOverview(guildId, user) { + await ensureProfileSystem(); const stats = await getProfileStats(user.id, guildId); const balance = await getCookieBalance(user.id); const { achievements, badges, achievementConfig } = getProfileCollections(); @@ -386,6 +428,7 @@ async function buildProfileOverview(guildId, user) { } async function buildBadgesEmbed(guildId, user) { + await ensureProfileSystem(); await ensureGuildBadges(guildId); const { badges } = getProfileCollections(); const documents = await badges.find({ guildId }).toArray(); @@ -416,6 +459,7 @@ async function buildBadgesEmbed(guildId, user) { } async function buildAchievementsEmbed(guildId, user) { + await ensureProfileSystem(); const { achievements, achievementConfig } = getProfileCollections(); const [unlockedDoc, definitions] = await Promise.all([ @@ -424,9 +468,16 @@ async function buildAchievementsEmbed(guildId, user) { ]); const unlocked = unlockedDoc?.unlocked ?? {}; - const lines = definitions.map(a => - `${unlocked[a.achievementId] ? "โœ…" : "โฌœ"} ${a.emoji} **${a.name}** โ€” ${a.requirement}` - ); + const stats = await getProfileStats(user.id, guildId); + const lines = definitions.map(a => { + const complete = Boolean(unlocked[a.achievementId]); + const current = readStatValue(stats, a); + const progress = complete + ? "Unlocked" + : `${Math.min(current, a.target).toLocaleString()} / ${a.target.toLocaleString()}`; + + return `${complete ? "โœ…" : "โฌœ"} ${a.emoji} **${a.name}** โ€” ${a.requirement}\nโ†ณ ${progress}`; + }); return new EmbedBuilder() .setTitle(`๐Ÿ† ${user.globalName ?? user.username}'s Achievements`) @@ -434,10 +485,67 @@ async function buildAchievementsEmbed(guildId, user) { .setFooter({ text: `profile:${user.id}` }); } +async function buildStatsEmbed(guildId, user) { + await ensureProfileSystem(); + const stats = await getProfileStats(user.id, guildId); + const e = stats.economy; + const g = stats.games; + + return new EmbedBuilder() + .setTitle(`๐Ÿ“Š ${user.globalName ?? user.username}'s Statistics`) + .setThumbnail(user.displayAvatarURL({ size: 256 })) + .addFields( + { + name: "๐Ÿช Economy", + value: [ + `Obtained: **${e.cookiesObtainedTotal.toLocaleString()}**`, + `Lost: **${e.cookiesLostTotal.toLocaleString()}**`, + `Given: **${e.cookiesGivenTotal.toLocaleString()}**`, + `Stolen: **${e.stolenTotal.toLocaleString()}**`, + `Stolen from you: **${e.stolenFromMeTotal.toLocaleString()}**`, + `Bot Vault stolen: **${e.vaultStolenTotal.toLocaleString()}**` + ].join("\n") + }, + { + name: "๐Ÿฅท Theft", + value: [ + `Unique victims: **${stats.unique.playersStolenFrom.length}**`, + `Unique thieves: **${stats.unique.playersStolenBy.length}**` + ].join("\n"), + inline: true + }, + { + name: "๐Ÿ”ฅ Streaks", + value: [ + `Current wins: **${stats.streaks.currentWin}**`, + `Best wins: **${stats.streaks.bestWin}**`, + `Current losses: **${stats.streaks.currentLoss}**`, + `Best losses: **${stats.streaks.bestLoss}**` + ].join("\n"), + inline: true + }, + { name: "๐ŸŽฐ Slots", value: formatGameStats(g.slots), inline: true }, + { name: "๐Ÿƒ Blackjack", value: formatGameStats(g.blackjack), inline: true }, + { name: "๐ŸŽก Roulette", value: formatGameStats(g.roulette), inline: true }, + { name: "๐Ÿฆ Heist", value: formatGameStats(g.heist), inline: true } + ) + .setFooter({ text: `profile:${user.id}` }); +} + +function formatGameStats(game) { + return [ + `Played: **${game?.played ?? 0}**`, + `Won: **${game?.won ?? 0}**`, + `Lost: **${game?.lost ?? 0}**`, + `Pushes: **${game?.pushed ?? 0}**` + ].join("\n"); +} + function buildProfileButtons() { return [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId("profileOverview").setLabel("Overview").setEmoji("๐Ÿ‘ค").setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId("profileStats").setLabel("Stats").setEmoji("๐Ÿ“Š").setStyle(ButtonStyle.Secondary), 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) @@ -457,6 +565,7 @@ module.exports = { trackProfileEvent, getProfileStats, buildProfileOverview, + buildStatsEmbed, buildBadgesEmbed, buildAchievementsEmbed, buildProfileButtons,