diff --git a/buttons/_global/abort.js b/buttons/_global/abort.js deleted file mode 100644 index e0a6e06..0000000 --- a/buttons/_global/abort.js +++ /dev/null @@ -1,60 +0,0 @@ -const { - EmbedBuilder -} = require("discord.js"); - - -function delay(ms) { - return new Promise( - resolve => - setTimeout( - resolve, - ms - ) - ); -} - - -module.exports = { - name: - "abort", - - description: - "abort it", - - - async execute( - interaction - ) { - const embed = - new EmbedBuilder() - .setTitle( - "Close" - ) - .setDescription( - "Closing..." - ); - - - await interaction.update({ - embeds: [ - embed - ], - - components: [] - }); - - - await delay( - 2000 - ); - - - try { - await interaction.deleteReply(); - } - - catch { - // Ignore if already deleted/expired. - } - } -} \ No newline at end of file diff --git a/buttons/crumbSaucer/close.js b/buttons/crumbSaucer/close.js new file mode 100644 index 0000000..d33a71c --- /dev/null +++ b/buttons/crumbSaucer/close.js @@ -0,0 +1,91 @@ +const { + EmbedBuilder +} = require( + "discord.js" +); + +const { + temporaryEphemeralReply +} = require( + "../../utils/cookieEconomy" +); + +const { + getCrumbSaucerSessionOwner +} = require( + "../../utils/crumbSaucer" +); + + +function delay( + ms +) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + +module.exports = { + name: + "crumbSaucerClose", + + + async execute( + interaction + ) { + const ownerId = + getCrumbSaucerSessionOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This isn't your Crumb Saucer session." + } + ); + } + + + const embed = + new EmbedBuilder() + .setTitle( + "Closed" + ) + .setDescription( + "Closing Crumb Saucer..." + ); + + + await interaction.update({ + embeds: [ + embed + ], + + components: [] + }); + + + await delay( + 1500 + ); + + + try { + await interaction.deleteReply(); + } + + catch {} + } +}; \ No newline at end of file diff --git a/buttons/crumbSaucer/collectCookies.js b/buttons/crumbSaucer/collectCookies.js index a196b0b..ae32835 100644 --- a/buttons/crumbSaucer/collectCookies.js +++ b/buttons/crumbSaucer/collectCookies.js @@ -1,7 +1,7 @@ const { - collectCookies + collectCookiesFromSaucer } = require( - "../../commands/applications/cookies" + "../../utils/crumbCollect" ); @@ -13,7 +13,7 @@ module.exports = { async execute( interaction ) { - return collectCookies( + return collectCookiesFromSaucer( interaction ); } diff --git a/buttons/crumbSaucer/collectMenu.js b/buttons/crumbSaucer/collectMenu.js new file mode 100644 index 0000000..e34b7b2 --- /dev/null +++ b/buttons/crumbSaucer/collectMenu.js @@ -0,0 +1,20 @@ +const { + showCollectPage +} = require( + "../../utils/crumbCollect" +); + + +module.exports = { + name: + "crumbSaucerCollectMenu", + + + async execute( + interaction + ) { + return showCollectPage( + interaction + ); + } +}; \ No newline at end of file diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index 33a20bb..0ecda53 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -9,151 +9,233 @@ const { getCookieBalance, changeCookies, transferCookies, - getCooldown, - setCooldown, - getUnixTime, + claimCooldown, randomInteger, randomFloat, randomItem, formatPercentage -} = require("../../utils/cookieEconomy"); +} = require( + "../../utils/cookieEconomy" +); const { trackProfileEvent -} = require("../../utils/profileSystem"); +} = require( + "../../utils/profileSystem" +); +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + const COLLECT_COOLDOWN = - 60 * 60; + 60 * 60; // 1 hour + const STEAL_COOLDOWN = - 15 * 60; + 15 * 60; // 15 minutes const ABRA_ID = "378135175059603457"; + const ABRA_MULTIPLIER = 1.5; +/* + * ============================================================ + * COMMAND + * ============================================================ + */ + module.exports = { - data: new SlashCommandBuilder() - .setName("cookies") - .setDescription( - "The ShaiBot Cookie economy." - ) + data: + new SlashCommandBuilder() + .setName( + "cookies" + ) - .addSubcommand(subcommand => - subcommand - .setName("collect") - .setDescription( - "Collect a fresh batch of Cookies." - ) - ) + .setDescription( + "The ShaiBot cookie economy." + ) - .addSubcommand(subcommand => - subcommand - .setName("steal") - .setDescription( - "Attempt to steal another user's Cookies." - ) - - .addUserOption(option => - option - .setName("target") - .setDescription( - "The unfortunate victim." + .addSubcommand( + subcommand => + subcommand + .setName( + "collect" ) - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("check") - .setDescription( - "Check a Cookie balance." - ) - - .addUserOption(option => - option - .setName("target") .setDescription( - "The user whose Cookies you want to check." + "Collect a fresh batch of cookies." ) - ) - ) + ) - .addSubcommand(subcommand => - subcommand - .setName("give") - .setDescription( - "Give some of your Cookies to another user." - ) + .addSubcommand( + subcommand => + subcommand + .setName( + "steal" + ) - .addUserOption(option => - option - .setName("target") .setDescription( - "The recipient." + "Attempt to steal another user's cookies." + ) + + .addUserOption( + option => + option + .setName( + "target" + ) + + .setDescription( + "The unfortunate victim." + ) + + .setRequired( + true + ) + ) + ) + + .addSubcommand( + subcommand => + subcommand + .setName( + "check" ) - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName("amount") .setDescription( - "How many Cookies to give." + "Check a cookie balance." ) - .setRequired(true) - .setMinValue(1) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("leaderboard") - .setDescription( - "Show the richest Cookie collectors." - ) - ), + .addUserOption( + option => + option + .setName( + "target" + ) + + .setDescription( + "The user whose cookies you want to check." + ) + ) + ) + + .addSubcommand( + subcommand => + subcommand + .setName( + "give" + ) + + .setDescription( + "Give some of your cookies to another user." + ) + + .addUserOption( + option => + option + .setName( + "target" + ) + + .setDescription( + "The recipient." + ) + + .setRequired( + true + ) + ) + + .addIntegerOption( + option => + option + .setName( + "amount" + ) + + .setDescription( + "How many cookies to give." + ) + + .setRequired( + true + ) + + .setMinValue( + 1 + ) + ) + ) + + .addSubcommand( + subcommand => + subcommand + .setName( + "leaderboard" + ) + + .setDescription( + "Show the richest cookie collectors." + ) + ), - async execute(interaction) { + async execute( + interaction + ) { switch ( interaction.options.getSubcommand() ) { case "collect": + return collect( interaction ); + case "steal": + return steal( interaction ); + case "check": + return check( interaction ); + case "give": + return give( interaction ); + case "leaderboard": + return leaderboard( interaction ); + default: + return interaction.reply({ content: - "Unknown Cookies command.", + "Unknown subcommand.", + flags: MessageFlags.Ephemeral }); @@ -162,45 +244,63 @@ module.exports = { }; -async function collect(interaction) { +/* + * ============================================================ + * /cookies collect + * ============================================================ + */ + +async function collect( + interaction +) { const userId = interaction.user.id; - const guildId = - interaction.guildId; - - const now = - getUnixTime(); + /* + * Shared Mongo-backed cooldown. + * + * This is the same key the Crumb Saucer + * Cookie Bakery should use. + */ const cooldown = - await getCooldown( + await claimCooldown( userId, - guildId, - "collect" + interaction.guildId, + "collect", + COLLECT_COOLDOWN ); if ( - cooldown > - now + !cooldown.claimed ) { return interaction.reply({ content: - `The batch is still cooking. Come back šŸŖ`, + `The batch is still cooking. Come back šŸŖ`, + flags: MessageFlags.Ephemeral }); } + /* + * Generate batch. + */ + let amount = randomInteger( 1, - 1000 + 20 ); + /* + * Legacy Abra bonus. + */ + if ( userId === ABRA_ID @@ -213,40 +313,33 @@ async function collect(interaction) { } - await changeCookies( - userId, - amount - ); + const balance = + await changeCookies( + userId, + amount + ); + + + /* + * Lifetime Cookie gain + achievements. + */ await trackProfileEvent( interaction, userId, { - type: "cookie_gain", + type: + "cookie_gain", + amount } ); - const nextCollect = - await setCooldown( - userId, - guildId, - "collect", - COLLECT_COOLDOWN - ); - - - const balance = - await getCookieBalance( - userId - ); - - const embed = new EmbedBuilder() .setTitle( - "šŸŖ Fresh Batch" + "šŸŖ Fresh Cookies!" ) .setDescription( @@ -262,7 +355,7 @@ async function collect(interaction) { "Collected", value: - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`, + `**+${amount.toLocaleString()} šŸŖ**`, inline: true @@ -284,7 +377,7 @@ async function collect(interaction) { "Next Batch", value: - ``, + ``, inline: false @@ -293,9 +386,12 @@ async function collect(interaction) { .setThumbnail( interaction.user.displayAvatarURL({ - size: 256 + size: + 256 }) - ); + ) + + .setTimestamp(); return interaction.reply({ @@ -306,7 +402,15 @@ async function collect(interaction) { } -async function steal(interaction) { +/* + * ============================================================ + * /cookies steal + * ============================================================ + */ + +async function steal( + interaction +) { const user = interaction.user; @@ -318,13 +422,20 @@ async function steal(interaction) { ); + /* + * ======================================================== + * VALIDATION + * ======================================================== + */ + if ( target.id === user.id ) { return interaction.reply({ content: - "You can't steal from yourself.", + "C'mon, don't embarrass yourself. You can't steal from yourself.", + flags: MessageFlags.Ephemeral }); @@ -332,52 +443,23 @@ async function steal(interaction) { if ( - target.id === - interaction.client.user.id + target.bot ) { return interaction.reply({ content: - "ShaiBot's vault requires a proper Vault Heist. Visit `/crumbsaucer`.", + "Bots have highly sophisticated Cookie security systems.", + flags: MessageFlags.Ephemeral }); } - if (target.bot) { - return interaction.reply({ - content: - "Other bots are protected by Cookie Law.", - flags: - MessageFlags.Ephemeral - }); - } - - - const now = - getUnixTime(); - - - const cooldown = - await getCooldown( - user.id, - interaction.guildId, - "steal" - ); - - - if ( - cooldown > - now - ) { - return interaction.reply({ - content: - `Lay low for a while. You can steal again .`, - flags: - MessageFlags.Ephemeral - }); - } - + /* + * ======================================================== + * BALANCES + * ======================================================== + */ const [ yourCookies, @@ -401,6 +483,7 @@ async function steal(interaction) { return interaction.reply({ content: "You need at least **1 Cookie** to attempt a robbery.", + flags: MessageFlags.Ephemeral }); @@ -413,13 +496,52 @@ async function steal(interaction) { ) { return interaction.reply({ content: - `${target} doesn't have any Cookies.`, + `Shame on you! ${target} doesn't have any Cookies. D:`, + flags: MessageFlags.Ephemeral }); } + /* + * ======================================================== + * COOLDOWN + * ======================================================== + * + * Claim before resolving the robbery. + * + * This prevents double clicks / concurrent steals. + */ + + const cooldown = + await claimCooldown( + user.id, + interaction.guildId, + "steal", + STEAL_COOLDOWN + ); + + + if ( + !cooldown.claimed + ) { + return interaction.reply({ + content: + `Lay low for a while. You can steal again .`, + + flags: + MessageFlags.Ephemeral + }); + } + + + /* + * ======================================================== + * DYNAMIC SUCCESS CHANCE + * ======================================================== + */ + const successChance = getStealChance( yourCookies, @@ -432,8 +554,11 @@ async function steal(interaction) { successChance; - let embed; - + /* + * ======================================================== + * SUCCESS + * ======================================================== + */ if (success) { const { @@ -459,6 +584,7 @@ async function steal(interaction) { return interaction.reply({ content: `${target}'s Cookie balance changed before you could finish the robbery.`, + flags: MessageFlags.Ephemeral }); @@ -469,44 +595,68 @@ async function steal(interaction) { } - await trackProfileEvent( - interaction, - user.id, - { - type: "steal_success", - amount, - targetUserId: target.id - } - ); + /* + * Thief stats. + */ await trackProfileEvent( interaction, user.id, { - type: "cookie_gain", - amount - } - ); + type: + "steal_success", - await trackProfileEvent( - interaction, - target.id, - { - type: "stolen_from_me", amount, - thiefUserId: user.id + + targetUserId: + target.id } ); + + await trackProfileEvent( + interaction, + user.id, + { + type: + "cookie_gain", + + amount + } + ); + + + /* + * Victim stats. + */ + await trackProfileEvent( interaction, target.id, { - type: "cookie_loss", + type: + "stolen_from_me", + + amount, + + thiefUserId: + user.id + } + ); + + + await trackProfileEvent( + interaction, + target.id, + { + type: + "cookie_loss", + amount } ); + const [ yourBalanceAfter, targetBalanceAfter @@ -526,14 +676,15 @@ async function steal(interaction) { ( amount / theirCookies - ) * + ) + * 100; - embed = + const embed = new EmbedBuilder() .setTitle( - "🄷 Cookie Robbery Successful" + "🄷 Robbery Successful!" ) .setDescription( @@ -590,210 +741,244 @@ async function steal(interaction) { { name: - `${target.displayName}'s Vault`, + `${target.username}'s Vault`, value: `**${targetBalanceAfter.toLocaleString()} šŸŖ**`, inline: true + }, + + { + name: + "Next Robbery", + + value: + ``, + + inline: + false } ) .setThumbnail( target.displayAvatarURL({ - size: 256 + size: + 256 }) - ); + ) + + .setTimestamp(); + + + return interaction.reply({ + embeds: [ + embed + ] + }); } - else { - const penaltyPercentage = - randomFloat( - 0.01, - 0.08 - ); + /* + * ======================================================== + * FAILURE + * ======================================================== + * + * Penalty remains percentage based. + */ + + const penaltyPercentage = + randomFloat( + 0.01, + 0.08 + ); - const penalty = - Math.max( - 1, + const penalty = + Math.max( + 1, - Math.min( - yourCookies, + Math.min( + yourCookies, - Math.floor( - yourCookies * - penaltyPercentage - ) + Math.floor( + yourCookies * + penaltyPercentage ) - ); + ) + ); - try { - await transferCookies( - user.id, - target.id, - penalty - ); + try { + await transferCookies( + user.id, + target.id, + penalty + ); - } catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { - return interaction.reply({ - content: - "Your Cookie balance changed before the robbery could finish.", - flags: - MessageFlags.Ephemeral - }); - } + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.reply({ + content: + "Your Cookie balance changed before the robbery could finish.", - - throw error; + flags: + MessageFlags.Ephemeral + }); } - await trackProfileEvent( - interaction, - user.id, - { - type: "cookie_loss", - amount: penalty - } - ); - - await trackProfileEvent( - interaction, - target.id, - { - type: "cookie_gain", - amount: penalty - } - ); - - const [ - yourBalanceAfter, - targetBalanceAfter - ] = - await Promise.all([ - getCookieBalance( - user.id - ), - - getCookieBalance( - target.id - ) - ]); - - - const actualPenaltyPercentage = - ( - penalty / - yourCookies - ) * - 100; - - - embed = - new EmbedBuilder() - .setTitle( - "🚨 Cookie Robbery Failed" - ) - - .setDescription( - randomStealFailResponse( - penalty, - target - ) - ) - - .addFields( - { - name: - "Penalty", - - value: - `**${penalty.toLocaleString()} šŸŖ**`, - - inline: - true - }, - - { - name: - "Vault Lost", - - value: - `**${actualPenaltyPercentage.toFixed(1)}%**`, - - inline: - true - }, - - { - name: - "Success Chance", - - value: - `**${formatPercentage(successChance)}**`, - - inline: - true - }, - - { - name: - "Your Vault", - - value: - `**${yourBalanceAfter.toLocaleString()} šŸŖ**`, - - inline: - true - }, - - { - name: - `${target.displayName}'s Vault`, - - value: - `**${targetBalanceAfter.toLocaleString()} šŸŖ**`, - - inline: - true - } - ) - - .setThumbnail( - target.displayAvatarURL({ - size: 256 - }) - ); + throw error; } - const nextSteal = - await setCooldown( - user.id, - interaction.guildId, - "steal", - STEAL_COOLDOWN - ); + /* + * Profile tracking. + */ + + await trackProfileEvent( + interaction, + user.id, + { + type: + "cookie_loss", + + amount: + penalty + } + ); - embed.addFields({ - name: - "Next Robbery", + await trackProfileEvent( + interaction, + target.id, + { + type: + "cookie_gain", - value: - ``, + amount: + penalty + } + ); - inline: - false - }); + + const [ + yourBalanceAfter, + targetBalanceAfter + ] = + await Promise.all([ + getCookieBalance( + user.id + ), + + getCookieBalance( + target.id + ) + ]); + + + const actualPenaltyPercentage = + ( + penalty / + yourCookies + ) + * + 100; + + + const embed = + new EmbedBuilder() + .setTitle( + "🚨 Robbery Failed!" + ) + + .setDescription( + randomStealFailResponse( + penalty, + target + ) + ) + + .addFields( + { + name: + "Fine", + + value: + `**${penalty.toLocaleString()} šŸŖ**`, + + inline: + true + }, + + { + name: + "Vault Lost", + + value: + `**${actualPenaltyPercentage.toFixed(1)}%**`, + + inline: + true + }, + + { + name: + "Success Chance", + + value: + `**${formatPercentage(successChance)}**`, + + inline: + true + }, + + { + name: + "Your Vault", + + value: + `**${yourBalanceAfter.toLocaleString()} šŸŖ**`, + + inline: + true + }, + + { + name: + `${target.username}'s Vault`, + + value: + `**${targetBalanceAfter.toLocaleString()} šŸŖ**`, + + inline: + true + }, + + { + name: + "Next Robbery", + + value: + ``, + + inline: + false + } + ) + + .setThumbnail( + target.displayAvatarURL({ + size: + 256 + }) + ) + + .setTimestamp(); return interaction.reply({ @@ -804,7 +989,15 @@ async function steal(interaction) { } -async function check(interaction) { +/* + * ============================================================ + * /cookies check + * ============================================================ + */ + +async function check( + interaction +) { const target = interaction.options.getUser( "target" @@ -819,30 +1012,32 @@ async function check(interaction) { ); - const isHouse = - target.id === - interaction.client.user.id; - - const embed = new EmbedBuilder() .setTitle( - isHouse - ? "šŸ¦ ShaiBot Cookie Vault" - : "šŸŖ Cookie Vault" + "šŸŖ Cookie Vault" ) .setDescription( - isHouse - ? `The house currently holds **${balance.toLocaleString()} Cookies**.` - : `${target} currently has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` + `${target} has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` ) + .addFields({ + name: + "Vault Balance", + + value: + `**${balance.toLocaleString()} šŸŖ**` + }) + .setThumbnail( target.displayAvatarURL({ - size: 256 + size: + 256 }) - ); + ) + + .setTimestamp(); return interaction.reply({ @@ -853,7 +1048,15 @@ async function check(interaction) { } -async function give(interaction) { +/* + * ============================================================ + * /cookies give + * ============================================================ + */ + +async function give( + interaction +) { const user = interaction.user; @@ -879,16 +1082,20 @@ async function give(interaction) { return interaction.reply({ content: "You cannot give Cookies to yourself.", + flags: MessageFlags.Ephemeral }); } - if (target.bot) { + if ( + target.bot + ) { return interaction.reply({ content: - "Bots don't accept direct Cookie transfers. Visit `/crumbsaucer` if you'd like to donate to the house involuntarily.", + "Bots don't need Cookies. Allegedly.", + flags: MessageFlags.Ephemeral }); @@ -909,7 +1116,8 @@ async function give(interaction) { ) { return interaction.reply({ content: - "You don't have enough Cookies.", + "We don't run a charity here. You don't have enough Cookies!", + flags: MessageFlags.Ephemeral }); @@ -920,27 +1128,41 @@ async function give(interaction) { } + /* + * Sender lifetime giving. + */ + await trackProfileEvent( interaction, user.id, { - type: "cookies_given", + type: + "cookies_given", + amount } ); + + /* + * Recipient lifetime gain. + */ + await trackProfileEvent( interaction, target.id, { - type: "cookie_gain", + type: + "cookie_gain", + amount } ); + const [ - yourBalance, - theirBalance + senderBalance, + recipientBalance ] = await Promise.all([ getCookieBalance( @@ -956,20 +1178,20 @@ async function give(interaction) { const embed = new EmbedBuilder() .setTitle( - "šŸŖ Cookies Shared" + "šŸŖ Cookies Shared!" ) .setDescription( - `${user} gave ${target} **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**.` + `${user} shared **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}** with ${target}.` ) .addFields( { name: - "Your Vault", + "Transferred", value: - `**${yourBalance.toLocaleString()} šŸŖ**`, + `**${amount.toLocaleString()} šŸŖ**`, inline: true @@ -977,10 +1199,21 @@ async function give(interaction) { { name: - `${target.displayName}'s Vault`, + "Your Vault", value: - `**${theirBalance.toLocaleString()} šŸŖ**`, + `**${senderBalance.toLocaleString()} šŸŖ**`, + + inline: + true + }, + + { + name: + `${target.username}'s Vault`, + + value: + `**${recipientBalance.toLocaleString()} šŸŖ**`, inline: true @@ -989,9 +1222,12 @@ async function give(interaction) { .setThumbnail( target.displayAvatarURL({ - size: 256 + size: + 256 }) - ); + ) + + .setTimestamp(); return interaction.reply({ @@ -1002,24 +1238,37 @@ async function give(interaction) { } -async function leaderboard(interaction) { +/* + * ============================================================ + * /cookies leaderboard + * ============================================================ + */ + +async function leaderboard( + interaction +) { const { cookies } = getCollections(); + /* + * Keep ShaiBot's central vault off + * the player leaderboard. + */ + const results = await cookies .find({ - userId: { - $regex: - /^\d+$/ - }, - cookies: { $gt: 0 + }, + + userId: { + $ne: + interaction.client.user.id } }) @@ -1028,49 +1277,41 @@ async function leaderboard(interaction) { -1 }) - .limit(25) + .limit( + 10 + ) + .toArray(); - const playerResults = - results - .filter( - record => - record.userId !== - interaction.client.user.id - ) - - .slice( - 0, - 10 - ); - - const description = - playerResults.length - ? playerResults + results.length + ? results .map( ( record, index - ) => - ( - `${getLeaderboardMedal(index)} ` + - `<@${record.userId}> — ` + + ) => { + const medal = + getLeaderboardMedal( + index + ); + + + return ( + `${medal} <@${record.userId}> — ` + `**${record.cookies.toLocaleString()}** šŸŖ` - ) + ); + } + ) + + .join( + "\n" ) - .join("\n") : "Nobody has collected any Cookies yet."; - const houseBalance = - await getCookieBalance( - interaction.client.user.id - ); - - const embed = new EmbedBuilder() .setTitle( @@ -1081,23 +1322,17 @@ async function leaderboard(interaction) { description ) - .addFields({ - name: - "šŸ¦ ShaiBot House Vault", - - value: - `**${houseBalance.toLocaleString()} šŸŖ**` - }) - .setTimestamp(); if ( - interaction.guild?.iconURL() + interaction.guild + ?.iconURL() ) { embed.setThumbnail( interaction.guild.iconURL({ - size: 256 + size: + 256 }) ); } @@ -1111,6 +1346,117 @@ async function leaderboard(interaction) { } +/* + * ============================================================ + * COLLECT RESPONSES + * ============================================================ + */ + +function randomCollectResponse( + amount, + user +) { + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `${user}, I made you ${cookies} šŸŖ`, + `${user}, here's your ${cookies} šŸŖ I ate the rest, sorry >.<`, + `Only ${cookies} šŸŖ for you today ${user}... Abra stole the rest :c`, + `${user}, would you like a glass of milk with your ${cookies}? šŸ„›`, + `${cookies} šŸŖ Careful, ${user}! They're still warm :3`, + `${cookies} šŸŖ Best batch yet!`, + `${user}, these ${cookies} I bequeath unto thine own self upon this day.`, + `${user}, your ${cookies} are fresh out of the oven. Try not to burn your tongue!`, + `${user}, I bribed the Cookie Monster to spare these ${cookies} just for you.`, + `${user}, Santa came early! You got ${cookies}. Milk not included.`, + `${user}, your ${cookies} are artisanal, gluten-free, and slightly cursed.`, + `A wild baker appeared! They hand ${user} ${cookies} šŸŖ`, + `${user}, Abra said these ${cookies} are "totally not stolen"... šŸ¤”`, + `${user}, here's your ${cookies} — watch out, they may be booby-trapped.` + ]; + + + return randomItem( + responses + ); +} + + +/* + * ============================================================ + * STEAL SUCCESS RESPONSES + * ============================================================ + */ + +function randomStealSuccessResponse( + amount, + target +) { + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `You've successfully stolen ${cookies} from ${target}!`, + `Ninja mode activated. You snatched ${cookies} from ${target} without leaving a crumb behind.`, + `Abra taught you well — ${cookies} from ${target} are now yours!`, + `Like a phantom in the night, you took ${cookies} from ${target}.`, + `You just pulled the sweetest heist: ${cookies} from ${target}!`, + `Mission complete. ${target} is ${cookies} poorer.`, + `You distracted ${target} with a dance and swiped ${cookies}.`, + `Clean getaway! ${target} won't notice the missing ${cookies} until it's too late.`, + `The Cookie Vault has been breached. ${cookies} taken from ${target}.`, + `You emerged from the shadows carrying ${cookies} from ${target}.` + ]; + + + return randomItem( + responses + ); +} + + +/* + * ============================================================ + * STEAL FAILURE RESPONSES + * ============================================================ + */ + +function randomStealFailResponse( + amount, + target +) { + const cookies = + `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; + + + const responses = [ + `You were caught red-handed by ${target} and lost ${cookies} as a penalty!`, + `Fail! ${target} caught you mid-bite — you dropped ${cookies}.`, + `Not your best moment... You lost ${cookies} to ${target} in the struggle.`, + `Stealth level: 0. You handed ${cookies} to ${target} as an apology.`, + `The Cookie jar was booby-trapped! You gave ${cookies} to ${target} while running away.`, + `Ouch. The guard dog barked, and now ${target} has your ${cookies}.`, + `You tripped over your own shoelace — ${target} picked up ${cookies}.`, + `Caught by the Cookie Police! ${cookies} confiscated and given to ${target}.`, + `Your pockets had holes... ${target} found the ${cookies} you dropped.` + ]; + + + return randomItem( + responses + ); +} + + +/* + * ============================================================ + * STEAL CHANCE + * ============================================================ + */ + function getStealChance( attackerCookies, targetCookies @@ -1118,13 +1464,19 @@ function getStealChance( const MIN_CHANCE = 0.15; + const MAX_CHANCE = 0.70; + const BASE_CHANCE = 0.50; + /* + * Wealthier targets become harder to rob. + */ + const targetPenalty = Math.log10( targetCookies + @@ -1134,6 +1486,10 @@ function getStealChance( 0.06; + /* + * Attacker / victim wealth ratio. + */ + const wealthRatio = attackerCookies / Math.max( @@ -1142,6 +1498,12 @@ function getStealChance( ); + /* + * Catch-up mechanic. + * + * Poorer attackers receive a small bonus. + */ + const povertyBonus = wealthRatio < 1 @@ -1155,6 +1517,11 @@ function getStealChance( : 0; + /* + * Rich players bullying poorer targets + * receive a success penalty. + */ + const bullyPenalty = wealthRatio > 1 @@ -1193,6 +1560,12 @@ function getStealChance( } +/* + * ============================================================ + * STEAL AMOUNT + * ============================================================ + */ + function getStealAmount( targetCookies ) { @@ -1230,11 +1603,22 @@ function getStealAmount( } +/* + * ============================================================ + * STEAL PERCENTAGE + * ============================================================ + */ + function getStealPercentage() { const roll = Math.random(); + /* + * 50%: + * 1–3% + */ + if ( roll < 0.50 @@ -1246,6 +1630,11 @@ function getStealPercentage() { } + /* + * 30%: + * 3–5% + */ + if ( roll < 0.80 @@ -1257,6 +1646,11 @@ function getStealPercentage() { } + /* + * 15%: + * 5–8% + */ + if ( roll < 0.95 @@ -1268,6 +1662,11 @@ function getStealPercentage() { } + /* + * 5% jackpot: + * 8–10% + */ + return randomFloat( 0.08, 0.10 @@ -1275,80 +1674,48 @@ function getStealPercentage() { } -function randomCollectResponse( - amount, - user -) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - return randomItem([ - `${user}, I made you ${cookies} šŸŖ`, - `${user}, here's your ${cookies} šŸŖ I ate the rest, sorry >.<`, - `Only ${cookies} šŸŖ for you today ${user}... Abra stole the rest :c`, - `${user}, would you like a glass of milk with your ${cookies}? šŸ„›`, - `${cookies} šŸŖ Careful, ${user}! They're still warm :3`, - `${cookies} šŸŖ Best batch yet!`, - `${user}, these ${cookies} I bequeath unto thine own self upon this day.`, - `${user}, your ${cookies} are fresh out of the oven.`, - `${user}, I bribed the Cookie Monster to spare these ${cookies}.`, - `A wild baker appeared! They hand ${user} ${cookies} šŸŖ`, - `${user}, Abra said these ${cookies} are "totally not stolen"... šŸ¤”` - ]); -} - - -function randomStealSuccessResponse( - amount, - target -) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - return randomItem([ - `You've successfully stolen ${cookies} from ${target}!`, - `Ninja mode activated. You snatched ${cookies} from ${target}.`, - `Abra taught you well — ${cookies} from ${target} are now yours!`, - `Like a phantom in the night, you took ${cookies} from ${target}.`, - `Clean getaway! ${target} won't notice the missing ${cookies} until it's too late.` - ]); -} - - -function randomStealFailResponse( - amount, - target -) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - return randomItem([ - `You were caught red-handed by ${target} and lost ${cookies}!`, - `${target} caught you mid-bite — you dropped ${cookies}.`, - `You lost ${cookies} to ${target} in the struggle.`, - `Stealth level: 0. You handed ${cookies} to ${target} as an apology.`, - `Caught by the Cookie Police! ${cookies} were given to ${target}.` - ]); -} - +/* + * ============================================================ + * LEADERBOARD MEDALS + * ============================================================ + */ function getLeaderboardMedal( index ) { - switch (index) { + switch ( + index + ) { case 0: + return "šŸ„‡"; + case 1: + return "🄈"; + case 2: + return "šŸ„‰"; + default: + return `**${index + 1}.**`; } -} \ No newline at end of file +} + + +/* + * ============================================================ + * EXPORTS FOR CRUMB SAUCER + * ============================================================ + * + * This lets the Saucer reuse the same collect implementation + * later if we decide to route its bakery directly through here. + */ + +module.exports.collect = + collect; \ No newline at end of file diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index f68735f..eab62a6 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -19,83 +19,86 @@ const { } = require("./profileSystem"); -/* - * ============================================================ - * CONFIG - * ============================================================ - */ - const HEIST_COOLDOWN = - 60 * 60; // 1 hour + 60 * 60; -/* - * All three approaches use the SAME cooldown: - * - * cooldowns_cookies.heist - * - * Sneak does NOT have its own cooldown. - * Hack does NOT have its own cooldown. - * Smash does NOT have its own cooldown. - */ - const HEIST_METHODS = { Sneak: { - baseChance: 0.65, + baseChance: + 0.65, - minStealPercent: 0.01, - maxStealPercent: 0.03, - maxStealAmount: 1500, + minStealPercent: + 0.01, - minPenaltyPercent: 0.01, - maxPenaltyPercent: 0.04, - maxPenaltyAmount: 750 + maxStealPercent: + 0.03, + + maxStealAmount: + 1500, + + minPenaltyPercent: + 0.01, + + maxPenaltyPercent: + 0.04, + + maxPenaltyAmount: + 750 }, Hack: { - baseChance: 0.45, + baseChance: + 0.45, - minStealPercent: 0.03, - maxStealPercent: 0.06, - maxStealAmount: 3500, + minStealPercent: + 0.03, - minPenaltyPercent: 0.03, - maxPenaltyPercent: 0.07, - maxPenaltyAmount: 2000 + maxStealPercent: + 0.06, + + maxStealAmount: + 3500, + + minPenaltyPercent: + 0.03, + + maxPenaltyPercent: + 0.07, + + maxPenaltyAmount: + 2000 }, Smash: { - baseChance: 0.25, + baseChance: + 0.25, - minStealPercent: 0.06, - maxStealPercent: 0.12, - maxStealAmount: 7500, + minStealPercent: + 0.06, - minPenaltyPercent: 0.06, - maxPenaltyPercent: 0.12, - maxPenaltyAmount: 5000 + maxStealPercent: + 0.12, + + maxStealAmount: + 7500, + + minPenaltyPercent: + 0.06, + + maxPenaltyPercent: + 0.12, + + maxPenaltyAmount: + 5000 } }; -/* - * ============================================================ - * RESOLVE HEIST - * ============================================================ - */ - async function resolveCookieHeist( interaction, methodName ) { - /* - * Validate ownership of this Heist menu. - * - * Footer: - * - * cookieHeist: - */ - const footer = interaction.message ?.embeds?.[0] @@ -114,10 +117,10 @@ async function resolveCookieHeist( if ( type !== - "cookieHeist" + "cookieHeist" || ownerId !== - interaction.user.id + interaction.user.id ) { return temporaryEphemeralReply( interaction, @@ -129,13 +132,9 @@ async function resolveCookieHeist( } - /* - * Validate method. - */ - const method = HEIST_METHODS[ - methodName + methodName ]; @@ -150,12 +149,6 @@ async function resolveCookieHeist( } - /* - * ======================================================== - * SHARED HEIST COOLDOWN - * ======================================================== - */ - const cooldown = await claimCooldown( interaction.user.id, @@ -179,10 +172,6 @@ async function resolveCookieHeist( } - /* - * Lock this Heist interaction immediately. - */ - await interaction.update({ embeds: [ new EmbedBuilder() @@ -212,11 +201,6 @@ async function resolveCookieHeist( interaction.client.user.id; - /* - * Re-fetch balances because they may have - * changed since the menu was opened. - */ - const [ playerCookies, vaultCookies @@ -234,10 +218,10 @@ async function resolveCookieHeist( if ( playerCookies < - 1 + 1 || vaultCookies < - 1 + 1 ) { return interaction.editReply({ embeds: [ @@ -267,21 +251,11 @@ async function resolveCookieHeist( } - /* - * Short theatrical delay. - */ - await sleep( 1200 ); - /* - * ======================================================== - * DYNAMIC VAULT SECURITY - * ======================================================== - */ - const securityPenalty = getVaultSecurityPenalty( vaultCookies @@ -302,12 +276,6 @@ async function resolveCookieHeist( successChance; - /* - * ======================================================== - * SUCCESS - * ======================================================== - */ - if (success) { const rolledPercent = randomFloat( @@ -377,30 +345,6 @@ async function resolveCookieHeist( } - const [ - playerAfter, - vaultAfter - ] = - await Promise.all([ - getCookieBalance( - playerId - ), - - getCookieBalance( - vaultId - ) - ]); - - - const actualPercentage = - ( - amount / - vaultCookies - ) - * - 100; - - await trackProfileEvent( interaction, playerId, @@ -412,7 +356,10 @@ async function resolveCookieHeist( "heist", result: - "win" + "win", + + profit: + amount } ); @@ -441,6 +388,30 @@ async function resolveCookieHeist( ); + const [ + playerAfter, + vaultAfter + ] = + await Promise.all([ + getCookieBalance( + playerId + ), + + getCookieBalance( + vaultId + ) + ]); + + + const actualPercentage = + ( + amount / + vaultCookies + ) + * + 100; + + const embed = new EmbedBuilder() .setTitle( @@ -551,12 +522,6 @@ async function resolveCookieHeist( } - /* - * ======================================================== - * FAILURE - * ======================================================== - */ - const penaltyPercent = randomFloat( method.minPenaltyPercent, @@ -582,11 +547,6 @@ async function resolveCookieHeist( try { - /* - * Heist fines always go into - * ShaiBot's central vault. - */ - await transferCookies( playerId, vaultId, @@ -630,6 +590,38 @@ async function resolveCookieHeist( } + await trackProfileEvent( + interaction, + playerId, + { + type: + "game_result", + + game: + "heist", + + result: + "loss", + + profit: + -penalty + } + ); + + + await trackProfileEvent( + interaction, + playerId, + { + type: + "cookie_loss", + + amount: + penalty + } + ); + + const [ playerAfter, vaultAfter @@ -654,35 +646,6 @@ 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( @@ -793,12 +756,6 @@ async function resolveCookieHeist( } -/* - * ============================================================ - * RETURN TO CRUMB SAUCER - * ============================================================ - */ - function buildReturnButton() { return [ new ActionRowBuilder() @@ -821,25 +778,9 @@ function buildReturnButton() { } -/* - * ============================================================ - * SECURITY - * ============================================================ - */ - function getVaultSecurityPenalty( vaultCookies ) { - /* - * Approximately: - * - * 100 -> 5% - * 1,000 -> 7.5% - * 10,000 -> 10% - * 100,000 -> 12.5% - * 1,000,000 -> 15% - */ - return Math.min( Math.log10( vaultCookies + @@ -853,19 +794,14 @@ function getVaultSecurityPenalty( } -/* - * ============================================================ - * PROGRESS TEXT - * ============================================================ - */ - function getHeistProgressText( methodName ) { switch ( - methodName + methodName ) { case "Sneak": + return [ "🄷 You slip through the service entrance...", "", @@ -874,6 +810,7 @@ function getHeistProgressText( case "Hack": + return [ "šŸ’» You connect to the vault terminal...", "", @@ -882,6 +819,7 @@ function getHeistProgressText( case "Smash": + return [ "šŸ’„ You charge directly at the vault...", "", @@ -890,17 +828,12 @@ function getHeistProgressText( default: + return "The heist is underway..."; } } -/* - * ============================================================ - * SUCCESS TEXT - * ============================================================ - */ - function getSuccessText( methodName, user, @@ -911,27 +844,31 @@ function getSuccessText( switch ( - methodName + methodName ) { case "Sneak": + return ( `🄷 ${user} slipped through security and quietly escaped with ${cookies}.` ); case "Hack": + return ( `šŸ’» ${user} convinced the vault terminal that ${cookies} belonged to them.` ); case "Smash": + return ( `šŸ’„ ${user} smashed through the Cookie Vault and escaped with ${cookies}!` ); default: + return ( `${user} escaped with ${cookies}.` ); @@ -939,12 +876,6 @@ function getSuccessText( } -/* - * ============================================================ - * FAILURE TEXT - * ============================================================ - */ - function getFailureText( methodName, user, @@ -955,9 +886,10 @@ function getFailureText( switch ( - methodName + methodName ) { case "Sneak": + return [ `🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`, "", @@ -966,6 +898,7 @@ function getFailureText( case "Hack": + return [ `🚨 ${user} entered the password incorrectly one too many times.`, "", @@ -974,6 +907,7 @@ function getFailureText( case "Smash": + return [ `🚨 ${user} discovered that the vault door is significantly stronger than expected.`, "", @@ -982,6 +916,7 @@ function getFailureText( default: + return ( `The heist failed and cost ${user} ${cookies}.` ); @@ -989,12 +924,6 @@ function getFailureText( } -/* - * ============================================================ - * UTIL - * ============================================================ - */ - function sleep( ms ) { @@ -1008,12 +937,6 @@ function sleep( } -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { resolveCookieHeist, HEIST_COOLDOWN diff --git a/utils/crumbBlackjack.js b/utils/crumbBlackjack.js index d5ea814..dd3a6d2 100644 --- a/utils/crumbBlackjack.js +++ b/utils/crumbBlackjack.js @@ -22,12 +22,6 @@ const { } = require("./profileSystem"); -/* - * ============================================================ - * CONFIG - * ============================================================ - */ - const GAME_COLLECTION = "items_games"; @@ -41,12 +35,6 @@ const BLACKJACK_BETS = [ ]; -/* - * ============================================================ - * SHOW BLACKJACK MENU - * ============================================================ - */ - async function showBlackjack( interaction ) { @@ -70,10 +58,6 @@ async function showBlackjack( } - /* - * Resume an unfinished game. - */ - const active = await getActiveBlackjack( interaction.user.id, @@ -146,26 +130,12 @@ async function showBlackjack( } -/* - * ============================================================ - * START GAME - * ============================================================ - */ - async function startBlackjack( interaction, bet, allowCustomBet = false ) { - /* - * Normal wager buttons must use one of our - * predefined values. - * - * All In and Run It Back are allowed to use - * custom wager amounts. - */ - if ( !allowCustomBet && @@ -183,11 +153,6 @@ async function startBlackjack( } - /* - * Every wager, predefined or custom, must - * still be a positive safe integer. - */ - if ( !Number.isSafeInteger( bet @@ -238,10 +203,6 @@ async function startBlackjack( ); - /* - * Prevent multiple active hands. - */ - const existing = await getActiveBlackjack( interaction.user.id, @@ -297,18 +258,6 @@ async function startBlackjack( } - /* - * Natural Blackjack returns: - * - * wager + 3:2 winnings - * - * = 2.5x total payout. - * - * The wager will move into the house before - * resolution, so the house currently needs - * enough to cover the additional 1.5x. - */ - if ( houseBalance < Math.ceil( @@ -326,10 +275,6 @@ async function startBlackjack( } - /* - * Take wager. - */ - try { await transferCookies( playerId, @@ -356,12 +301,6 @@ async function startBlackjack( } - /* - * ======================================================== - * REAL 52-CARD DECK - * ======================================================== - */ - const deck = createDeck(); @@ -426,12 +365,6 @@ async function startBlackjack( ); - /* - * ======================================================== - * NATURAL BLACKJACK CHECK - * ======================================================== - */ - const playerBlackjack = isBlackjack( playerCards @@ -448,10 +381,6 @@ async function startBlackjack( playerBlackjack || dealerBlackjack ) { - /* - * Both natural Blackjack = Push. - */ - if ( playerBlackjack && dealerBlackjack @@ -496,12 +425,6 @@ async function startBlackjack( } -/* - * ============================================================ - * ALL IN - * ============================================================ - */ - async function startBlackjackAllIn( interaction ) { @@ -525,10 +448,6 @@ async function startBlackjackAllIn( } - /* - * Resolve balance at click time. - */ - const balance = await getCookieBalance( interaction.user.id @@ -549,11 +468,6 @@ async function startBlackjackAllIn( } - /* - * true allows the balance to be used even though - * it likely isn't one of BLACKJACK_BETS. - */ - return startBlackjack( interaction, balance, @@ -562,16 +476,6 @@ async function startBlackjackAllIn( } -/* - * ============================================================ - * RUN IT BACK - * ============================================================ - * - * Reuses only the previous BET. - * - * A completely new shuffled deck and hand are created. - */ - async function runBlackjackBack( interaction ) { @@ -588,12 +492,6 @@ async function runBlackjackBack( []; - /* - * Expected: - * - * crumbBlackjackResult:: - */ - if ( parts[0] !== "crumbBlackjackResult" @@ -650,13 +548,6 @@ async function runBlackjackBack( } - /* - * Reuse the exact previous wager. - * - * true is required because a previous All In bet - * may not be part of BLACKJACK_BETS. - */ - return startBlackjack( interaction, bet, @@ -665,12 +556,6 @@ async function runBlackjackBack( } -/* - * ============================================================ - * HIT - * ============================================================ - */ - async function hitBlackjack( interaction ) { @@ -748,27 +633,25 @@ async function hitBlackjack( getCollections(); - const games = - db.collection( + await db + .collection( GAME_COLLECTION - ); + ) + .updateOne( + { + gameId: + game.gameId, - - await games.updateOne( - { - gameId: - game.gameId, - - state: - "Playing" - }, - { - $set: { - deck, - playerCards + state: + "Playing" + }, + { + $set: { + deck, + playerCards + } } - } - ); + ); const updated = { @@ -778,10 +661,6 @@ async function hitBlackjack( }; - /* - * Bust. - */ - if ( score > 21 @@ -794,10 +673,6 @@ async function hitBlackjack( } - /* - * Automatically stand on exactly 21. - */ - if ( score === 21 @@ -824,12 +699,6 @@ async function hitBlackjack( } -/* - * ============================================================ - * STAND - * ============================================================ - */ - async function standBlackjack( interaction, suppliedGame = @@ -884,10 +753,6 @@ async function standBlackjack( ]; - /* - * Dealer stands on 17. - */ - while ( getHandValue( dealerCards @@ -950,10 +815,6 @@ async function standBlackjack( } - /* - * Same value = Push. - */ - else { result = "Push"; @@ -968,12 +829,6 @@ async function standBlackjack( } -/* - * ============================================================ - * DOUBLE - * ============================================================ - */ - async function doubleBlackjack( interaction ) { @@ -1008,10 +863,6 @@ async function doubleBlackjack( } - /* - * Double only on initial two cards. - */ - if ( game.playerCards.length !== 2 @@ -1086,10 +937,6 @@ async function doubleBlackjack( ]; - /* - * Double receives exactly one additional card. - */ - playerCards.push( deck.pop() ); @@ -1158,10 +1005,6 @@ async function doubleBlackjack( } - /* - * Double automatically stands. - */ - return standBlackjack( interaction, updated @@ -1169,12 +1012,6 @@ async function doubleBlackjack( } -/* - * ============================================================ - * FINISH GAME - * ============================================================ - */ - async function finishBlackjack( interaction, game, @@ -1188,11 +1025,9 @@ async function finishBlackjack( 0; - switch (result) { - /* - * Normal win. - */ - + switch ( + result + ) { case "Win": case "DealerBust": @@ -1203,12 +1038,6 @@ async function finishBlackjack( break; - /* - * Push: - * - * Return exactly the wager. - */ - case "Push": payout = @@ -1217,12 +1046,6 @@ async function finishBlackjack( break; - /* - * Natural Blackjack: - * - * stake + 3:2 winnings. - */ - case "Blackjack": payout = @@ -1234,10 +1057,6 @@ async function finishBlackjack( break; - /* - * Bust / Loss / Dealer Blackjack. - */ - default: payout = @@ -1245,10 +1064,6 @@ async function finishBlackjack( } - /* - * Pay player. - */ - if ( payout > 0 @@ -1281,7 +1096,7 @@ async function finishBlackjack( .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.originalBet ?? game.bet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet}` }) ], @@ -1299,10 +1114,6 @@ async function finishBlackjack( } - /* - * Mark game finished. - */ - const { db } = @@ -1356,6 +1167,7 @@ async function finishBlackjack( let profileResult; + if ( result === "Push" @@ -1364,6 +1176,7 @@ async function finishBlackjack( "push"; } + else if ( [ "Win", @@ -1377,6 +1190,7 @@ async function finishBlackjack( "win"; } + else { profileResult = "loss"; @@ -1394,7 +1208,9 @@ async function finishBlackjack( "blackjack", result: - profileResult + profileResult, + + profit } ); @@ -1416,6 +1232,7 @@ async function finishBlackjack( ); } + else if ( profit < 0 @@ -1520,12 +1337,15 @@ async function finishBlackjack( ) /* - * Owner + previous exact wager. + * Important: + * + * Run It Back uses the original wager, + * not the doubled total. */ .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.originalBet ?? game.bet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet}` }); @@ -1540,12 +1360,6 @@ async function finishBlackjack( } -/* - * ============================================================ - * GAME EMBED - * ============================================================ - */ - function buildGameEmbed( game ) { @@ -1608,12 +1422,6 @@ function buildGameEmbed( } -/* - * ============================================================ - * BET BUTTONS - * ============================================================ - */ - function buildBetButtons() { return [ new ActionRowBuilder() @@ -1624,18 +1432,14 @@ function buildBetButtons() { .setCustomId( `crumbBlackjackBet${bet}` ) - .setLabel( bet.toLocaleString() ) - .setEmoji( "šŸŖ" ) - .setStyle( - bet >= - 500 + bet >= 500 ? ButtonStyle.Primary : ButtonStyle.Secondary ) @@ -1676,12 +1480,6 @@ function buildBetButtons() { } -/* - * ============================================================ - * ACTIVE GAME BUTTONS - * ============================================================ - */ - function buildGameButtons( game ) { @@ -1744,12 +1542,6 @@ function buildGameButtons( } -/* - * ============================================================ - * RESULT BUTTONS - * ============================================================ - */ - function buildResultButtons() { return [ new ActionRowBuilder() @@ -1800,12 +1592,6 @@ function buildResultButtons() { } -/* - * ============================================================ - * CARDS - * ============================================================ - */ - function createDeck() { const suits = [ "ā™ ļø", @@ -1988,12 +1774,6 @@ function formatCards( } -/* - * ============================================================ - * DATABASE LOOKUPS - * ============================================================ - */ - async function getActiveBlackjack( userId, guildId @@ -2072,12 +1852,6 @@ async function getGameFromInteraction( } -/* - * ============================================================ - * SESSION OWNER - * ============================================================ - */ - function getBlackjackSessionOwner( interaction ) { @@ -2110,12 +1884,6 @@ function getBlackjackSessionOwner( } - /* - * Active hand: - * - * crumbBlackjack:: - */ - if ( parts[0] === "crumbBlackjack" @@ -2124,12 +1892,6 @@ function getBlackjackSessionOwner( } - /* - * Finished result: - * - * crumbBlackjackResult:: - */ - if ( parts[0] === "crumbBlackjackResult" @@ -2142,12 +1904,6 @@ function getBlackjackSessionOwner( } -/* - * ============================================================ - * RESULT TITLES - * ============================================================ - */ - function getResultTitle( result ) { @@ -2191,12 +1947,6 @@ function getResultTitle( } -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { showBlackjack, diff --git a/utils/crumbCollect.js b/utils/crumbCollect.js new file mode 100644 index 0000000..41d8e0f --- /dev/null +++ b/utils/crumbCollect.js @@ -0,0 +1,400 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getCookieBalance, + changeCookies, + claimCooldown, + temporaryEphemeralReply +} = require("./cookieEconomy"); + +const { + trackProfileEvent +} = require("./profileSystem"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const COLLECT_COOLDOWN = + 60 * 60; + + +/* + * IMPORTANT: + * + * Always use exactly the same cooldown key. + */ + +const COLLECT_COOLDOWN_KEY = + "collect"; + + +/* + * ============================================================ + * SHOW COLLECT PAGE + * ============================================================ + */ + +async function showCollectPage( + interaction +) { + const ownerId = + getCollectOwner( + 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 Cookie Bakery" + ) + + .setDescription( + [ + "Fresh Cookies are prepared regularly.", + "", + "Press **Collect Cookies** to claim your batch.", + "", + "Your collection cooldown is shared with `/cookies collect`." + ].join("\n") + ) + + .addFields({ + name: + "Your Vault", + + value: + `**${balance.toLocaleString()} šŸŖ**` + }) + + .setFooter({ + text: + `crumbSaucerCollect:${interaction.user.id}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildCollectButtons() + }); +} + + +/* + * ============================================================ + * COLLECT + * ============================================================ + */ + +async function collectCookiesFromSaucer( + interaction +) { + const ownerId = + getCollectOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This isn't your Crumb Saucer session." + } + ); + } + + + /* + * Mongo-backed cooldown. + * + * Same pattern as Cookie Heist: + * + * userId + * guildId + * cooldown key + * duration + */ + + const cooldown = + await claimCooldown( + interaction.user.id, + interaction.guildId, + COLLECT_COOLDOWN_KEY, + COLLECT_COOLDOWN + ); + + + /* + * Already collected. + */ + + if ( + !cooldown.claimed + ) { + const balance = + await getCookieBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "šŸŖ Cookie Bakery" + ) + + .setDescription( + [ + "Your next batch isn't ready yet.", + "", + `Come back .` + ].join("\n") + ) + + .addFields({ + name: + "Your Vault", + + value: + `**${balance.toLocaleString()} šŸŖ**` + }) + + .setFooter({ + text: + `crumbSaucerCollect:${interaction.user.id}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildCollectButtons() + }); + } + + + /* + * ======================================================== + * GENERATE BATCH + * ======================================================== + */ + + const amount = + Math.floor( + Math.random() * + 1000 + ) + + + 1; + + + await changeCookies( + interaction.user.id, + amount + ); + + + /* + * Profile / achievements. + */ + + await trackProfileEvent( + interaction, + interaction.user.id, + { + type: + "cookie_gain", + + amount + } + ); + + + const balance = + await getCookieBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "šŸŖ Fresh Batch!" + ) + + .setDescription( + [ + `You collected **${amount.toLocaleString()} Cookies**! šŸŖ`, + "", + `Your next batch will be ready .` + ].join("\n") + ) + + .addFields({ + name: + "Your Vault", + + value: + `**${balance.toLocaleString()} šŸŖ**` + }) + + .setFooter({ + text: + `crumbSaucerCollect:${interaction.user.id}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildCollectButtons() + }); +} + + +/* + * ============================================================ + * BUTTONS + * ============================================================ + */ + +function buildCollectButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbSaucerCollect" + ) + .setLabel( + "Collect Cookies" + ) + .setEmoji( + "šŸŖ" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "crumbSaucerBack" + ) + .setLabel( + "Crumb Saucer" + ) + .setEmoji( + "ā—€ļø" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * OWNER + * ============================================================ + */ + +function getCollectOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + /* + * Main menu -> Cookie Bakery. + */ + + if ( + parts[0] === + "crumbSaucer" + ) { + return parts[1]; + } + + + /* + * Bakery page/result. + */ + + if ( + parts[0] === + "crumbSaucerCollect" + ) { + return parts[1]; + } + + + return null; +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + showCollectPage, + collectCookiesFromSaucer +}; \ No newline at end of file diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index a3e2e80..3f93bd3 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -16,12 +16,6 @@ const { } = require("./profileSystem"); -/* - * ============================================================ - * CONFIG - * ============================================================ - */ - const ROULETTE_BETS = [ 10, 50, @@ -64,10 +58,6 @@ const ROULETTE_CHOICES = { }; -/* - * European Roulette red numbers. - */ - const RED_NUMBERS = new Set([ 1, @@ -91,12 +81,6 @@ const RED_NUMBERS = ]); -/* - * ============================================================ - * SHOW ROULETTE - * ============================================================ - */ - async function showRoulette( interaction ) { @@ -171,12 +155,6 @@ async function showRoulette( } -/* - * ============================================================ - * CHOOSE NORMAL WAGER - * ============================================================ - */ - async function chooseRouletteBet( interaction, bet @@ -223,12 +201,6 @@ async function chooseRouletteBet( } -/* - * ============================================================ - * ALL IN - * ============================================================ - */ - async function startRouletteAllIn( interaction ) { @@ -252,10 +224,6 @@ async function startRouletteAllIn( } - /* - * Resolve the balance at click time. - */ - const balance = await getCookieBalance( interaction.user.id @@ -276,13 +244,6 @@ async function startRouletteAllIn( } - /* - * All In only selects the wager. - * - * Nothing is transferred until the player - * chooses Red / Black / Odd / Even / Zero. - */ - return showRouletteChoices( interaction, balance @@ -290,18 +251,6 @@ async function startRouletteAllIn( } -/* - * ============================================================ - * SHOW CHOICE MENU - * ============================================================ - * - * Shared by: - * - * - normal wager buttons - * - All In - * - Run It Back - */ - async function showRouletteChoices( interaction, bet @@ -389,16 +338,6 @@ async function showRouletteChoices( } -/* - * ============================================================ - * RUN IT BACK - * ============================================================ - * - * Remembers ONLY the previous wager. - * - * It does NOT remember Red / Black / Odd / Even / Zero. - */ - async function runRouletteBack( interaction ) { @@ -415,12 +354,6 @@ async function runRouletteBack( []; - /* - * Expected: - * - * crumbRouletteResult:: - */ - if ( parts[0] !== "crumbRouletteResult" @@ -477,10 +410,6 @@ async function runRouletteBack( } - /* - * Same wager, fresh choice. - */ - return showRouletteChoices( interaction, bet @@ -488,12 +417,6 @@ async function runRouletteBack( } -/* - * ============================================================ - * PLAY ROULETTE - * ============================================================ - */ - async function playRoulette( interaction, choiceKey @@ -566,15 +489,6 @@ async function playRoulette( } - /* - * IMPORTANT: - * - * Do NOT require ROULETTE_BETS.includes(bet) here. - * - * All In and Run It Back may legitimately use - * amounts outside the predefined wager buttons. - */ - if ( !Number.isSafeInteger( bet @@ -616,11 +530,6 @@ async function playRoulette( ]); - /* - * Balance may have changed while the player - * was choosing their bet type. - */ - if ( playerBalance < bet @@ -635,20 +544,6 @@ async function playRoulette( } - /* - * Worst payout depends on the selected wager. - * - * Red / Black / Odd / Even: - * x2 - * - * Zero: - * x36 - * - * The player's wager is transferred to the house - * before payout, so houseBalance + bet is available - * after accepting the wager. - */ - const maximumPayout = bet * choice.multiplier; @@ -669,12 +564,6 @@ async function playRoulette( } - /* - * ======================================================== - * TAKE WAGER - * ======================================================== - */ - try { await transferCookies( playerId, @@ -701,12 +590,6 @@ async function playRoulette( } - /* - * ======================================================== - * PSEUDO ANIMATION - * ======================================================== - */ - await interaction.update({ embeds: [ buildSpinEmbed( @@ -764,12 +647,6 @@ async function playRoulette( ); - /* - * ======================================================== - * ACTUAL RESULT - * ======================================================== - */ - const result = randomRouletteNumber(); @@ -788,12 +665,6 @@ async function playRoulette( : 0; - /* - * ======================================================== - * PAYOUT - * ======================================================== - */ - if ( payout > 0 @@ -871,7 +742,9 @@ async function playRoulette( ? "win" : profit < 0 ? "loss" - : "push" + : "push", + + profit } ); @@ -893,6 +766,7 @@ async function playRoulette( ); } + else if ( profit < 0 @@ -992,13 +866,6 @@ async function playRoulette( } ) - /* - * Remember only: - * - * owner - * wager - */ - .setFooter({ text: `crumbRouletteResult:${playerId}:${bet}` @@ -1016,17 +883,13 @@ async function playRoulette( } -/* - * ============================================================ - * RESULT LOGIC - * ============================================================ - */ - function didRouletteBetWin( choice, number ) { - switch (choice) { + switch ( + choice + ) { case "Red": return ( @@ -1090,12 +953,6 @@ function didRouletteBetWin( } -/* - * ============================================================ - * DISPLAY - * ============================================================ - */ - function buildSpinEmbed( choice, bet, @@ -1167,12 +1024,6 @@ function formatRouletteNumber( } -/* - * ============================================================ - * WAGER BUTTONS - * ============================================================ - */ - function buildBetButtons() { return [ new ActionRowBuilder() @@ -1183,18 +1034,14 @@ function buildBetButtons() { .setCustomId( `crumbRouletteBet${bet}` ) - .setLabel( bet.toLocaleString() ) - .setEmoji( "šŸŖ" ) - .setStyle( - bet >= - 500 + bet >= 500 ? ButtonStyle.Primary : ButtonStyle.Secondary ) @@ -1235,12 +1082,6 @@ function buildBetButtons() { } -/* - * ============================================================ - * CHOICE BUTTONS - * ============================================================ - */ - function buildChoiceButtons() { return [ new ActionRowBuilder() @@ -1330,22 +1171,10 @@ function buildChoiceButtons() { } -/* - * ============================================================ - * RESULT BUTTONS - * ============================================================ - */ - function buildResultButtons() { return [ new ActionRowBuilder() .addComponents( - /* - * Same wager. - * - * Player chooses Red/Black/etc again. - */ - new ButtonBuilder() .setCustomId( "crumbRouletteRunItBack" @@ -1360,10 +1189,6 @@ function buildResultButtons() { ButtonStyle.Success ), - /* - * Return to wager selection. - */ - new ButtonBuilder() .setCustomId( "crumbSaucerRoulette" @@ -1378,10 +1203,6 @@ function buildResultButtons() { ButtonStyle.Primary ), - /* - * Main Crumb Saucer. - */ - new ButtonBuilder() .setCustomId( "crumbSaucerBack" @@ -1400,12 +1221,6 @@ function buildResultButtons() { } -/* - * ============================================================ - * SESSION OWNER - * ============================================================ - */ - function getRouletteSessionOwner( interaction ) { @@ -1422,10 +1237,6 @@ function getRouletteSessionOwner( []; - /* - * Main Saucer - */ - if ( parts[0] === "crumbSaucer" @@ -1434,10 +1245,6 @@ function getRouletteSessionOwner( } - /* - * Roulette wager menu - */ - if ( parts[0] === "crumbRouletteMenu" @@ -1446,10 +1253,6 @@ function getRouletteSessionOwner( } - /* - * Roulette choice menu. - */ - if ( parts[0] === "crumbRouletteBet" @@ -1458,10 +1261,6 @@ function getRouletteSessionOwner( } - /* - * Roulette result. - */ - if ( parts[0] === "crumbRouletteResult" @@ -1474,12 +1273,6 @@ function getRouletteSessionOwner( } -/* - * ============================================================ - * RANDOM - * ============================================================ - */ - function randomRouletteNumber() { return Math.floor( Math.random() * @@ -1501,20 +1294,11 @@ function sleep( } -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { showRoulette, - chooseRouletteBet, startRouletteAllIn, showRouletteChoices, - playRoulette, - runRouletteBack }; \ No newline at end of file diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js index b00e4e2..d4c45f5 100644 --- a/utils/crumbSaucer.js +++ b/utils/crumbSaucer.js @@ -10,115 +10,271 @@ const { temporaryEphemeralReply } = require("./cookieEconomy"); -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." - }); + +/* + * ============================================================ + * MAIN MENU + * ============================================================ + */ + +async function showCrumbSaucer( + interaction +) { + /* + * Button navigation must preserve ownership. + */ + + 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(); - if (interaction.isButton()) { + const balance = + await getCookieBalance( + interaction.user.id + ); + + + const embed = + buildMainMenuEmbed( + interaction.user, + balance + ); + + + if ( + interaction.isButton() + ) { return interaction.update({ - embeds: [embed], - components + embeds: [ + embed + ], + + components: + buildMainMenuButtons() }); } + return interaction.reply({ - embeds: [embed], - components + embeds: [ + embed + ], + + components: + buildMainMenuButtons() }); } -function buildMainMenuEmbed(user, balance) { + +/* + * ============================================================ + * MAIN MENU EMBED + * ============================================================ + */ + +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.", + "", + "### šŸŖ Cookie Bakery", + "Collect your regular batch of Cookies.", + "", + "### šŸŽ° Slots", + "Spin the reels and chase the Cookie jackpot.", + "", + "### šŸƒ Blackjack", + "Beat the dealer without going over 21.", + "", + "### šŸŽ” Roulette", + "Bet on Red, Black, Odd, Even, or Zero.", + "", + "### šŸ¦ Vault Heist", + "Attempt to breach ShaiBot's Cookie Vault." + ].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 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( + "crumbSaucerBlackjack" + ) + .setLabel( + "Blackjack" + ) + .setEmoji( + "šŸƒ" + ) + .setStyle( + ButtonStyle.Success + ), - new ButtonBuilder() - .setCustomId("crumbSaucerRoulette") - .setLabel("Roulette") - .setEmoji("šŸŽ”") - .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId( + "crumbSaucerRoulette" + ) + .setLabel( + "Roulette" + ) + .setEmoji( + "šŸŽ”" + ) + .setStyle( + ButtonStyle.Primary + ), - new ButtonBuilder() - .setCustomId("crumbSaucerHeist") - .setLabel("Vault Heist") - .setEmoji("šŸ¦") - .setStyle(ButtonStyle.Danger) - ), + new ButtonBuilder() + .setCustomId( + "crumbSaucerHeist" + ) + .setLabel( + "Vault Heist" + ) + .setEmoji( + "šŸ¦" + ) + .setStyle( + ButtonStyle.Danger + ) + ), - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("abort") - .setLabel("Close") - .setEmoji("āœ–ļø") - .setStyle(ButtonStyle.Secondary), + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbSaucerCollectMenu" + ) + .setLabel( + "Cookie Bakery" + ) + .setEmoji( + "šŸŖ" + ) + .setStyle( + ButtonStyle.Success + ), - new ButtonBuilder() - .setCustomId("crumbSaucerCollect") - .setLabel("Collect") - .setEmoji("šŸŖ") - .setStyle(ButtonStyle.Success) - ) + /* + * Do NOT use global "abort" here. + * + * The dedicated handler validates + * Crumb Saucer ownership first. + */ + + new ButtonBuilder() + .setCustomId( + "crumbSaucerClose" + ) + .setLabel( + "Close" + ) + .setEmoji( + "āœ–ļø" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) ]; } -function getCrumbSaucerSessionOwner(interaction) { - const footer = interaction.message?.embeds?.[0]?.footer?.text; - const parts = footer?.split(":") ?? []; - switch (parts[0]) { +/* + * ============================================================ + * SESSION OWNER + * ============================================================ + */ + +function getCrumbSaucerSessionOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + switch ( + parts[0] + ) { case "crumbSaucer": + case "crumbSaucerCollect": case "crumbSaucerSlots": case "crumbSaucerSlotsResult": case "crumbSaucerBlackjack": @@ -127,16 +283,35 @@ function getCrumbSaucerSessionOwner(interaction) { case "crumbRouletteBet": case "crumbRouletteResult": case "cookieHeist": + return parts[1]; + + /* + * Active Blackjack: + * + * crumbBlackjack:: + */ + case "crumbBlackjack": + return parts[2]; + default: + return null; } } + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + module.exports = { - showCrumbSaucer -}; + showCrumbSaucer, + getCrumbSaucerSessionOwner +}; \ No newline at end of file diff --git a/utils/crumbSlots.js b/utils/crumbSlots.js index 658bc3f..49e6ee0 100644 --- a/utils/crumbSlots.js +++ b/utils/crumbSlots.js @@ -16,7 +16,15 @@ const { trackProfileEvent } = require("./profileSystem"); -const SLOT_BETS = [10, 50, 100, 500, 1000]; + +const SLOT_BETS = [ + 10, + 50, + 100, + 500, + 1000 +]; + const SLOT_SYMBOLS = [ "šŸ’", @@ -26,300 +34,857 @@ 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 } + { + 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." - }); +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}` - }); + 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() + 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." - }); +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." - }); + + 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) - ]); + const playerId = + interaction.user.id; - if (playerBalance < bet) { - return temporaryEphemeralReply(interaction, { - content: `You need **${bet.toLocaleString()} Cookies** for that wager.` - }); + + 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." - }); + 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); + 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." - }); + 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 sleep( + 500 + ); + + + const firstPreview = + randomItem( + SLOT_SYMBOLS + ); + await interaction.editReply({ embeds: [ buildSpinningEmbed( bet, - [firstPreview, "ā”", "ā”"], + [ + firstPreview, + "ā”", + "ā”" + ], "The first reel stops..." ) ], + components: [] }); - await sleep(500); - const secondPreview = randomItem(SLOT_SYMBOLS); + await sleep( + 500 + ); + + + const secondPreview = + randomItem( + SLOT_SYMBOLS + ); + await interaction.editReply({ embeds: [ buildSpinningEmbed( bet, - [firstPreview, secondPreview, "ā”"], + [ + firstPreview, + secondPreview, + "ā”" + ], "One more..." ) ], + components: [] }); - await sleep(600); - const outcome = rollSlotOutcome(); - const result = generateOutcomeLine(outcome); - const payout = Math.floor(bet * outcome.multiplier); + await sleep( + 600 + ); - if (payout > 0) { + + const outcome = + rollSlotOutcome(); + + + const result = + generateOutcomeLine( + outcome + ); + + + const payout = + Math.floor( + bet * + outcome.multiplier + ); + + + if ( + payout > + 0 + ) { try { - await transferCookies(vaultId, playerId, payout); + await transferCookies( + vaultId, + playerId, + payout + ); + } catch (error) { - console.error("CRITICAL: Crumb Saucer Slots payout failed:", error); + console.error( + "CRITICAL: Crumb Saucer Slots payout failed:", + error + ); + return interaction.editReply({ embeds: [ new EmbedBuilder() - .setTitle("šŸŽ° Crumb Saucer Error") + .setTitle( + "šŸŽ° Crumb Saucer Error" + ) + .setDescription( "The reels stopped, but the payout system had a problem. Please contact an administrator." ) + .setFooter({ - text: `crumbSaucerSlotsResult:${playerId}:${bet}` + text: + `crumbSaucerSlotsResult:${playerId}:${bet}` }) ], - components: buildResultButtons() + + components: + buildResultButtons() }); } } - const [playerAfter, vaultAfter] = await Promise.all([ - getCookieBalance(playerId), - getCookieBalance(vaultId) - ]); - const profit = payout - bet; + const [ + playerAfter, + vaultAfter + ] = + await Promise.all([ + getCookieBalance( + playerId + ), - await trackProfileEvent(interaction, playerId, { - type: "game_result", - game: "slots", - result: profit > 0 ? "win" : profit < 0 ? "loss" : "push" - }); + getCookieBalance( + vaultId + ) + ]); - 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 profit = + payout - + bet; + + + await trackProfileEvent( + interaction, + playerId, + { + type: + "game_result", + + game: + "slots", + + result: + profit > 0 + ? "win" + : profit < 0 + ? "loss" + : "push", + + profit + } + ); + + + if ( + profit > + 0 + ) { + await trackProfileEvent( + interaction, + playerId, + { + type: + "cookie_gain", + + amount: + 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}` - }); + + 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() + 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." - }); +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." - }); + 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." - }); + + if ( + !SLOT_BETS.includes( + bet + ) + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "The previous wager is no longer available." + } + ); } - return playSlots(interaction, bet); + + 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) - ) + 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) - ) + 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 - ); + const totalWeight = + SLOT_OUTCOMES.reduce( + ( + total, + outcome + ) => + total + + outcome.weight, - let roll = Math.random() * totalWeight; + 0 + ); - for (const outcome of SLOT_OUTCOMES) { - roll -= outcome.weight; - if (roll <= 0) return outcome; + + 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) + randomItem( + SLOT_SYMBOLS + ), + + randomItem( + SLOT_SYMBOLS + ), + + randomItem( + SLOT_SYMBOLS + ) ]; } -function generateOutcomeLine(outcome) { - if (outcome.type === "triple") { + +function generateOutcomeLine( + outcome +) { + if ( + outcome.type === + "triple" + ) { return [ outcome.symbol, outcome.symbol, @@ -327,73 +892,204 @@ function generateOutcomeLine(outcome) { ]; } - if (outcome.type === "pair") { - const symbol = randomItem(SLOT_SYMBOLS); + + 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; + 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); + result = + generateRandomLine(); + + } while ( + new Set( + result + ).size < + 3 + ); + return result; } -function buildSpinningEmbed(bet, symbols, status) { + +function buildSpinningEmbed( + bet, + symbols, + status +) { return new EmbedBuilder() - .setTitle("šŸŽ° Crumb Saucer Slots") - .setDescription([ - "## " + symbols.join(" "), - "", - `**${status}**` - ].join("\n")) + .setTitle( + "šŸŽ° Crumb Saucer Slots" + ) + + .setDescription( + [ + "## " + + symbols.join( + " " + ), + "", + `**${status}**` + ].join("\n") + ) + .addFields({ - name: "Bet", - value: `**${bet.toLocaleString()} šŸŖ**` + 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 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]; +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)); + +function sleep( + ms +) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); } + module.exports = { showSlots, playSlots, runSlotsBack -}; +}; \ No newline at end of file diff --git a/utils/profileSystem.js b/utils/profileSystem.js index eff62b1..a9b43e3 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -6,568 +6,2401 @@ const { MessageFlags } = require("discord.js"); -const { mClient } = require("../index"); +const { + mClient +} = require("../index"); + const { getCookieBalance, getUnixTime } = require("./cookieEconomy"); -const DATABASE_NAME = process.env.M_DB; + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const DATABASE_NAME = + process.env.M_DB; + const COLLECTIONS = { - stats: "stats_profiles", - achievements: "items_achievements", - badges: "guild_badges", - achievementConfig: "config_achievements" + stats: + "stats_profiles", + + achievements: + "items_achievements", + + badges: + "guild_badges", + + 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: "šŸ•µļø" }, - { 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: "šŸ•³ļø" }, - { 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 = [ - { 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 } -]; +/* + * ============================================================ + * COLLECTION ACCESS + * ============================================================ + */ function getProfileCollections() { - if (!DATABASE_NAME) throw new Error("M_DB is not configured in .env"); - const db = mClient.db(DATABASE_NAME); + 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) + + stats: + db.collection( + COLLECTIONS.stats + ), + + achievements: + db.collection( + COLLECTIONS.achievements + ), + + badges: + db.collection( + COLLECTIONS.badges + ), + + achievementConfig: + db.collection( + COLLECTIONS.achievementConfig + ) }; } + +/* + * ============================================================ + * INITIALIZE + * ============================================================ + */ + async function ensureProfileSystem() { - if (profileSystemInitPromise) return profileSystemInitPromise; + const { + stats, + achievements, + badges, + achievementConfig + } = + getProfileCollections(); - 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 } - ); + await stats.createIndex( + { + userId: + 1, + + guildId: + 1 + }, + { + unique: + true } - })(); + ); - try { - return await profileSystemInitPromise; - } catch (error) { - profileSystemInitPromise = null; - throw error; - } + + 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 ensureGuildBadges(guildId) { - await ensureProfileSystem(); - 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) { +/* + * ============================================================ + * DEFAULT STATS + * ============================================================ + */ + +function getDefaultStats( + userId, + guildId +) { return { userId, guildId, + economy: { - cookiesObtainedTotal: 0, - cookiesLostTotal: 0, - cookiesGivenTotal: 0, - stolenTotal: 0, - stolenFromMeTotal: 0, - vaultStolenTotal: 0 + cookiesObtainedTotal: + 0, + + cookiesLostTotal: + 0, + + cookiesGivenTotal: + 0, + + stolenTotal: + 0, + + stolenFromMeTotal: + 0, + + vaultStolenTotal: + 0 }, + unique: { - playersStolenFrom: [], - playersStolenBy: [] + 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 } + total: { + played: + 0, + + won: + 0, + + lost: + 0, + + pushed: + 0 + }, + + slots: { + played: + 0, + + won: + 0, + + lost: + 0, + + pushed: + 0, + + cookiesWon: + 0, + + cookiesLost: + 0 + }, + + blackjack: { + played: + 0, + + won: + 0, + + lost: + 0, + + pushed: + 0, + + cookiesWon: + 0, + + cookiesLost: + 0 + }, + + roulette: { + played: + 0, + + won: + 0, + + lost: + 0, + + pushed: + 0, + + cookiesWon: + 0, + + cookiesLost: + 0 + }, + + heist: { + played: + 0, + + won: + 0, + + lost: + 0, + + pushed: + 0, + + cookiesWon: + 0, + + cookiesLost: + 0 + } }, + streaks: { - currentWin: 0, - bestWin: 0, - currentLoss: 0, - bestLoss: 0 + currentWin: + 0, + + bestWin: + 0, + + currentLoss: + 0, + + bestLoss: + 0 } }; } -async function ensureStatsDocument(userId, guildId) { - const { stats } = getProfileCollections(); + +/* + * ============================================================ + * ENSURE STATS DOCUMENT + * ============================================================ + */ + +async function ensureStatsDocument( + userId, + guildId +) { + const { + stats + } = + getProfileCollections(); + + await stats.updateOne( - { userId, guildId }, - { $setOnInsert: getDefaultStats(userId, guildId) }, - { upsert: true } + { + userId, + guildId + }, + { + $setOnInsert: + getDefaultStats( + userId, + guildId + ) + }, + { + upsert: + true + } ); } -async function ensureAchievementDocument(userId, guildId) { - const { achievements } = getProfileCollections(); + +/* + * ============================================================ + * ENSURE ACHIEVEMENT DOCUMENT + * ============================================================ + */ + +async function ensureAchievementDocument( + userId, + guildId +) { + const { + achievements + } = + getProfileCollections(); + + await achievements.updateOne( - { userId, guildId }, - { $setOnInsert: { userId, guildId, unlocked: {} } }, - { upsert: true } + { + 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); + +/* + * ============================================================ + * GET PROFILE STATS + * ============================================================ + */ + +async function getProfileStats( + userId, + guildId +) { + const { + stats + } = + getProfileCollections(); + + + const existing = + await stats.findOne({ + userId, + guildId + }); + + + return existing + ?? + getDefaultStats( + userId, + guildId + ); } -async function trackProfileEvent(interaction, userId, event) { - await ensureProfileSystem(); - const guildId = interaction.guildId; - if (!guildId || !userId || !event?.type) return; - const { stats } = getProfileCollections(); - await ensureStatsDocument(userId, guildId); +/* + * ============================================================ + * TRACK PROFILE EVENT + * ============================================================ + */ - 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; - } +async function trackProfileEvent( + interaction, + userId, + event +) { + const guildId = + interaction.guildId; - const updatedStats = await getProfileStats(userId, guildId); - await evaluateAchievements(interaction, userId, updatedStats, event.type); - 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 { + if ( + !guildId + || + !userId + || + !event?.type + ) { return; } - await stats.updateOne( - { userId, guildId }, - { - $inc: increment, - ...(Object.keys(set).length ? { $set: set } : {}) - } + + await ensureStatsDocument( + userId, + guildId ); - 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, eventType = null) { - const { achievements, achievementConfig } = getProfileCollections(); - const guildId = interaction.guildId; - await ensureAchievementDocument(userId, guildId); + const { + stats + } = + getProfileCollections(); - 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 ?? {}; - for (const definition of definitions) { - if (unlocked[definition.achievementId]) continue; - const currentValue = readStatValue(statsDocument, definition); - if (currentValue < definition.target) continue; + switch ( + event.type + ) { + /* + * Lifetime gain. + */ - const field = `unlocked.${definition.achievementId}`; - const result = await achievements.updateOne( - { userId, guildId, [field]: { $exists: false } }, - { $set: { [field]: getUnixTime() } } + case "cookie_gain": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.cookiesObtainedTotal": + event.amount + } + } + ); + + break; + + + /* + * Lifetime loss. + */ + + case "cookie_loss": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.cookiesLostTotal": + event.amount + } + } + ); + + break; + + + /* + * Voluntary giving. + */ + + case "cookies_given": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.cookiesGivenTotal": + event.amount + } + } + ); + + break; + + + /* + * Successful player theft. + */ + + case "steal_success": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.stolenTotal": + event.amount + }, + + $addToSet: { + "unique.playersStolenFrom": + event.targetUserId + } + } + ); + + break; + + + /* + * Victim side. + */ + + case "stolen_from_me": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.stolenFromMeTotal": + event.amount + }, + + $addToSet: { + "unique.playersStolenBy": + event.thiefUserId + } + } + ); + + break; + + + /* + * Successful Bot Vault Heist. + */ + + case "vault_steal": + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "economy.vaultStolenTotal": + event.amount + } + } + ); + + break; + + + /* + * Game result. + */ + + case "game_result": + + await trackGameResult( + userId, + guildId, + event + ); + + break; + + + default: + + return; + } + + + /* + * Always evaluate from fresh DB state. + */ + + const updatedStats = + await getProfileStats( + userId, + guildId ); - if (result.modifiedCount === 1) { - await sendAchievementNotification(interaction, userId, definition); + + await evaluateAchievements( + interaction, + userId, + updatedStats + ); + + + await evaluateBadges( + interaction, + userId, + updatedStats, + event + ); + + + return updatedStats; +} + + +/* + * ============================================================ + * GAME RESULT TRACKING + * ============================================================ + */ + +async function trackGameResult( + userId, + guildId, + event +) { + const { + stats + } = + getProfileCollections(); + + + const game = + String( + event.game + ?? + "" + ).toLowerCase(); + + + if ( + ![ + "slots", + "blackjack", + "roulette", + "heist" + ].includes( + game + ) + ) { + return; + } + + + const result = + String( + event.result + ?? + "" + ).toLowerCase(); + + + const profit = + Number( + event.profit + ?? + 0 + ); + + + const increment = { + "games.total.played": + 1, + + [`games.${game}.played`]: + 1 + }; + + + const set = {}; + + + /* + * ======================================================== + * WIN + * ======================================================== + */ + + if ( + result === + "win" + ) { + increment[ + "games.total.won" + ] = + 1; + + + increment[ + `games.${game}.won` + ] = + 1; + + + increment[ + "streaks.currentWin" + ] = + 1; + + + set[ + "streaks.currentLoss" + ] = + 0; + } + + + /* + * ======================================================== + * LOSS + * ======================================================== + */ + + else if ( + result === + "loss" + ) { + increment[ + "games.total.lost" + ] = + 1; + + + increment[ + `games.${game}.lost` + ] = + 1; + + + increment[ + "streaks.currentLoss" + ] = + 1; + + + set[ + "streaks.currentWin" + ] = + 0; + } + + + /* + * ======================================================== + * PUSH + * ======================================================== + */ + + else if ( + result === + "push" + ) { + increment[ + "games.total.pushed" + ] = + 1; + + + increment[ + `games.${game}.pushed` + ] = + 1; + + + /* + * Push leaves streaks unchanged. + */ + } + + + else { + return; + } + + + /* + * ======================================================== + * REALIZED NET COOKIE RESULT + * ======================================================== + * + * Positive: + * + * cookiesWon += profit + * + * Negative: + * + * cookiesLost += abs(profit) + * + * Push: + * + * normally profit = 0 + */ + + if ( + Number.isFinite( + profit + ) + ) { + if ( + profit > + 0 + ) { + increment[ + `games.${game}.cookiesWon` + ] = + profit; + } + + + else if ( + profit < + 0 + ) { + increment[ + `games.${game}.cookiesLost` + ] = + Math.abs( + profit + ); + } + } + + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: + increment, + + ...(Object.keys(set).length + ? { + $set: + set + } + : {}) + } + ); + + + /* + * Update best streaks. + */ + + const current = + await stats.findOne({ + userId, + guildId + }); + + + await stats.updateOne( + { + userId, + guildId + }, + { + $max: { + "streaks.bestWin": + current?.streaks + ?.currentWin + ?? + 0, + + "streaks.bestLoss": + current?.streaks + ?.currentLoss + ?? + 0 + } + } + ); +} + + +/* + * ============================================================ + * ACHIEVEMENT EVALUATION + * ============================================================ + */ + +async function evaluateAchievements( + interaction, + userId, + statsDocument +) { + const guildId = + interaction.guildId; + + + const { + achievements, + achievementConfig + } = + getProfileCollections(); + + + await ensureAchievementDocument( + userId, + guildId + ); + + + /* + * Only active achievements count toward + * normal evaluation and collector progress. + */ + + const definitions = + await achievementConfig + .find({ + active: { + $ne: + false + } + }) + .toArray(); + + + let unlockedDoc = + await achievements.findOne({ + userId, + guildId + }); + + + let unlocked = + unlockedDoc?.unlocked + ?? + {}; + + + /* + * ======================================================== + * NORMAL ACHIEVEMENTS FIRST + * ======================================================== + * + * Collector achievements are evaluated afterwards, + * because unlocking one normal achievement can itself + * push the user over 50% or 100%. + */ + + const normalDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + + for ( + const definition + of normalDefinitions + ) { + if ( + unlocked[ + definition.achievementId + ] + ) { + continue; + } + + + const currentValue = + readStatValue( + statsDocument, + definition + ); + + + if ( + currentValue < + Number( + definition.target + ) + ) { + continue; + } + + + const unlockedNow = + await unlockAchievement( + interaction, + userId, + guildId, + definition + ); + + + if ( + unlockedNow + ) { + unlocked[ + definition.achievementId + ] = + getUnixTime(); + } + } + + + /* + * Refresh after normal unlocks. + */ + + unlockedDoc = + await achievements.findOne({ + userId, + guildId + }); + + + unlocked = + unlockedDoc?.unlocked + ?? + {}; + + + /* + * ======================================================== + * COLLECTOR ACHIEVEMENTS + * ======================================================== + */ + + const collectorDefinitions = + definitions.filter( + definition => + definition.measure === + "achievementPercentage" + ); + + + /* + * Collector achievements should NOT count themselves + * toward the denominator. + * + * Otherwise adding "50%" and "100%" achievements changes + * the percentage they are measuring. + */ + + const collectibleDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + + const totalCollectible = + collectibleDefinitions.length; + + + const unlockedCollectible = + collectibleDefinitions.filter( + definition => + Boolean( + unlocked[ + definition.achievementId + ] + ) + ).length; + + + const achievementPercentage = + totalCollectible > + 0 + ? unlockedCollectible / + totalCollectible + + : 0; + + + for ( + const definition + of collectorDefinitions + ) { + if ( + unlocked[ + definition.achievementId + ] + ) { + continue; + } + + + if ( + achievementPercentage < + Number( + definition.target + ) + ) { + continue; + } + + + const unlockedNow = + await unlockAchievement( + interaction, + userId, + guildId, + definition + ); + + + if ( + unlockedNow + ) { + unlocked[ + definition.achievementId + ] = + getUnixTime(); } } } -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"); +/* + * ============================================================ + * UNLOCK ACHIEVEMENT + * ============================================================ + */ - if ((statsDocument?.economy?.vaultStolenTotal ?? 0) >= 100000) { - await tryClaimBadge(interaction, userId, "first_100k_vault_stolen"); +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; } -} -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" } + await sendAchievementNotification( + interaction, + definition ); - 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, userId, achievement) { - try { - await interaction.channel.send({ - embeds: [ - new EmbedBuilder() - .setTitle("šŸ† Achievement Unlocked!") - .setDescription([ - `<@${userId}>`, - "", - `${achievement.emoji} **${achievement.name}**`, - "", - achievement.description - ].join("\n")) - ] - }); - } catch {} + return true; } -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; +/* + * ============================================================ + * READ STAT VALUE + * ============================================================ + */ + +function readStatValue( + document, + definition +) { + if ( + !definition.stat + ) { + return 0; + } + + + const value = + definition.stat + .split(".") + .reduce( + ( + current, + key + ) => + current?.[key], + + document + ); + + + if ( + definition.measure === + "arrayLength" + ) { + return Array.isArray( + value + ) + ? value.length + : 0; + } + + + return Number( + value + ?? + 0 + ); +} + + +/* + * ============================================================ + * ACHIEVEMENT NOTIFICATION + * ============================================================ + */ + +async function sendAchievementNotification( + interaction, + achievement +) { + try { + await interaction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle( + "šŸ† Achievement Unlocked!" + ) + + .setDescription( + [ + `${achievement.emoji} **${achievement.name}**`, + "", + achievement.description + ].join("\n") + ) + ], + + flags: + MessageFlags.Ephemeral + }); + } + + catch { + /* + * Unlock stays persisted even if the Discord + * interaction can no longer accept follow-ups. + */ } } -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; +/* + * ============================================================ + * BADGE EVALUATION + * ============================================================ + * + * Existing badges remain supported here. + * + * We can expand this next with: + * + * - first Slots jackpot + * - first natural Blackjack + * - first Roulette Zero + * - first 5 / 10 / 15 unique steals + * - Achievement Hunter + * - Completionist + */ + +async function evaluateBadges( + interaction, + userId, + statsDocument, + event = + {} +) { + const guildId = + interaction.guildId; + + + if (!guildId) { + return; } - return Number(value ?? 0); + + /* + * ======================================================== + * ECONOMY BADGES + * ======================================================== + */ + + const balance = + await getCookieBalance( + userId + ); + + + if ( + balance >= + 100000 + ) { + await tryClaimBadge( + interaction, + userId, + "first_100k_cookies" + ); + } + + + if ( + balance >= + 1000000 + ) { + await tryClaimBadge( + interaction, + userId, + "first_1m_cookies" + ); + } + + + /* + * ======================================================== + * VAULT BADGE + * ======================================================== + */ + + if ( + ( + statsDocument + ?.economy + ?.vaultStolenTotal + ?? + 0 + ) + >= + 100000 + ) { + await tryClaimBadge( + interaction, + userId, + "first_100k_vault_stolen" + ); + } + + + /* + * ======================================================== + * UNIQUE PLAYER THEFT BADGES + * ======================================================== + */ + + const uniquePlayersStolenFrom = + statsDocument + ?.unique + ?.playersStolenFrom + ?? + []; + + + const uniqueStealCount = + uniquePlayersStolenFrom.length; + + + if ( + uniqueStealCount >= + 5 + ) { + await tryClaimBadge( + interaction, + userId, + "first_unique_steals_5" + ); + } + + + if ( + uniqueStealCount >= + 10 + ) { + await tryClaimBadge( + interaction, + userId, + "first_unique_steals_10" + ); + } + + + if ( + uniqueStealCount >= + 15 + ) { + await tryClaimBadge( + interaction, + userId, + "first_unique_steals_15" + ); + } + + + /* + * ======================================================== + * SLOT JACKPOT + * ======================================================== + */ + + if ( + event.type === + "game_result" + && + event.game === + "slots" + && + event.jackpot === + true + ) { + await tryClaimBadge( + interaction, + userId, + "first_slots_jackpot" + ); + } + + + /* + * ======================================================== + * NATURAL BLACKJACK + * ======================================================== + */ + + if ( + event.type === + "game_result" + && + event.game === + "blackjack" + && + event.natural === + true + && + event.result === + "win" + ) { + await tryClaimBadge( + interaction, + userId, + "first_blackjack_natural" + ); + } + + + /* + * ======================================================== + * BLACKJACK ALL IN + * ======================================================== + */ + + if ( + event.type === + "game_result" + && + event.game === + "blackjack" + && + event.allIn === + true + && + event.result === + "win" + ) { + await tryClaimBadge( + interaction, + userId, + "first_blackjack_allin_win" + ); + } + + + /* + * ======================================================== + * ROULETTE ZERO + * ======================================================== + */ + + if ( + event.type === + "game_result" + && + event.game === + "roulette" + && + event.zero === + true + && + event.result === + "win" + ) { + await tryClaimBadge( + interaction, + userId, + "first_roulette_zero" + ); + } + + + /* + * ======================================================== + * ROULETTE ALL IN + * ======================================================== + */ + + if ( + event.type === + "game_result" + && + event.game === + "roulette" + && + event.allIn === + true + && + event.result === + "win" + ) { + await tryClaimBadge( + interaction, + userId, + "first_roulette_allin_win" + ); + } + + + /* + * ======================================================== + * ACHIEVEMENT COLLECTOR BADGES + * ======================================================== + */ + + await evaluateCollectorBadges( + interaction, + userId + ); } -async function buildProfileOverview(guildId, user) { - await ensureProfileSystem(); - const stats = await getProfileStats(user.id, guildId); - const balance = await getCookieBalance(user.id); - const { achievements, badges, achievementConfig } = getProfileCollections(); +async function evaluateCollectorBadges( + interaction, + userId +) { + const guildId = + interaction.guildId; - 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; + const { + achievements, + achievementConfig + } = + getProfileCollections(); - 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 }, + + /* + * Don't count Achievement Hunter / Completionist + * themselves toward the percentage. + */ + + const activeAchievements = + await achievementConfig + .find({ + active: { + $ne: + false + }, + + measure: { + $ne: + "achievementPercentage" + } + }) + .toArray(); + + + if ( + activeAchievements.length === + 0 + ) { + return; + } + + + const achievementDocument = + await achievements.findOne({ + userId, + guildId + }); + + + const unlocked = + achievementDocument + ?.unlocked + ?? + {}; + + + const unlockedCount = + activeAchievements.filter( + achievement => + Boolean( + unlocked[ + achievement.achievementId + ] + ) + ).length; + + + const percentage = + unlockedCount / + activeAchievements.length; + + + /* + * First player to 50%. + */ + + if ( + percentage >= + 0.5 + ) { + await tryClaimBadge( + interaction, + userId, + "first_achievement_half", { - 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 + unlockedAchievements: + unlockedCount, + + availableAchievements: + activeAchievements.length + } + ); + } + + + /* + * First player to 100%. + */ + + if ( + percentage >= + 1 + ) { + await tryClaimBadge( + interaction, + userId, + "first_achievement_all", + { + unlockedAchievements: + unlockedCount, + + availableAchievements: + activeAchievements.length + } + ); + } +} + + +/* + * ============================================================ + * CLAIM BADGE + * ============================================================ + */ + +async function tryClaimBadge( + interaction, + userId, + badgeId, + metadata = + null +) { + const { + badges + } = + getProfileCollections(); + + + const guildId = + interaction.guildId; + + + if ( + !guildId + ) { + return false; + } + + + /* + * Build the fields that should be written + * only when this badge is actually claimed. + */ + + const update = { + ownerId: + userId, + + unlockedAt: + getUnixTime() + }; + + + /* + * Optional historical context. + * + * Example for Completionist: + * + * { + * unlockedAchievements: 64, + * availableAchievements: 64 + * } + */ + + if ( + metadata + && + typeof metadata === + "object" + ) { + update.metadata = + metadata; + } + + + /* + * Atomic claim. + * + * Only an unclaimed badge can be updated. + * + * If two users qualify at essentially the + * same time, MongoDB lets only one update + * ownerId from null. + */ + + const badge = + await badges.findOneAndUpdate( + { + guildId, + badgeId, + + ownerId: + null }, { - name: "šŸ”„ Best Streaks", - value: [ - `Wins: **${stats.streaks.bestWin}**`, - `Losses: **${stats.streaks.bestLoss}**` - ].join("\n"), - inline: true + $set: + update + }, + { + returnDocument: + "after" + } + ); + + + /* + * Badge does not exist, was already claimed, + * or this user did not win the atomic claim. + */ + + if ( + !badge + || + badge.ownerId !== + userId + ) { + return false; + } + + + /* + * ======================================================== + * PUBLIC ANNOUNCEMENT + * ======================================================== + */ + + const description = [ + `${badge.emoji ?? "šŸ…"} **${badge.name ?? badge.badgeId}**`, + "", + `<@${userId}> claimed a guild-unique badge!`, + "", + badge.description + ?? + "A unique badge has been claimed.", + "", + "*This badge can no longer be earned by another player.*" + ]; + + + /* + * Add collector history if applicable. + */ + + if ( + badge.metadata + ?.unlockedAchievements !== + undefined + && + badge.metadata + ?.availableAchievements !== + undefined + ) { + description.splice( + description.length - + 1, + 0, + + `Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`, + "" + ); + } + + + try { + await interaction.channel.send({ + embeds: [ + new EmbedBuilder() + .setTitle( + "šŸ… Unique Badge Claimed!" + ) + + .setDescription( + description.join( + "\n" + ) + ) + + .setFooter({ + text: + `badge:${badge.badgeId}` + }) + + .setTimestamp() + ] + }); + } + + catch (error) { + /* + * The badge claim itself is already persisted. + * + * A Discord send failure should never undo it. + */ + + console.error( + `[Badge] Failed to announce ${badgeId} for ${userId}:`, + error + ); + } + + + return true; +} + + +/* + * ============================================================ + * PROFILE OVERVIEW + * ============================================================ + */ + +async function buildProfileOverview( + guildId, + user +) { + const stats = + await getProfileStats( + user.id, + guildId + ); + + + const balance = + await getCookieBalance( + user.id + ); + + + const { + achievements, + badges, + achievementConfig + } = + getProfileCollections(); + + + const [ + achievementDoc, + achievementCount, + ownedBadges + ] = + await Promise.all([ + achievements.findOne({ + userId: + user.id, + + guildId + }), + + achievementConfig.countDocuments({ + active: { + $ne: + false + } + }), + + badges.find({ + guildId, + + ownerId: + user.id + }).toArray() + ]); + + + const unlockedCount = + Object.keys( + achievementDoc?.unlocked + ?? + {} + ).length; + + + return new EmbedBuilder() + .setTitle( + `šŸ‘¤ ${user.globalName ?? user.username}'s Profile` + ) + + .setThumbnail( + user.displayAvatarURL({ + size: + 256 + }) + ) + + .addFields( + { + name: + "šŸŖ Cookies", + + value: + balance.toLocaleString(), + + inline: + true + }, + + { + name: + "šŸ† Achievements", + + value: + `${unlockedCount} / ${achievementCount}`, + + inline: + true + }, + + { + name: + "šŸ… Unique Badges", + + value: + `${ownedBadges.length}`, + + inline: + true + }, + + { + name: + "šŸŽ® Games", + + value: + [ + `Played: **${stats.games.total.played}**`, + `Won: **${stats.games.total.won}**`, + `Lost: **${stats.games.total.lost}**`, + `Pushes: **${stats.games.total.pushed}**` + ].join("\n"), + + inline: + true + }, + + { + name: + "šŸ”„ Best Streaks", + + value: + [ + `Wins: **${stats.streaks.bestWin}**`, + `Losses: **${stats.streaks.bestLoss}**` + ].join("\n"), + + inline: + true } ) - .setFooter({ text: `profile:${user.id}` }); + + .setFooter({ + text: + `profile:${user.id}` + }); } -async function buildBadgesEmbed(guildId, user) { - await ensureProfileSystem(); - 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); +/* + * ============================================================ + * BADGES PAGE + * ============================================================ + */ + +async function buildBadgesEmbed( + guildId, + user +) { + const { + badges + } = + getProfileCollections(); + + + const documents = + await badges.find({ + guildId + }).toArray(); + + + const owned = + documents.filter( + badge => + badge.ownerId === + user.id + ); + + + const claimedByOthers = + documents.filter( + badge => + badge.ownerId + && + badge.ownerId !== + user.id + ); + + + const unclaimed = + documents.filter( + badge => + !badge.ownerId + ); + return new EmbedBuilder() - .setTitle(`šŸ… ${user.globalName ?? user.username}'s Badges`) - .setDescription([ - "### Owned", - owned.length - ? owned.map(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) { - await ensureProfileSystem(); - 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 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`) - .setDescription(lines.join("\n") || "No achievements configured.") - .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 } + .setTitle( + `šŸ… ${user.globalName ?? user.username}'s Badges` ) - .setFooter({ text: `profile:${user.id}` }); + + .setDescription( + [ + "### Owned", + owned.length + ? owned + .map( + badge => + `${badge.emoji} **${badge.name}**\n${badge.description}` + ) + .join("\n\n") + : "*No unique badges yet.*", + + "", + "### Claimed by Others", + claimedByOthers.length + ? claimedByOthers + .map( + badge => + `${badge.emoji} **${badge.name}** — <@${badge.ownerId}>` + ) + .join("\n") + : "*None yet.*", + + "", + "### Still Available", + unclaimed.length + ? unclaimed + .map( + badge => + `${badge.emoji} **${badge.name}**\n${badge.description}` + ) + .join("\n\n") + : "*All unique badges have been claimed.*" + ].join("\n") + ) + + .setFooter({ + text: + `profile:${user.id}` + }); } -function formatGameStats(game) { - return [ - `Played: **${game?.played ?? 0}**`, - `Won: **${game?.won ?? 0}**`, - `Lost: **${game?.lost ?? 0}**`, - `Pushes: **${game?.pushed ?? 0}**` - ].join("\n"); + +/* + * ============================================================ + * ACHIEVEMENTS PAGE + * ============================================================ + */ + +async function buildAchievementsEmbed( + guildId, + user +) { + const { + achievements, + achievementConfig + } = + getProfileCollections(); + + + const [ + unlockedDoc, + definitions + ] = + await Promise.all([ + achievements.findOne({ + userId: + user.id, + + guildId + }), + + achievementConfig + .find({ + active: { + $ne: + false + } + }) + .sort({ + category: + 1, + + target: + 1 + }) + .toArray() + ]); + + + const unlocked = + unlockedDoc?.unlocked + ?? + {}; + + + const lines = + definitions.map( + achievement => { + const complete = + Boolean( + unlocked[ + achievement.achievementId + ] + ); + + + return [ + complete + ? "āœ…" + : "⬜", + + " ", + + achievement.emoji, + + " **", + + achievement.name, + + "** — ", + + achievement.requirement + ].join(""); + } + ); + + + return new EmbedBuilder() + .setTitle( + `šŸ† ${user.globalName ?? user.username}'s Achievements` + ) + + .setDescription( + lines.join("\n") + || + "No achievements configured." + ) + + .setFooter({ + text: + `profile:${user.id}` + }); } + +/* + * ============================================================ + * PROFILE BUTTONS + * ============================================================ + */ + function buildProfileButtons() { return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId("profileOverview").setLabel("Overview").setEmoji("šŸ‘¤").setStyle(ButtonStyle.Primary), - new ButtonBuilder().setCustomId("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) - ) + 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; + +/* + * ============================================================ + * PROFILE TARGET + * ============================================================ + */ + +function getProfileTargetId( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const [ + type, + userId + ] = + footer?.split(":") + ?? + []; + + + if ( + type !== + "profile" + ) { + return null; + } + + + return userId; } + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + module.exports = { ensureProfileSystem, - ensureGuildBadges, + trackProfileEvent, getProfileStats, + buildProfileOverview, - buildStatsEmbed, buildBadgesEmbed, buildAchievementsEmbed, buildProfileButtons, + getProfileTargetId -}; +}; \ No newline at end of file