From 030b4b88838901329b352aec8d5d6658a42cc1a3 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Sat, 22 Aug 2026 23:33:20 +0200 Subject: [PATCH] updated profile and centralized functions --- buttons/profile/achievements.js | 71 +- buttons/profile/achievementsLeft.js | 73 +- buttons/profile/achievementsRight.js | 73 +- buttons/profile/badges.js | 71 +- buttons/profile/badgesLeft.js | 73 +- buttons/profile/badgesRight.js | 73 +- buttons/profile/stats.js | 53 +- commands/applications/profileAdmin.js | 415 ++++ commands/applications/records.js | 61 + index.js | 177 +- utils/cookieHeist.js | 30 +- utils/crumbBlackjack.js | 32 +- utils/crumbRoulette.js | 32 +- utils/crumbSlots.js | 32 +- utils/permissions.js | 216 ++ utils/profileMaintenance.js | 403 ++++ utils/profileRecords.js | 225 ++ utils/profileSystem.js | 2839 ++++++++++++++++++------- 18 files changed, 3747 insertions(+), 1202 deletions(-) create mode 100644 commands/applications/profileAdmin.js create mode 100644 commands/applications/records.js create mode 100644 utils/permissions.js create mode 100644 utils/profileMaintenance.js create mode 100644 utils/profileRecords.js diff --git a/buttons/profile/achievements.js b/buttons/profile/achievements.js index e4ce1b5..66e675c 100644 --- a/buttons/profile/achievements.js +++ b/buttons/profile/achievements.js @@ -1,54 +1,51 @@ const { + requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileAchievements", + name: "profileAchievements", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildAchievementsEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - 0 - ); + const result = await buildAchievementsEmbed( + interaction.guildId, + user, + 0, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileAchievementsLeft", + "profileAchievementsRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "achievements", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/achievementsLeft.js b/buttons/profile/achievementsLeft.js index 7e72918..5871882 100644 --- a/buttons/profile/achievementsLeft.js +++ b/buttons/profile/achievementsLeft.js @@ -1,54 +1,53 @@ +// buttons/profile/achievementsLeft.js + const { + requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileAchievementsLeft", + name: "profileAchievementsLeft", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildAchievementsEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - context.page - 1 - ); + const result = await buildAchievementsEmbed( + interaction.guildId, + user, + session.page - 1, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileAchievementsLeft", + "profileAchievementsRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "achievements", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/achievementsRight.js b/buttons/profile/achievementsRight.js index e35a23d..ad55a9b 100644 --- a/buttons/profile/achievementsRight.js +++ b/buttons/profile/achievementsRight.js @@ -1,54 +1,53 @@ +// buttons/profile/achievementsRight.js + const { + requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileAchievementsRight", + name: "profileAchievementsRight", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildAchievementsEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - context.page + 1 - ); + const result = await buildAchievementsEmbed( + interaction.guildId, + user, + session.page + 1, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileAchievementsLeft", + "profileAchievementsRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "achievements", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/badges.js b/buttons/profile/badges.js index 75d5251..94f995a 100644 --- a/buttons/profile/badges.js +++ b/buttons/profile/badges.js @@ -1,54 +1,51 @@ const { + requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileBadges", + name: "profileBadges", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildBadgesEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - 0 - ); + const result = await buildBadgesEmbed( + interaction.guildId, + user, + 0, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileBadgesLeft", + "profileBadgesRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "badges", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/badgesLeft.js b/buttons/profile/badgesLeft.js index ad3f3b7..87c4ac5 100644 --- a/buttons/profile/badgesLeft.js +++ b/buttons/profile/badgesLeft.js @@ -1,54 +1,53 @@ +// buttons/profile/badgesLeft.js + const { + requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileBadgesLeft", + name: "profileBadgesLeft", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildBadgesEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - context.page - 1 - ); + const result = await buildBadgesEmbed( + interaction.guildId, + user, + session.page - 1, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileBadgesLeft", + "profileBadgesRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "badges", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/badgesRight.js b/buttons/profile/badgesRight.js index 8716229..61208fe 100644 --- a/buttons/profile/badgesRight.js +++ b/buttons/profile/badgesRight.js @@ -1,54 +1,53 @@ +// buttons/profile/badgesRight.js + const { + requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - getProfileContextFromFooter, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + buildProfilePaginationButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileBadgesRight", + name: "profileBadgesRight", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildBadgesEmbed( - interaction.guildId, - user, - context.sessionOwnerId, - context.page + 1 - ); + const result = await buildBadgesEmbed( + interaction.guildId, + user, + session.page + 1, + session.sessionOwnerId + ); - const pageContext = - getProfileContextFromFooter( - embed.data.footer?.text + const components = [ + ...buildProfileButtons() + ]; + + if (result.pageCount > 1) { + components.push( + buildProfilePaginationButtons( + "profileBadgesLeft", + "profileBadgesRight", + result.page, + result.pageCount + ) ); + } return interaction.update({ - embeds: [embed], - components: - buildProfileButtons( - "badges", - pageContext?.page ?? 0, - pageContext?.pageCount ?? 1 - ) + embeds: [result.embed], + components }); } -}; +}; \ No newline at end of file diff --git a/buttons/profile/stats.js b/buttons/profile/stats.js index 53c56a2..733a5d4 100644 --- a/buttons/profile/stats.js +++ b/buttons/profile/stats.js @@ -1,47 +1,34 @@ const { - buildStatsEmbed, - buildProfileButtons, - requireProfileOwner -} = require( - "../../utils/profileSystem" -); + requireProfileOwner, + buildProfileStatsEmbed, + buildProfileButtons +} = require("../../utils/profileSystem"); module.exports = { - name: - "profileStats", + name: "profileStats", - async execute( - interaction - ) { - const context = - await requireProfileOwner( - interaction - ); + async execute(interaction) { + const session = await requireProfileOwner( + interaction + ); - if (!context) { + if (!session) { return; } - const user = - await interaction.client.users.fetch( - context.profileUserId - ); + const user = await interaction.client.users.fetch( + session.profileUserId + ); - const embed = - await buildStatsEmbed( - interaction.guildId, - user, - context.sessionOwnerId - ); + const embed = await buildProfileStatsEmbed( + interaction.guildId, + user, + session.sessionOwnerId + ); return interaction.update({ embeds: [embed], - components: - buildProfileButtons( - "stats", - 0, - 1 - ) + components: buildProfileButtons() }); } -}; +}; \ No newline at end of file diff --git a/commands/applications/profileAdmin.js b/commands/applications/profileAdmin.js new file mode 100644 index 0000000..bb3752f --- /dev/null +++ b/commands/applications/profileAdmin.js @@ -0,0 +1,415 @@ +const { + SlashCommandBuilder, + MessageFlags +} = require( + "discord.js" +); + +const { + requireAdminAccess +} = require( + "../../utils/permissions" +); + +const { + reconcileProfileAchievements, + auditProfileSystem +} = require( + "../../utils/profileSystem" +); + + +/* + * ============================================================ + * COMMAND + * ============================================================ + */ + +module.exports = { + data: + new SlashCommandBuilder() + .setName( + "profileadmin" + ) + + .setDescription( + "Profile and achievement administration." + ) + + /* + * ================================================= + * RECONCILE + * ================================================= + */ + + .addSubcommand( + subcommand => + subcommand + .setName( + "reconcile" + ) + + .setDescription( + "Re-evaluate stored statistics for achievements." + ) + + .addUserOption( + option => + option + .setName( + "user" + ) + + .setDescription( + "Only reconcile this user's profile." + ) + + .setRequired( + false + ) + ) + + .addBooleanOption( + option => + option + .setName( + "dry-run" + ) + + .setDescription( + "Show what would change without unlocking anything." + ) + + .setRequired( + false + ) + ) + ) + + /* + * ================================================= + * AUDIT + * ================================================= + */ + + .addSubcommand( + subcommand => + subcommand + .setName( + "audit" + ) + + .setDescription( + "Audit the profile and achievement system." + ) + ), + + + /* + * ======================================================== + * EXECUTE + * ======================================================== + */ + + async execute( + interaction + ) { + /* + * Runtime authorization. + * + * This intentionally replaces: + * + * .setDefaultMemberPermissions( + * PermissionFlagsBits.Administrator + * ) + * + * so BOT_AUTHOR_ID can also invoke the command. + */ + + if ( + !await requireAdminAccess( + interaction + ) + ) { + return; + } + + + const subcommand = + interaction.options + .getSubcommand(); + + + switch ( + subcommand + ) { + case "reconcile": + + return runReconcile( + interaction + ); + + + case "audit": + + return runAudit( + interaction + ); + + + default: + + return interaction.reply({ + content: + "Unknown profile administration action.", + + flags: + MessageFlags.Ephemeral + }); + } + } +}; + + +/* + * ============================================================ + * RECONCILE + * ============================================================ + */ + +async function runReconcile( + interaction +) { + const target = + interaction.options.getUser( + "user" + ); + + + const dryRun = + interaction.options.getBoolean( + "dry-run" + ) + ?? + false; + + + /* + * This can touch multiple Mongo documents, + * so acknowledge the interaction first. + */ + + await interaction.deferReply({ + flags: + MessageFlags.Ephemeral + }); + + + try { + const result = + await reconcileProfileAchievements( + interaction.guildId, + { + userId: + target?.id + ?? + null, + + dryRun + } + ); + + + const lines = [ + dryRun + ? "### ๐Ÿ”Ž Profile Reconciliation โ€” Dry Run" + : "### โœ… Profile Reconciliation Complete", + + "", + + target + ? `Target: ${target}` + : "Target: **Entire Guild**", + + "", + + `Profiles checked: **${result.profilesChecked ?? 0}**`, + `Achievement unlocks found: **${result.unlocksFound ?? 0}**` + ]; + + + if ( + !dryRun + ) { + lines.push( + `Achievements unlocked: **${result.unlocksApplied ?? 0}**` + ); + } + + + if ( + result.skippedEventOnly !== + undefined + ) { + lines.push( + `Event-only checks skipped: **${result.skippedEventOnly}**` + ); + } + + + if ( + Array.isArray( + result.details + ) + && + result.details.length > + 0 + ) { + const preview = + result.details + .slice( + 0, + 15 + ) + .map( + entry => { + if ( + typeof entry === + "string" + ) { + return `โ€ข ${entry}`; + } + + + return ( + `โ€ข <@${entry.userId}> โ€” ` + + `${entry.achievementName ?? entry.achievementId}` + ); + } + ); + + + lines.push( + "", + "### Changes", + ...preview + ); + + + if ( + result.details.length > + preview.length + ) { + lines.push( + "", + `*...and ${result.details.length - preview.length} more.*` + ); + } + } + + + return interaction.editReply({ + content: + lines.join( + "\n" + ) + }); + + } catch (error) { + console.error( + "[ProfileAdmin] Reconciliation failed:", + error + ); + + + return interaction.editReply({ + content: + "Profile reconciliation failed. Check the bot logs for details." + }); + } +} + + +/* + * ============================================================ + * AUDIT + * ============================================================ + */ + +async function runAudit( + interaction +) { + await interaction.deferReply({ + flags: + MessageFlags.Ephemeral + }); + + + try { + const result = + await auditProfileSystem( + interaction.guildId + ); + + + const lines = [ + "### ๐Ÿงพ Profile System Audit", + "", + + `Profile documents: **${result.profileDocuments ?? 0}**`, + `Achievement documents: **${result.achievementDocuments ?? 0}**`, + `Active achievement definitions: **${result.activeAchievements ?? 0}**`, + `Legacy achievement definitions: **${result.legacyAchievements ?? 0}**`, + `Guild badges: **${result.badges ?? 0}**`, + `Claimed badges: **${result.claimedBadges ?? 0}**`, + `Available badges: **${result.availableBadges ?? 0}**` + ]; + + + if ( + Array.isArray( + result.warnings + ) + && + result.warnings.length > + 0 + ) { + lines.push( + "", + "### โš ๏ธ Warnings", + + ...result.warnings.map( + warning => + `โ€ข ${warning}` + ) + ); + } + + else { + lines.push( + "", + "โœ… No obvious profile-system issues were found." + ); + } + + + return interaction.editReply({ + content: + lines.join( + "\n" + ) + }); + + } catch (error) { + console.error( + "[ProfileAdmin] Audit failed:", + error + ); + + + return interaction.editReply({ + content: + "Profile audit failed. Check the bot logs for details." + }); + } +} \ No newline at end of file diff --git a/commands/applications/records.js b/commands/applications/records.js new file mode 100644 index 0000000..a48d203 --- /dev/null +++ b/commands/applications/records.js @@ -0,0 +1,61 @@ +const { + SlashCommandBuilder +} = require("discord.js"); + +const { + buildRecordsEmbed, + getRecordChoices +} = require( + "../../utils/profileRecords" +); + +module.exports = { + data: + new SlashCommandBuilder() + .setName( + "records" + ) + .setDescription( + "View Shai Cookie and Crumb Saucer records" + ) + .addStringOption( + option => + option + .setName( + "category" + ) + .setDescription( + "Record category" + ) + .setRequired( + true + ) + .addChoices( + ...getRecordChoices() + ) + ), + + async execute( + interaction + ) { + const category = + interaction.options.getString( + "category", + true + ); + + const embed = + await buildRecordsEmbed({ + guildId: + interaction.guildId, + category, + botUserId: + interaction.client.user.id, + limit: 10 + }); + + return interaction.reply({ + embeds: [embed] + }); + } +}; diff --git a/index.js b/index.js index 84b7e8e..0d4b5c0 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,12 @@ const { - Client, - GatewayIntentBits, - Collection, - Partials + Client, + GatewayIntentBits, + Collection, + Partials } = require("discord.js"); const { - MongoClient + MongoClient } = require("mongodb"); const fs = require("node:fs"); @@ -19,12 +19,12 @@ require("dotenv").config(); */ const mClient = - new MongoClient( - process.env.M_URI - ); + new MongoClient( + process.env.M_URI + ); exports.mClient = - mClient; + mClient; /* @@ -32,30 +32,29 @@ exports.mClient = */ const client = - new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildPresences, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent - ], - - partials: [ - Partials.Channel, - Partials.Message, - Partials.User, - Partials.GuildMember, - Partials.Reaction - ] - }); + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent + ], + partials: [ + Partials.Channel, + Partials.Message, + Partials.User, + Partials.GuildMember, + Partials.Reaction + ] + }); exports.client = - client; + client; /* @@ -63,22 +62,22 @@ exports.client = */ client.commands = - new Collection(); + new Collection(); client.legacyCommands = - new Collection(); + new Collection(); client.aliases = - new Collection(); + new Collection(); client.buttons = - new Collection(); + new Collection(); client.selectMenus = - new Collection(); + new Collection(); client.modals = - new Collection(); + new Collection(); /* @@ -86,56 +85,84 @@ client.modals = */ async function start() { - try { + try { + /* + * Connect Mongo first. + */ - /* - * Connect Mongo first. - */ + await mClient.connect(); - await mClient.connect(); - - console.log( - "MongoDB connected" - ); + console.log( + "MongoDB connected" + ); - /* - * Load handlers. - */ + /* + * Initialize database indexes before handlers are + * exposed to Discord interactions. + * + * Requiring these after mClient is exported avoids + * circular-import initialization problems because the + * shared utilities import mClient from index.js. + */ - const handlerFiles = - fs.readdirSync( - "./handlers" - ); + const { + ensureCookieIndexes + } = require( + "./utils/cookieEconomy" + ); + + const { + ensureProfileSystem + } = require( + "./utils/profileSystem" + ); + + await Promise.all([ + ensureCookieIndexes(), + ensureProfileSystem() + ]); + + console.log( + "Database indexes initialized" + ); - for ( - const handler - of handlerFiles - ) { - require( - `./handlers/${handler}` - )(client); - } + /* + * Load handlers. + */ + + const handlerFiles = + fs.readdirSync( + "./handlers" + ); + + for ( + const handler + of handlerFiles + ) { + require( + `./handlers/${handler}` + )(client); + } - /* - * Login Discord. - */ + /* + * Login Discord. + */ - await client.login( - process.env.D_TOKEN - ); + await client.login( + process.env.D_TOKEN + ); - } catch (error) { - console.error( - "Failed to start ShaiBot:", - error - ); + } catch (error) { + console.error( + "Failed to start ShaiBot:", + error + ); - process.exit(1); - } + process.exit(1); + } } - -start(); \ No newline at end of file +start(); diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index 9735229..1617570 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -15,7 +15,8 @@ const { } = require("./cookieEconomy"); const { - trackProfileEvent + trackProfileEvent, + recordGameOutcome } = require("./profileSystem"); const HEIST_COOLDOWN = @@ -255,12 +256,10 @@ async function resolveCookieHeist( throw error; } - await trackProfileEvent( + await recordGameOutcome( interaction, playerId, { - type: - "game_result", game: "heist", result: @@ -280,15 +279,6 @@ async function resolveCookieHeist( } ); - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_gain", - amount - } - ); const [ playerAfter, @@ -444,12 +434,10 @@ async function resolveCookieHeist( throw error; } - await trackProfileEvent( + await recordGameOutcome( interaction, playerId, { - type: - "game_result", game: "heist", result: @@ -459,16 +447,6 @@ async function resolveCookieHeist( } ); - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_loss", - amount: - penalty - } - ); const [ playerAfter, diff --git a/utils/crumbBlackjack.js b/utils/crumbBlackjack.js index 979d03d..ca69c66 100644 --- a/utils/crumbBlackjack.js +++ b/utils/crumbBlackjack.js @@ -18,7 +18,7 @@ const { } = require("./cookieEconomy"); const { - trackProfileEvent + recordGameOutcome } = require("./profileSystem"); const GAME_COLLECTION = @@ -929,12 +929,10 @@ async function finishBlackjack( profileResult = "loss"; } - await trackProfileEvent( + await recordGameOutcome( interaction, game.userId, { - type: - "game_result", game: "blackjack", result: @@ -949,32 +947,6 @@ async function finishBlackjack( } ); - if (profit > 0) { - await trackProfileEvent( - interaction, - game.userId, - { - type: - "cookie_gain", - amount: - profit - } - ); - } - - else if (profit < 0) { - await trackProfileEvent( - interaction, - game.userId, - { - type: - "cookie_loss", - amount: - Math.abs(profit) - } - ); - } - const description = [ "**Dealer**", formatCards( diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index 0073afb..b976488 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -12,7 +12,7 @@ const { } = require("./cookieEconomy"); const { - trackProfileEvent + recordGameOutcome } = require("./profileSystem"); const ROULETTE_BETS = [ @@ -577,12 +577,10 @@ async function playRoulette( const profit = payout - bet; - await trackProfileEvent( + await recordGameOutcome( interaction, playerId, { - type: - "game_result", game: "roulette", result: @@ -602,32 +600,6 @@ async function playRoulette( } ); - if (profit > 0) { - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_gain", - amount: - profit - } - ); - } - - else if (profit < 0) { - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_loss", - amount: - Math.abs(profit) - } - ); - } - const resultEmbed = new EmbedBuilder() .setTitle( diff --git a/utils/crumbSlots.js b/utils/crumbSlots.js index 7da2072..7532691 100644 --- a/utils/crumbSlots.js +++ b/utils/crumbSlots.js @@ -13,7 +13,7 @@ const { } = require("./cookieEconomy"); const { - trackProfileEvent + recordGameOutcome } = require("./profileSystem"); const SLOT_BETS = [ @@ -308,12 +308,10 @@ async function playSlots( const profit = payout - bet; - await trackProfileEvent( + await recordGameOutcome( interaction, playerId, { - type: - "game_result", game: "slots", result: @@ -331,32 +329,6 @@ async function playSlots( } ); - if (profit > 0) { - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_gain", - amount: - profit - } - ); - } - - else if (profit < 0) { - await trackProfileEvent( - interaction, - playerId, - { - type: - "cookie_loss", - amount: - Math.abs(profit) - } - ); - } - const resultEmbed = new EmbedBuilder() .setTitle( diff --git a/utils/permissions.js b/utils/permissions.js new file mode 100644 index 0000000..bc299eb --- /dev/null +++ b/utils/permissions.js @@ -0,0 +1,216 @@ +const { + PermissionFlagsBits, + MessageFlags +} = require( + "discord.js" +); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + * + * .env: + * + * BOT_AUTHOR_ID=123456789012345678 + * + * Do not hard-code the author ID into commands. + */ + +function getBotAuthorId() { + return ( + process.env.BOT_AUTHOR_ID + ?? + null + ); +} + + +/* + * ============================================================ + * BOT AUTHOR + * ============================================================ + */ + +function isBotAuthor( + userId +) { + const authorId = + getBotAuthorId(); + + + if ( + !authorId + || + !userId + ) { + return false; + } + + + return ( + String( + userId + ) + === + String( + authorId + ) + ); +} + + +/* + * ============================================================ + * GUILD ADMINISTRATOR + * ============================================================ + */ + +function isAdministrator( + interaction +) { + if ( + !interaction + ?.memberPermissions + ) { + return false; + } + + + return interaction + .memberPermissions + .has( + PermissionFlagsBits.Administrator + ); +} + + +/* + * ============================================================ + * ADMIN COMMAND ACCESS + * ============================================================ + * + * Access is granted when: + * + * - the user is the configured bot author + * OR + * - the user has Discord Administrator + * + * Bot author is checked first so the owner can use admin + * utilities even without Discord Administrator in the guild. + */ + +function canUseAdminCommands( + interaction +) { + if ( + !interaction + ?.user + ?.id + ) { + return false; + } + + + return ( + isBotAuthor( + interaction.user.id + ) + || + isAdministrator( + interaction + ) + ); +} + + +/* + * ============================================================ + * STANDARD ACCESS DENIAL + * ============================================================ + * + * Administrative access failures are intentionally ephemeral. + */ + +async function denyAdminAccess( + interaction, + message = + "You don't have permission to use this command." +) { + const payload = { + content: + message, + + flags: + MessageFlags.Ephemeral + }; + + + if ( + interaction.replied + || + interaction.deferred + ) { + return interaction.followUp( + payload + ); + } + + + return interaction.reply( + payload + ); +} + + +/* + * ============================================================ + * REQUIRE ADMIN ACCESS + * ============================================================ + * + * Convenience helper: + * + * if (!await requireAdminAccess(interaction)) { + * return; + * } + */ + +async function requireAdminAccess( + interaction +) { + if ( + canUseAdminCommands( + interaction + ) + ) { + return true; + } + + + await denyAdminAccess( + interaction + ); + + + return false; +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + getBotAuthorId, + + isBotAuthor, + isAdministrator, + + canUseAdminCommands, + requireAdminAccess, + + denyAdminAccess +}; \ No newline at end of file diff --git a/utils/profileMaintenance.js b/utils/profileMaintenance.js new file mode 100644 index 0000000..4fb5f11 --- /dev/null +++ b/utils/profileMaintenance.js @@ -0,0 +1,403 @@ +const { + getProfileCollections, + getProfileStats, + readStatValue +} = require("./profileSystem"); + +const { + getUnixTime +} = require("./cookieEconomy"); + +function getActiveDefinitions( + achievementConfig +) { + return achievementConfig + .find({ + active: { + $ne: false + } + }) + .toArray(); +} + +async function reconcileUserAchievements({ + guildId, + userId, + dryRun = false +}) { + const { + achievements, + achievementConfig + } = getProfileCollections(); + + const [ + statsDocument, + definitions, + achievementDocument + ] = await Promise.all([ + getProfileStats( + userId, + guildId + ), + getActiveDefinitions( + achievementConfig + ), + achievements.findOne({ + userId, + guildId + }) + ]); + + const unlocked = { + ...(achievementDocument?.unlocked ?? {}) + }; + + const additions = {}; + let skippedEventOnly = 0; + + const normalDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + for (const definition of normalDefinitions) { + if (unlocked[definition.achievementId]) { + continue; + } + + if (!definition.stat) { + skippedEventOnly++; + continue; + } + + const currentValue = + readStatValue( + statsDocument, + definition + ); + + if ( + currentValue < + Number(definition.target ?? 0) + ) { + continue; + } + + const timestamp = + getUnixTime(); + + additions[ + definition.achievementId + ] = timestamp; + + unlocked[ + definition.achievementId + ] = timestamp; + } + + const collectibleDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + const unlockedCollectible = + collectibleDefinitions.filter( + definition => + Boolean( + unlocked[ + definition.achievementId + ] + ) + ).length; + + const percentage = + collectibleDefinitions.length > 0 + ? unlockedCollectible / + collectibleDefinitions.length + : 0; + + for ( + const definition + of definitions.filter( + item => + item.measure === + "achievementPercentage" + ) + ) { + if ( + unlocked[ + definition.achievementId + ] + ) { + continue; + } + + if ( + percentage < + Number(definition.target ?? 0) + ) { + continue; + } + + additions[ + definition.achievementId + ] = getUnixTime(); + } + + const additionEntries = + Object.entries(additions); + + if ( + !dryRun && + additionEntries.length > 0 + ) { + const set = {}; + + for ( + const [achievementId, timestamp] + of additionEntries + ) { + set[ + `unlocked.${achievementId}` + ] = timestamp; + } + + await achievements.updateOne( + { + userId, + guildId + }, + { + $setOnInsert: { + userId, + guildId + }, + $set: set + }, + { + upsert: true + } + ); + } + + return { + userId, + checked: + definitions.length, + unlocked: + additionEntries.length, + skippedEventOnly, + dryRun, + achievementIds: + additionEntries.map( + ([achievementId]) => + achievementId + ) + }; +} + +async function reconcileAllAchievements({ + guildId, + dryRun = false +}) { + const { + stats + } = getProfileCollections(); + + const userIds = + await stats.distinct( + "userId", + { + guildId + } + ); + + const summary = { + users: + userIds.length, + unlocked: + 0, + skippedEventOnly: + 0, + results: [] + }; + + for (const userId of userIds) { + const result = + await reconcileUserAchievements({ + guildId, + userId, + dryRun + }); + + summary.unlocked += + result.unlocked; + + summary.skippedEventOnly += + result.skippedEventOnly; + + summary.results.push( + result + ); + } + + return summary; +} + +async function auditProfileData( + guildId +) { + const { + stats, + achievements, + achievementConfig, + badges + } = getProfileCollections(); + + const [ + statDocuments, + achievementDocuments, + activeAchievements, + badgeDocuments + ] = await Promise.all([ + stats.find({ + guildId + }).toArray(), + achievements.find({ + guildId + }).toArray(), + achievementConfig.countDocuments({ + active: { + $ne: false + } + }), + badges.find({ + guildId + }).toArray() + ]); + + const issues = []; + + for (const document of statDocuments) { + if (!document.userId) { + issues.push( + "Stats document missing userId." + ); + continue; + } + + const total = + document.games?.total ?? {}; + + const played = + Number(total.played ?? 0); + + const resolved = + Number(total.won ?? 0) + + Number(total.lost ?? 0) + + Number(total.pushed ?? 0); + + if (played !== resolved) { + issues.push( + `${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).` + ); + } + + for ( + const game + of [ + "slots", + "blackjack", + "roulette", + "heist" + ] + ) { + const value = + document.games?.[game] ?? {}; + + const gamePlayed = + Number(value.played ?? 0); + + const gameResolved = + Number(value.won ?? 0) + + Number(value.lost ?? 0) + + Number(value.pushed ?? 0); + + if ( + gamePlayed !== + gameResolved + ) { + issues.push( + `${document.userId}: games.${game}.played (${gamePlayed}) does not match W/L/P total (${gameResolved}).` + ); + } + } + + const nonNegativeFields = [ + document.economy?.cookiesObtainedTotal, + document.economy?.cookiesLostTotal, + document.economy?.cookiesGivenTotal, + document.economy?.stolenTotal, + document.economy?.stolenFromMeTotal, + document.economy?.vaultStolenTotal + ]; + + if ( + nonNegativeFields.some( + value => + Number(value ?? 0) < 0 + ) + ) { + issues.push( + `${document.userId}: one or more economy counters are negative.` + ); + } + } + + const statUsers = + new Set( + statDocuments.map( + document => + document.userId + ) + ); + + const orphanAchievementDocs = + achievementDocuments.filter( + document => + !statUsers.has( + document.userId + ) + ).length; + + const claimedBadges = + badgeDocuments.filter( + badge => + Boolean(badge.ownerId) + ).length; + + return { + statDocuments: + statDocuments.length, + achievementDocuments: + achievementDocuments.length, + activeAchievements, + badgeDocuments: + badgeDocuments.length, + claimedBadges, + orphanAchievementDocs, + issueCount: + issues.length, + issues + }; +} + +module.exports = { + reconcileUserAchievements, + reconcileAllAchievements, + auditProfileData +}; diff --git a/utils/profileRecords.js b/utils/profileRecords.js new file mode 100644 index 0000000..1440ead --- /dev/null +++ b/utils/profileRecords.js @@ -0,0 +1,225 @@ +const { + EmbedBuilder +} = require("discord.js"); + +const { + getProfileCollections +} = require("./profileSystem"); + +const { + getCollections +} = require("./cookieEconomy"); + +const RECORDS = { + balance: { + label: + "๐Ÿช Current Balance", + source: + "cookies", + field: + "cookies", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + }, + obtained: { + label: + "๐Ÿช Lifetime Cookies Obtained", + field: + "economy.cookiesObtainedTotal", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + }, + given: { + label: + "๐ŸŽ Cookies Given", + field: + "economy.cookiesGivenTotal", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + }, + stolen: { + label: + "๐Ÿฅท Cookies Stolen", + field: + "economy.stolenTotal", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + }, + vault: { + label: + "๐Ÿฆ Bot Vault Stolen", + field: + "economy.vaultStolenTotal", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + }, + wins: { + label: + "๐Ÿ† Games Won", + field: + "games.total.won", + format: + value => + Number(value ?? 0).toLocaleString() + }, + played: { + label: + "๐ŸŽฎ Games Played", + field: + "games.total.played", + format: + value => + Number(value ?? 0).toLocaleString() + }, + streak: { + label: + "๐Ÿ”ฅ Best Win Streak", + field: + "streaks.bestWin", + format: + value => + Number(value ?? 0).toLocaleString() + }, + lost: { + label: + "๐Ÿ’ธ Lifetime Cookies Lost", + field: + "economy.cookiesLostTotal", + format: + value => + `${Number(value ?? 0).toLocaleString()} ๐Ÿช` + } +}; + +function getNestedValue( + document, + path +) { + return path + .split(".") + .reduce( + (value, key) => + value?.[key], + document + ); +} + +async function buildRecordsEmbed({ + guildId, + category, + botUserId, + limit = 10 +}) { + const definition = + RECORDS[category] ?? + RECORDS.balance; + + let rows; + + if ( + definition.source === + "cookies" + ) { + const { + cookies + } = getCollections(); + + rows = + await cookies + .find({ + userId: { + $ne: botUserId + }, + cookies: { + $gt: 0 + } + }) + .sort({ + cookies: -1 + }) + .limit(limit) + .toArray(); + } + + else { + const { + stats + } = getProfileCollections(); + + rows = + await stats + .find({ + guildId, + userId: { + $ne: botUserId + }, + [definition.field]: { + $gt: 0 + } + }) + .sort({ + [definition.field]: -1 + }) + .limit(limit) + .toArray(); + } + + const medals = [ + "๐Ÿฅ‡", + "๐Ÿฅˆ", + "๐Ÿฅ‰" + ]; + + const description = + rows.length > 0 + ? rows.map( + (row, index) => { + const value = + getNestedValue( + row, + definition.field + ); + + const rank = + medals[index] ?? + `**${index + 1}.**`; + + return `${rank} <@${row.userId}> โ€” **${definition.format(value)}**`; + } + ).join("\n") + : "No qualifying records yet."; + + return new EmbedBuilder() + .setTitle( + `๐Ÿ“š Records โ€” ${definition.label}` + ) + .setDescription( + description + ) + .setFooter({ + text: + "Records are derived from persisted profile statistics." + }) + .setTimestamp(); +} + +function getRecordChoices() { + return Object.entries(RECORDS) + .map( + ([value, definition]) => ({ + name: + definition.label, + value + }) + ); +} + +module.exports = { + buildRecordsEmbed, + getRecordChoices +}; diff --git a/utils/profileSystem.js b/utils/profileSystem.js index f790e9b..2bccbd9 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -7,7 +7,17 @@ const { } = require("discord.js"); const { mClient } = require("../index"); -const { getCookieBalance, getUnixTime } = require("./cookieEconomy"); +const { + getCookieBalance, + getUnixTime +} = require("./cookieEconomy"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ const DATABASE_NAME = process.env.M_DB; @@ -18,6 +28,15 @@ const COLLECTIONS = { achievementConfig: "config_achievements" }; +const PROFILE_PAGE_CHARACTER_LIMIT = 3500; + + +/* + * ============================================================ + * COLLECTION ACCESS + * ============================================================ + */ + function getProfileCollections() { if (!DATABASE_NAME) { throw new Error("M_DB is not configured in .env"); @@ -34,6 +53,13 @@ function getProfileCollections() { }; } + +/* + * ============================================================ + * INDEXES + * ============================================================ + */ + async function ensureProfileSystem() { const { stats, @@ -63,6 +89,13 @@ async function ensureProfileSystem() { ); } + +/* + * ============================================================ + * DEFAULT STATS + * ============================================================ + */ + function getDefaultStats(userId, guildId) { return { userId, @@ -136,6 +169,13 @@ function getDefaultStats(userId, guildId) { }; } + +/* + * ============================================================ + * ENSURE DOCUMENTS + * ============================================================ + */ + async function ensureStatsDocument(userId, guildId) { const { stats } = getProfileCollections(); @@ -151,6 +191,7 @@ async function ensureStatsDocument(userId, guildId) { ); } + async function ensureAchievementDocument(userId, guildId) { const { achievements } = getProfileCollections(); @@ -167,18 +208,34 @@ async function ensureAchievementDocument(userId, guildId) { ); } + +/* + * ============================================================ + * GET PROFILE STATS + * ============================================================ + */ + async function getProfileStats(userId, guildId) { const { stats } = getProfileCollections(); - return await stats.findOne({ + const existing = await stats.findOne({ userId, guildId - }) ?? getDefaultStats( + }); + + return existing ?? getDefaultStats( userId, guildId ); } + +/* + * ============================================================ + * PROFILE EVENTS + * ============================================================ + */ + async function trackProfileEvent( interaction, userId, @@ -186,11 +243,7 @@ async function trackProfileEvent( ) { const guildId = interaction.guildId; - if ( - !guildId || - !userId || - !event?.type - ) { + if (!guildId || !userId || !event?.type) { return; } @@ -208,7 +261,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesObtainedTotal": - event.amount + Number(event.amount ?? 0) } } ); @@ -220,7 +273,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesLostTotal": - event.amount + Number(event.amount ?? 0) } } ); @@ -232,7 +285,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesGivenTotal": - event.amount + Number(event.amount ?? 0) } } ); @@ -244,8 +297,9 @@ async function trackProfileEvent( { $inc: { "economy.stolenTotal": - event.amount + Number(event.amount ?? 0) }, + $addToSet: { "unique.playersStolenFrom": event.targetUserId @@ -260,8 +314,9 @@ async function trackProfileEvent( { $inc: { "economy.stolenFromMeTotal": - event.amount + Number(event.amount ?? 0) }, + $addToSet: { "unique.playersStolenBy": event.thiefUserId @@ -276,7 +331,7 @@ async function trackProfileEvent( { $inc: { "economy.vaultStolenTotal": - event.amount + Number(event.amount ?? 0) } } ); @@ -294,11 +349,10 @@ async function trackProfileEvent( return; } - const updatedStats = - await getProfileStats( - userId, - guildId - ); + const updatedStats = await getProfileStats( + userId, + guildId + ); await evaluateAchievements( interaction, @@ -316,6 +370,120 @@ async function trackProfileEvent( return updatedStats; } + +/* + * ============================================================ + * CENTRAL GAME RESULT + * ============================================================ + */ + +async function recordGameOutcome( + interaction, + userId, + { + game, + result, + profit = 0, + ...metadata + } +) { + const guildId = interaction.guildId; + + if (!guildId || !userId) { + return; + } + + const normalizedProfit = Number( + profit ?? 0 + ); + + await ensureStatsDocument( + userId, + guildId + ); + + const { stats } = getProfileCollections(); + + /* + * One authoritative game event. + */ + await trackGameResult( + userId, + guildId, + { + game, + result, + profit: normalizedProfit, + ...metadata + } + ); + + /* + * Global economy totals. + * + * Games using recordGameOutcome() should NOT also send + * cookie_gain / cookie_loss events for the same result. + */ + if (normalizedProfit > 0) { + await stats.updateOne( + { userId, guildId }, + { + $inc: { + "economy.cookiesObtainedTotal": + normalizedProfit + } + } + ); + } + + else if (normalizedProfit < 0) { + await stats.updateOne( + { userId, guildId }, + { + $inc: { + "economy.cookiesLostTotal": + Math.abs(normalizedProfit) + } + } + ); + } + + const updatedStats = await getProfileStats( + userId, + guildId + ); + + const event = { + type: "game_result", + game, + result, + profit: normalizedProfit, + ...metadata + }; + + await evaluateAchievements( + interaction, + userId, + updatedStats + ); + + await evaluateBadges( + interaction, + userId, + updatedStats, + event + ); + + return updatedStats; +} + + +/* + * ============================================================ + * GAME STATS + * ============================================================ + */ + async function trackGameResult( userId, guildId, @@ -357,6 +525,7 @@ async function trackGameResult( increment["games.total.won"] = 1; increment[`games.${game}.won`] = 1; increment["streaks.currentWin"] = 1; + set["streaks.currentLoss"] = 0; } @@ -364,6 +533,7 @@ async function trackGameResult( increment["games.total.lost"] = 1; increment[`games.${game}.lost`] = 1; increment["streaks.currentLoss"] = 1; + set["streaks.currentWin"] = 0; } @@ -410,17 +580,22 @@ async function trackGameResult( { $max: { "streaks.bestWin": - current?.streaks - ?.currentWin ?? 0, + current?.streaks?.currentWin ?? 0, "streaks.bestLoss": - current?.streaks - ?.currentLoss ?? 0 + current?.streaks?.currentLoss ?? 0 } } ); } + +/* + * ============================================================ + * ACHIEVEMENT EVALUATION + * ============================================================ + */ + async function evaluateAchievements( interaction, userId, @@ -428,6 +603,10 @@ async function evaluateAchievements( ) { const guildId = interaction.guildId; + if (!guildId) { + return; + } + const { achievements, achievementConfig @@ -447,15 +626,18 @@ async function evaluateAchievements( }) .toArray(); - let unlockedDoc = + let unlockedDocument = await achievements.findOne({ userId, guildId }); let unlocked = - unlockedDoc?.unlocked ?? {}; + unlockedDocument?.unlocked ?? {}; + /* + * Normal DB-backed achievements first. + */ const normalDefinitions = definitions.filter( definition => @@ -464,15 +646,26 @@ async function evaluateAchievements( ); for (const definition of normalDefinitions) { - if (unlocked[definition.achievementId]) { + if ( + unlocked[ + definition.achievementId + ] + ) { continue; } - const currentValue = - readStatValue( - statsDocument, - definition - ); + /* + * Event-only achievements do not have a persisted + * stat path and are intentionally skipped here. + */ + if (!definition.stat) { + continue; + } + + const currentValue = readStatValue( + statsDocument, + definition + ); if ( currentValue < @@ -496,15 +689,21 @@ async function evaluateAchievements( } } - unlockedDoc = + /* + * Refresh after normal unlocks. + */ + unlockedDocument = await achievements.findOne({ userId, guildId }); unlocked = - unlockedDoc?.unlocked ?? {}; + unlockedDocument?.unlocked ?? {}; + /* + * Collector achievements. + */ const collectorDefinitions = definitions.filter( definition => @@ -532,19 +731,23 @@ async function evaluateAchievements( ) ).length; - const achievementPercentage = + const percentage = totalCollectible > 0 ? unlockedCollectible / totalCollectible : 0; for (const definition of collectorDefinitions) { - if (unlocked[definition.achievementId]) { + if ( + unlocked[ + definition.achievementId + ] + ) { continue; } if ( - achievementPercentage < + percentage < Number(definition.target) ) { continue; @@ -566,6 +769,13 @@ async function evaluateAchievements( } } + +/* + * ============================================================ + * UNLOCK ACHIEVEMENT + * ============================================================ + */ + async function unlockAchievement( interaction, userId, @@ -583,6 +793,7 @@ async function unlockAchievement( { userId, guildId, + [field]: { $exists: false } @@ -607,6 +818,13 @@ async function unlockAchievement( return true; } + +/* + * ============================================================ + * STAT READER + * ============================================================ + */ + function readStatValue( document, definition @@ -633,32 +851,80 @@ function readStatValue( : 0; } - return Number(value ?? 0); + return Number( + value ?? 0 + ); } + +/* + * ============================================================ + * ACHIEVEMENT NOTIFICATION + * ============================================================ + * + * Public by default. + */ + async function sendAchievementNotification( interaction, achievement ) { + const embed = + new EmbedBuilder() + .setTitle( + "๐Ÿ† Achievement Unlocked!" + ) + .setDescription( + [ + `${achievement.emoji ?? "๐Ÿ†"} **${achievement.name}**`, + "", + achievement.description + ?? + achievement.requirement + ?? + "Achievement unlocked." + ].join("\n") + ) + .setTimestamp(); + try { - await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle( - "๐Ÿ† Achievement Unlocked!" - ) - .setDescription( - [ - `${achievement.emoji} **${achievement.name}**`, - "", - achievement.description - ].join("\n") - ) - ] - }); - } catch {} + if ( + interaction.replied + || + interaction.deferred + ) { + await interaction.followUp({ + embeds: [embed] + }); + + return; + } + + /* + * Do not consume the primary interaction reply. + */ + if (interaction.channel) { + await interaction.channel.send({ + embeds: [embed] + }); + } + } + + catch (error) { + console.error( + `[Achievement] Failed to announce ${achievement.achievementId}:`, + error + ); + } } + +/* + * ============================================================ + * BADGE EVALUATION + * ============================================================ + */ + async function evaluateBadges( interaction, userId, @@ -676,6 +942,9 @@ async function evaluateBadges( userId ); + /* + * Economy badges. + */ if (balance >= 100000) { await tryClaimBadge( interaction, @@ -692,12 +961,19 @@ async function evaluateBadges( ); } + /* + * Vault milestone. + */ if ( ( statsDocument ?.economy - ?.vaultStolenTotal ?? 0 - ) >= 100000 + ?.vaultStolenTotal + ?? + 0 + ) + >= + 100000 ) { await tryClaimBadge( interaction, @@ -706,13 +982,17 @@ async function evaluateBadges( ); } - const uniquePlayersStolenFrom = - statsDocument - ?.unique - ?.playersStolenFrom ?? []; - + /* + * Unique player theft milestones. + */ const uniqueStealCount = - uniquePlayersStolenFrom.length; + ( + statsDocument + ?.unique + ?.playersStolenFrom + ?? + [] + ).length; if (uniqueStealCount >= 5) { await tryClaimBadge( @@ -738,11 +1018,16 @@ async function evaluateBadges( ); } + /* + * Slots jackpot. + */ if ( event.type === - "game_result" && + "game_result" + && event.game === - "slots" && + "slots" + && event.jackpot === true ) { @@ -753,13 +1038,19 @@ async function evaluateBadges( ); } + /* + * Natural Blackjack. + */ if ( event.type === - "game_result" && + "game_result" + && event.game === - "blackjack" && + "blackjack" + && event.natural === - true && + true + && event.result === "win" ) { @@ -770,13 +1061,19 @@ async function evaluateBadges( ); } + /* + * Blackjack All-In win. + */ if ( event.type === - "game_result" && + "game_result" + && event.game === - "blackjack" && + "blackjack" + && event.allIn === - true && + true + && event.result === "win" ) { @@ -787,13 +1084,19 @@ async function evaluateBadges( ); } + /* + * Roulette Zero. + */ if ( event.type === - "game_result" && + "game_result" + && event.game === - "roulette" && + "roulette" + && event.zero === - true && + true + && event.result === "win" ) { @@ -804,13 +1107,19 @@ async function evaluateBadges( ); } + /* + * Roulette All-In. + */ if ( event.type === - "game_result" && + "game_result" + && event.game === - "roulette" && + "roulette" + && event.allIn === - true && + true + && event.result === "win" ) { @@ -827,6 +1136,13 @@ async function evaluateBadges( ); } + +/* + * ============================================================ + * COLLECTOR BADGES + * ============================================================ + */ + async function evaluateCollectorBadges( interaction, userId @@ -844,6 +1160,7 @@ async function evaluateCollectorBadges( active: { $ne: false }, + measure: { $ne: "achievementPercentage" @@ -889,6 +1206,7 @@ async function evaluateCollectorBadges( { unlockedAchievements: unlockedCount, + availableAchievements: activeAchievements.length } @@ -903,6 +1221,7 @@ async function evaluateCollectorBadges( { unlockedAchievements: unlockedCount, + availableAchievements: activeAchievements.length } @@ -910,6 +1229,13 @@ async function evaluateCollectorBadges( } } + +/* + * ============================================================ + * CLAIM GLOBAL BADGE + * ============================================================ + */ + async function tryClaimBadge( interaction, userId, @@ -931,7 +1257,8 @@ async function tryClaimBadge( }; if ( - metadata && + metadata + && typeof metadata === "object" ) { @@ -955,7 +1282,8 @@ async function tryClaimBadge( ); if ( - !badge || + !badge + || badge.ownerId !== userId ) { @@ -967,28 +1295,31 @@ async function tryClaimBadge( "", `<@${userId}> claimed a guild-unique badge!`, "", - badge.description ?? - "A unique badge has been claimed.", - "", - "*This badge can no longer be earned by another player.*" + badge.description + ?? + "A unique badge has been claimed.", + "" ]; if ( badge.metadata ?.unlockedAchievements !== - undefined && + undefined + && badge.metadata ?.availableAchievements !== undefined ) { - description.splice( - description.length - 1, - 0, + description.push( `Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`, "" ); } + description.push( + "*This badge can no longer be earned by another player.*" + ); + try { await interaction.channel.send({ embeds: [ @@ -1006,7 +1337,9 @@ async function tryClaimBadge( .setTimestamp() ] }); - } catch (error) { + } + + catch (error) { console.error( `[Badge] Failed to announce ${badgeId} for ${userId}:`, error @@ -1016,10 +1349,335 @@ async function tryClaimBadge( return true; } + +/* + * ============================================================ + * PROFILE FOOTER / SESSION STATE + * ============================================================ + * + * profile:::
:: + */ + +function buildProfileFooter( + sessionOwnerId, + profileUserId, + section = "overview", + page = 0, + pageCount = 1 +) { + return [ + "profile", + sessionOwnerId, + profileUserId, + section, + page, + pageCount + ].join(":"); +} + + +function parseProfileFooter( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + const parts = + footer?.split(":") ?? []; + + if ( + parts[0] !== + "profile" + ) { + return null; + } + + /* + * Current footer. + */ + if ( + parts.length >= + 6 + ) { + return { + sessionOwnerId: + parts[1], + + profileUserId: + parts[2], + + section: + parts[3], + + page: + Number(parts[4]) || 0, + + pageCount: + Number(parts[5]) || 1 + }; + } + + /* + * Legacy footer: + * + * profile: + */ + if ( + parts.length >= + 2 + ) { + return { + sessionOwnerId: + parts[1], + + profileUserId: + parts[1], + + section: + "overview", + + page: + 0, + + pageCount: + 1 + }; + } + + return null; +} + + +function getProfileTargetId( + interaction +) { + return ( + parseProfileFooter( + interaction + )?.profileUserId + ?? + null + ); +} + + +function getProfileSessionOwnerId( + interaction +) { + return ( + parseProfileFooter( + interaction + )?.sessionOwnerId + ?? + null + ); +} + + +/* + * ============================================================ + * PROFILE OWNERSHIP + * ============================================================ + */ + +async function requireProfileOwner( + interaction +) { + const session = + parseProfileFooter( + interaction + ); + + if (!session) { + await temporaryProfileError( + interaction, + "This profile session is no longer valid." + ); + + return null; + } + + if ( + session.sessionOwnerId !== + interaction.user.id + ) { + await temporaryProfileError( + interaction, + "This isn't your profile session." + ); + + return null; + } + + return session; +} + + +async function temporaryProfileError( + interaction, + content +) { + const payload = { + content, + + flags: + MessageFlags.Ephemeral + }; + + if ( + interaction.replied + || + interaction.deferred + ) { + return interaction.followUp( + payload + ); + } + + return interaction.reply( + payload + ); +} + + +/* + * ============================================================ + * 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( + "profileAchievements" + ) + .setLabel( + "Achievements" + ) + .setEmoji( + "๐Ÿ†" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileBadges" + ) + .setLabel( + "Badges" + ) + .setEmoji( + "๐Ÿ…" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileClose" + ) + .setLabel( + "Close" + ) + .setEmoji( + "โœ–๏ธ" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +function buildProfilePaginationButtons( + leftId, + rightId, + page, + pageCount +) { + return new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + leftId + ) + .setLabel( + "โ—„" + ) + .setStyle( + ButtonStyle.Secondary + ) + .setDisabled( + page <= 0 + ), + + new ButtonBuilder() + .setCustomId( + rightId + ) + .setLabel( + "โ–บ" + ) + .setStyle( + ButtonStyle.Secondary + ) + .setDisabled( + page >= + pageCount - 1 + ) + ); +} + + +/* + * ============================================================ + * PROFILE OVERVIEW + * ============================================================ + */ + async function buildProfileOverview( guildId, user, - sessionOwnerId = user.id + sessionOwnerId = + user.id ) { const stats = await getProfileStats( @@ -1039,32 +1697,57 @@ async function buildProfileOverview( } = getProfileCollections(); const [ - achievementDoc, - achievementCount, + achievementDocument, + activeDefinitions, ownedBadges ] = await Promise.all([ achievements.findOne({ - userId: user.id, + userId: + user.id, + guildId }), - achievementConfig.countDocuments({ - active: { - $ne: false - } - }), + achievementConfig + .find({ + active: { + $ne: false + } + }) + .toArray(), badges.find({ guildId, - ownerId: user.id + + ownerId: + user.id }).toArray() ]); + const unlocked = + achievementDocument?.unlocked ?? {}; + const unlockedCount = - Object.keys( - achievementDoc?.unlocked ?? {} + activeDefinitions.filter( + achievement => + Boolean( + unlocked[ + achievement.achievementId + ] + ) ).length; + const achievementCount = + activeDefinitions.length; + + const completion = + achievementCount > 0 + ? ( + unlockedCount / + achievementCount + ) * 100 + : 0; + return new EmbedBuilder() .setTitle( `๐Ÿ‘ค ${user.globalName ?? user.username}'s Profile` @@ -1076,40 +1759,66 @@ async function buildProfileOverview( ) .addFields( { - name: "๐Ÿช Cookies", + name: + "๐Ÿช Cookies", + value: balance.toLocaleString(), - inline: true + + inline: + true }, + { - name: "๐Ÿ† Achievements", + name: + "๐Ÿ† Achievements", + value: - `${unlockedCount} / ${achievementCount}`, - inline: true + `${unlockedCount} / ${achievementCount}\n**${completion.toFixed(1)}%**`, + + inline: + true }, + { - name: "๐Ÿ… Unique Badges", + name: + "๐Ÿ… Unique Badges", + value: `${ownedBadges.length}`, - inline: true + + 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: + "๐ŸŽฎ Games", + + value: + [ + `Played: **${stats.games?.total?.played ?? 0}**`, + `Won: **${stats.games?.total?.won ?? 0}**`, + `Lost: **${stats.games?.total?.lost ?? 0}**`, + `Pushes: **${stats.games?.total?.pushed ?? 0}**` + ].join("\n"), + + inline: + true }, + { - name: "๐Ÿ”ฅ Best Streaks", - value: [ - `Wins: **${stats.streaks.bestWin}**`, - `Losses: **${stats.streaks.bestLoss}**` - ].join("\n"), - inline: true + name: + "๐Ÿ”ฅ Best Streaks", + + value: + [ + `Wins: **${stats.streaks?.bestWin ?? 0}**`, + `Losses: **${stats.streaks?.bestLoss ?? 0}**` + ].join("\n"), + + inline: + true } ) .setFooter({ @@ -1124,10 +1833,18 @@ async function buildProfileOverview( }); } -async function buildStatsEmbed( + +/* + * ============================================================ + * STATS PAGE + * ============================================================ + */ + +async function buildProfileStatsEmbed( guildId, user, - sessionOwnerId = user.id + sessionOwnerId = + user.id ) { const stats = await getProfileStats( @@ -1135,240 +1852,146 @@ async function buildStatsEmbed( guildId ); - const gameLines = [ - ["๐ŸŽฐ Slots", stats.games.slots], - ["๐Ÿƒ Blackjack", stats.games.blackjack], - ["๐ŸŽก Roulette", stats.games.roulette], - ["๐Ÿฆ Heist", stats.games.heist] - ].map( - ([name, game]) => [ - `**${name}**`, - `Played ${game?.played ?? 0} โ€ข Won ${game?.won ?? 0} โ€ข Lost ${game?.lost ?? 0} โ€ข Push ${game?.pushed ?? 0}`, - `Net tracked: **+${(game?.cookiesWon ?? 0).toLocaleString()} / -${(game?.cookiesLost ?? 0).toLocaleString()} ๐Ÿช**` - ].join("\n") - ); + const embed = + new EmbedBuilder() + .setTitle( + `๐Ÿ“Š ${user.globalName ?? user.username}'s Stats` + ) + .setThumbnail( + user.displayAvatarURL({ + size: 256 + }) + ); - return new EmbedBuilder() - .setTitle( - `๐Ÿ“Š ${user.globalName ?? user.username}'s Stats` - ) - .setThumbnail( - user.displayAvatarURL({ - size: 256 - }) - ) - .addFields( - { - name: "๐Ÿช Economy", - value: [ - `Obtained: **${(stats.economy?.cookiesObtainedTotal ?? 0).toLocaleString()} ๐Ÿช**`, - `Lost: **${(stats.economy?.cookiesLostTotal ?? 0).toLocaleString()} ๐Ÿช**`, - `Given: **${(stats.economy?.cookiesGivenTotal ?? 0).toLocaleString()} ๐Ÿช**`, - `Stolen: **${(stats.economy?.stolenTotal ?? 0).toLocaleString()} ๐Ÿช**`, - `Stolen from you: **${(stats.economy?.stolenFromMeTotal ?? 0).toLocaleString()} ๐Ÿช**`, - `Vault stolen: **${(stats.economy?.vaultStolenTotal ?? 0).toLocaleString()} ๐Ÿช**` - ].join("\n") - }, - { - name: "๐Ÿฅท Unique Players", - value: [ - `Stolen from: **${stats.unique?.playersStolenFrom?.length ?? 0}**`, - `Stolen by: **${stats.unique?.playersStolenBy?.length ?? 0}**` - ].join("\n"), - inline: true - }, - { - name: "๐Ÿ”ฅ Streaks", - value: [ - `Current wins: **${stats.streaks?.currentWin ?? 0}**`, - `Best wins: **${stats.streaks?.bestWin ?? 0}**`, - `Current losses: **${stats.streaks?.currentLoss ?? 0}**`, - `Best losses: **${stats.streaks?.bestLoss ?? 0}**` - ].join("\n"), - inline: true - }, - { - name: "๐ŸŽฎ Per Game", - value: - gameLines.join("\n\n") - } - ) - .setFooter({ - text: - buildProfileFooter( - sessionOwnerId, - user.id, - "stats", - 0, - 1 - ) - }); -} + const games = [ + [ + "๐ŸŽฐ", + "Slots", + stats.games?.slots + ], -async function buildBadgesEmbed( - guildId, - user, - sessionOwnerId = user.id, - page = 0 -) { - const { badges } = - getProfileCollections(); + [ + "๐Ÿƒ", + "Blackjack", + stats.games?.blackjack + ], - const documents = - await badges.find({ - guildId - }).sort({ - category: 1, - name: 1 - }).toArray(); + [ + "๐ŸŽก", + "Roulette", + stats.games?.roulette + ], - const owned = - documents.filter( - badge => - badge.ownerId === - user.id - ); - - const pages = []; - - pages.push({ - title: "Owned", - content: - owned.length - ? owned.map( - badge => - formatBadgeLine( - badge, - user.id, - true - ) - ).join("\n\n") - : "*No unique badges yet.*" - }); - - const categoryOrder = [ - "economy", - "stealing", - "slots", - "blackjack", - "roulette", - "heist", - "collector" + [ + "๐Ÿฆ", + "Heist", + stats.games?.heist + ] ]; - const categories = - [...new Set( - documents.map( - badge => - badge.category ?? - "other" - ) - )].sort( - (a, b) => { - const ai = - categoryOrder.indexOf(a); - const bi = - categoryOrder.indexOf(b); + for ( + const [ + emoji, + name, + game + ] + of games + ) { + const played = + game?.played ?? 0; - if ( - ai === -1 && - bi === -1 - ) { - return a.localeCompare(b); - } + const won = + game?.won ?? 0; - if (ai === -1) { - return 1; - } + const lost = + game?.lost ?? 0; - if (bi === -1) { - return -1; - } + const pushed = + game?.pushed ?? 0; - return ai - bi; - } - ); + const cookiesWon = + game?.cookiesWon ?? 0; - for (const category of categories) { - const categoryBadges = - documents.filter( - badge => - (badge.category ?? "other") === - category - ); + const cookiesLost = + game?.cookiesLost ?? 0; - if (!categoryBadges.length) { - continue; - } + const net = + cookiesWon - + cookiesLost; - const chunks = - paginateLines( - categoryBadges.map( - badge => - formatBadgeLine( - badge, - user.id, - false - ) - ), - 3200, - "\n\n" - ); + const decidedGames = + won + + lost; - for ( - let index = 0; - index < chunks.length; - index++ - ) { - pages.push({ - title: - `${formatCategoryName(category)}${chunks.length > 1 ? ` ${index + 1}/${chunks.length}` : ""}`, - content: - chunks[index] - }); - } + const winRate = + decidedGames > 0 + ? ( + won / + decidedGames + ) * 100 + : 0; + + embed.addFields({ + name: + `${emoji} ${name}`, + + value: + [ + `Played: **${played.toLocaleString()}**`, + `W / L / P: **${won} / ${lost} / ${pushed}**`, + `Win Rate: **${winRate.toFixed(1)}%**`, + `Won: **${cookiesWon.toLocaleString()} ๐Ÿช**`, + `Lost: **${cookiesLost.toLocaleString()} ๐Ÿช**`, + `Net: **${net >= 0 ? "+" : ""}${net.toLocaleString()} ๐Ÿช**` + ].join("\n"), + + inline: + true + }); } - const safePage = - clampPage( - page, - pages.length - ); + embed.addFields({ + name: + "๐Ÿช Economy", - const selected = - pages[safePage] ?? { - title: "Badges", - content: - "No badges configured." - }; - - return new EmbedBuilder() - .setTitle( - `๐Ÿ… ${user.globalName ?? user.username}'s Badges` - ) - .setDescription( + value: [ - `### ${selected.title}`, - selected.content + `Obtained: **${(stats.economy?.cookiesObtainedTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Lost: **${(stats.economy?.cookiesLostTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Given: **${(stats.economy?.cookiesGivenTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Stolen: **${(stats.economy?.stolenTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Stolen from you: **${(stats.economy?.stolenFromMeTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Vault stolen: **${(stats.economy?.vaultStolenTotal ?? 0).toLocaleString()} ๐Ÿช**` ].join("\n") - ) - .setFooter({ - text: - buildProfileFooter( - sessionOwnerId, - user.id, - "badges", - safePage, - pages.length - ) - }); + }); + + embed.setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "stats", + 0, + 1 + ) + }); + + return embed; } + +/* + * ============================================================ + * ACHIEVEMENTS PAGE + * ============================================================ + */ + async function buildAchievementsEmbed( guildId, user, - sessionOwnerId = user.id, - page = 0 + page = 0, + sessionOwnerId = + user.id ) { const { achievements, @@ -1376,11 +1999,13 @@ async function buildAchievementsEmbed( } = getProfileCollections(); const [ - unlockedDoc, + unlockedDocument, definitions ] = await Promise.all([ achievements.findOne({ - userId: user.id, + userId: + user.id, + guildId }), @@ -1399,45 +2024,84 @@ async function buildAchievementsEmbed( ]); const unlocked = - unlockedDoc?.unlocked ?? {}; + unlockedDocument?.unlocked ?? {}; - const lines = - definitions.map( - achievement => { - const complete = - Boolean( - unlocked[ - achievement.achievementId - ] - ); + const lines = []; - return [ - complete ? "โœ…" : "โฌœ", - " ", - achievement.emoji ?? "๐Ÿ†", - " **", - achievement.name, - "** โ€” ", - achievement.requirement ?? - achievement.description ?? - "Requirement unavailable." - ].join(""); - } + let lastCategory = null; + + for (const achievement of definitions) { + const complete = + Boolean( + unlocked[ + achievement.achievementId + ] + ); + + const category = + achievement.category + ?? + "other"; + + if ( + category !== + lastCategory + ) { + lines.push( + `### ${formatCategoryName(category)}` + ); + + lastCategory = + category; + } + + const hidden = + achievement.secret === + true + && + !complete; + + if (hidden) { + lines.push( + "โฌœ โ“ **???** โ€” Hidden achievement." + ); + + continue; + } + + lines.push( + [ + complete + ? "โœ…" + : "โฌœ", + + " ", + + achievement.emoji + ?? + "๐Ÿ†", + + " **", + + achievement.name, + + "** โ€” ", + + achievement.requirement + ?? + achievement.description + ?? + "Requirement unavailable." + ].join("") ); + } const pages = paginateLines( lines, - 3400, - "\n" + PROFILE_PAGE_CHARACTER_LIMIT ); - if (!pages.length) { - pages.push( - "No achievements configured." - ); - } - const safePage = clampPage( page, @@ -1454,417 +2118,297 @@ async function buildAchievementsEmbed( ) ).length; - return new EmbedBuilder() - .setTitle( - `๐Ÿ† ${user.globalName ?? user.username}'s Achievements` - ) - .setDescription( - pages[safePage] - ) - .addFields({ - name: "Progress", - value: - `**${unlockedCount} / ${definitions.length}** achievements unlocked` - }) - .setFooter({ - text: - buildProfileFooter( - sessionOwnerId, - user.id, - "achievements", - safePage, - pages.length - ) - }); -} - -function buildProfileButtons( - section = "overview", - page = 0, - pageCount = 1 -) { - const rows = [ - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId( - "profileOverview" - ) - .setLabel( - "Overview" - ) - .setEmoji( - "๐Ÿ‘ค" - ) - .setStyle( - section === "overview" - ? ButtonStyle.Primary - : ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - "profileStats" - ) - .setLabel( - "Stats" - ) - .setEmoji( - "๐Ÿ“Š" - ) - .setStyle( - section === "stats" - ? ButtonStyle.Primary - : ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - "profileBadges" - ) - .setLabel( - "Badges" - ) - .setEmoji( - "๐Ÿ…" - ) - .setStyle( - section === "badges" - ? ButtonStyle.Primary - : ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - "profileAchievements" - ) - .setLabel( - "Achievements" - ) - .setEmoji( - "๐Ÿ†" - ) - .setStyle( - section === "achievements" - ? ButtonStyle.Primary - : ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - "profileClose" - ) - .setLabel( - "Close" - ) - .setEmoji( - "โœ–๏ธ" - ) - .setStyle( - ButtonStyle.Danger - ) + const embed = + new EmbedBuilder() + .setTitle( + `๐Ÿ† ${user.globalName ?? user.username}'s Achievements` ) - ]; + .setDescription( + pages[safePage] + ?? + "No achievements configured." + ) + .addFields({ + name: + "Progress", - if ( - [ - "badges", - "achievements" - ].includes(section) && - pageCount > 1 - ) { - const prefix = - section === "badges" - ? "profileBadges" - : "profileAchievements"; - - rows.push( - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId( - `${prefix}Left` - ) - .setLabel( - "Previous" - ) - .setEmoji( - "โ—€๏ธ" - ) - .setDisabled( - page <= 0 - ) - .setStyle( - ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - `${prefix}Page` - ) - .setLabel( - `${page + 1} / ${pageCount}` - ) - .setDisabled( - true - ) - .setStyle( - ButtonStyle.Secondary - ), - - new ButtonBuilder() - .setCustomId( - `${prefix}Right` - ) - .setLabel( - "Next" - ) - .setEmoji( - "โ–ถ๏ธ" - ) - .setDisabled( - page >= - pageCount - 1 - ) - .setStyle( - ButtonStyle.Secondary - ) - ) - ); - } - - return rows; -} - -function buildProfileFooter( - sessionOwnerId, - profileUserId, - section, - page, - pageCount -) { - return [ - "profile", - sessionOwnerId, - profileUserId, - section, - page, - pageCount - ].join(":"); -} - -function getProfileContext( - interaction -) { - const footer = - interaction.message - ?.embeds?.[0] - ?.footer - ?.text; - - return getProfileContextFromFooter( - footer - ); -} - -function getProfileContextFromFooter( - footer -) { - const [ - type, - sessionOwnerId, - profileUserId, - section = "overview", - rawPage = "0", - rawPageCount = "1" - ] = footer?.split(":") ?? []; - - if ( - type !== "profile" || - !sessionOwnerId || - !profileUserId - ) { - return null; - } + value: + `**${unlockedCount} / ${definitions.length}** achievements unlocked` + }) + .setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "achievements", + safePage, + pages.length + ) + }); return { - sessionOwnerId, - profileUserId, - section, + embed, page: - Number(rawPage) || 0, + safePage, pageCount: - Math.max( - 1, - Number(rawPageCount) || 1 - ) + pages.length }; } -function getProfileTargetId( - interaction -) { - return getProfileContext( - interaction - )?.profileUserId ?? null; -} -async function requireProfileOwner( - interaction +/* + * ============================================================ + * BADGES PAGE + * ============================================================ + */ + +async function buildBadgesEmbed( + guildId, + user, + page = 0, + sessionOwnerId = + user.id ) { - const context = - getProfileContext( - interaction + const { badges } = + getProfileCollections(); + + const documents = + await badges + .find({ + guildId + }) + .sort({ + category: 1, + name: 1 + }) + .toArray(); + + const categories = + groupBadgesByCategory( + documents ); - if (!context) { - await replyProfileError( - interaction, - "This profile session is no longer valid." - ); - - return null; - } - - if ( - context.sessionOwnerId !== - interaction.user.id - ) { - await replyProfileError( - interaction, - "This isn't your profile session." - ); - - return null; - } - - return context; -} - -async function replyProfileError( - interaction, - content -) { - const payload = { - content, - flags: - MessageFlags.Ephemeral - }; - - if ( - interaction.replied || - interaction.deferred - ) { - return interaction.followUp( - payload - ); - } - - return interaction.reply( - payload - ); -} - -function paginateLines( - lines, - maxLength = 3400, - separator = "\n" -) { const pages = []; - let current = []; - for (const line of lines) { - const candidate = - [...current, line].join( - separator + /* + * Page 1: + * badges owned by viewed user. + */ + const owned = + documents.filter( + badge => + badge.ownerId === + user.id + ); + + pages.push({ + title: + "Owned Badges", + + description: + owned.length + ? owned + .map( + badge => + formatBadgeLine( + badge, + user.id + ) + ) + .join("\n\n") + : "*No unique badges owned yet.*" + }); + + /* + * Remaining pages: + * derived from MongoDB categories. + */ + for ( + const [ + category, + categoryBadges + ] + of categories + ) { + pages.push({ + title: + formatCategoryName( + category + ), + + description: + categoryBadges + .map( + badge => + formatBadgeLine( + badge, + user.id + ) + ) + .join("\n\n") + }); + } + + const safePage = + clampPage( + page, + pages.length + ); + + const current = + pages[safePage] + ?? + { + title: + "Badges", + + description: + "No badges configured." + }; + + const embed = + new EmbedBuilder() + .setTitle( + `๐Ÿ… ${user.globalName ?? user.username}'s Badges` + ) + .setDescription( + [ + `### ${current.title}`, + "", + current.description + ].join("\n") + ) + .setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "badges", + safePage, + pages.length + ) + }); + + return { + embed, + page: + safePage, + pageCount: + pages.length + }; +} + + +/* + * ============================================================ + * BADGE DISPLAY HELPERS + * ============================================================ + */ + +function groupBadgesByCategory( + badges +) { + const categories = + new Map(); + + for (const badge of badges) { + const category = + badge.category + ?? + inferBadgeCategory( + badge.badgeId ); if ( - candidate.length > - maxLength && - current.length + !categories.has( + category + ) ) { - pages.push( - current.join( - separator - ) + categories.set( + category, + [] ); - - current = [line]; } - else { - current.push(line); - } + categories + .get(category) + .push(badge); } - if (current.length) { - pages.push( - current.join( - separator - ) - ); - } - - return pages; + return categories; } -function clampPage( - page, - pageCount + +function inferBadgeCategory( + badgeId = "" ) { - return Math.max( - 0, - Math.min( - Number(page) || 0, - Math.max( - 0, - pageCount - 1 - ) + if ( + badgeId.includes( + "slots" ) - ); + ) { + return "slots"; + } + + if ( + badgeId.includes( + "blackjack" + ) + ) { + return "blackjack"; + } + + if ( + badgeId.includes( + "roulette" + ) + ) { + return "roulette"; + } + + if ( + badgeId.includes( + "achievement" + ) + ) { + return "collector"; + } + + if ( + badgeId.includes( + "steal" + ) + ) { + return "stealing"; + } + + if ( + badgeId.includes( + "vault" + ) + ) { + return "heist"; + } + + return "economy"; } -function formatCategoryName( - category -) { - const labels = { - economy: - "๐Ÿช Economy", - stealing: - "๐Ÿฅท Stealing", - slots: - "๐ŸŽฐ Slots", - blackjack: - "๐Ÿƒ Blackjack", - roulette: - "๐ŸŽก Roulette", - heist: - "๐Ÿฆ Heist", - collector: - "๐Ÿ† Collector", - other: - "๐Ÿ… Other" - }; - - return labels[category] ?? - `๐Ÿ… ${category.charAt(0).toUpperCase()}${category.slice(1)}`; -} function formatBadgeLine( badge, - profileUserId, - ownedPage + profileUserId ) { - const emoji = - badge.emoji ?? "๐Ÿ…"; + const hidden = + badge.secret === + true + && + !badge.ownerId; - const description = - badge.description ?? - "No description available."; - - if (ownedPage) { - return `${emoji} **${badge.name}**\n${description}`; + if (hidden) { + return [ + "โ“ **???**", + "*Hidden global badge.*" + ].join("\n"); } let status; @@ -1873,37 +2417,820 @@ function formatBadgeLine( badge.ownerId === profileUserId ) { - status = "โœ… **Owned by this player**"; + status = + "โœ… **Owned by this player**"; } - else if (badge.ownerId) { - status = `๐Ÿ”’ Claimed by <@${badge.ownerId}>`; + else if ( + badge.ownerId + ) { + status = + `๐Ÿ”’ Claimed by <@${badge.ownerId}>`; } else { - status = "๐ŸŸข **Available**"; + status = + "โœจ **Still available**"; } - return [ - `${emoji} **${badge.name}**`, - description, + const lines = [ + `${badge.emoji ?? "๐Ÿ…"} **${badge.name ?? badge.badgeId}**`, + badge.description + ?? + "No description.", status - ].join("\n"); + ]; + + if (badge.unlockedAt) { + lines.push( + `Claimed ` + ); + } + + if ( + badge.metadata + ?.unlockedAchievements !== + undefined + && + badge.metadata + ?.availableAchievements !== + undefined + ) { + lines.push( + `Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**` + ); + } + + return lines.join("\n"); } + +/* + * ============================================================ + * RECONCILE ACHIEVEMENTS + * ============================================================ + */ + +async function reconcileProfileAchievements( + guildId, + { + userId = null, + dryRun = false + } = {} +) { + const { + stats, + achievements, + achievementConfig + } = getProfileCollections(); + + const profileFilter = { + guildId + }; + + if (userId) { + profileFilter.userId = + userId; + } + + const [ + profileDocuments, + definitions + ] = await Promise.all([ + stats.find( + profileFilter + ).toArray(), + + achievementConfig + .find({ + active: { + $ne: false + } + }) + .toArray() + ]); + + const normalDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + && + Boolean( + definition.stat + ) + ); + + const eventOnlyDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + && + !definition.stat + ); + + let unlocksFound = 0; + let unlocksApplied = 0; + + const details = []; + + for (const profile of profileDocuments) { + const profileUserId = + profile.userId; + + if (!profileUserId) { + continue; + } + + const achievementDocument = + await achievements.findOne({ + userId: + profileUserId, + + guildId + }); + + const unlocked = + achievementDocument?.unlocked ?? {}; + + for (const definition of normalDefinitions) { + if ( + unlocked[ + definition.achievementId + ] + ) { + continue; + } + + const currentValue = + readStatValue( + profile, + definition + ); + + if ( + currentValue < + Number(definition.target) + ) { + continue; + } + + unlocksFound++; + + details.push({ + userId: + profileUserId, + + achievementId: + definition.achievementId, + + achievementName: + definition.name + }); + + if (dryRun) { + continue; + } + + await ensureAchievementDocument( + profileUserId, + guildId + ); + + const field = + `unlocked.${definition.achievementId}`; + + const result = + await achievements.updateOne( + { + userId: + profileUserId, + + guildId, + + [field]: { + $exists: + false + } + }, + { + $set: { + [field]: + getUnixTime() + } + } + ); + + if ( + result.modifiedCount === + 1 + ) { + unlocksApplied++; + } + } + + /* + * Collector achievements. + */ + const collectorDefinitions = + definitions.filter( + definition => + definition.measure === + "achievementPercentage" + ); + + if ( + collectorDefinitions.length === + 0 + ) { + continue; + } + + const refreshed = + dryRun + ? unlocked + : ( + await achievements.findOne({ + userId: + profileUserId, + + guildId + }) + )?.unlocked + ?? + {}; + + const collectible = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + const achieved = + collectible.filter( + definition => { + if ( + refreshed[ + definition.achievementId + ] + ) { + return true; + } + + if ( + dryRun + && + definition.stat + ) { + return ( + readStatValue( + profile, + definition + ) + >= + Number( + definition.target + ) + ); + } + + return false; + } + ).length; + + const percentage = + collectible.length > 0 + ? achieved / + collectible.length + : 0; + + for ( + const definition + of collectorDefinitions + ) { + if ( + refreshed[ + definition.achievementId + ] + ) { + continue; + } + + if ( + percentage < + Number(definition.target) + ) { + continue; + } + + unlocksFound++; + + details.push({ + userId: + profileUserId, + + achievementId: + definition.achievementId, + + achievementName: + definition.name + }); + + if (dryRun) { + continue; + } + + const field = + `unlocked.${definition.achievementId}`; + + const result = + await achievements.updateOne( + { + userId: + profileUserId, + + guildId, + + [field]: { + $exists: + false + } + }, + { + $set: { + [field]: + getUnixTime() + } + } + ); + + if ( + result.modifiedCount === + 1 + ) { + unlocksApplied++; + } + } + } + + return { + profilesChecked: + profileDocuments.length, + + unlocksFound, + unlocksApplied, + + skippedEventOnly: + eventOnlyDefinitions.length, + + details + }; +} + + +/* + * ============================================================ + * PROFILE SYSTEM AUDIT + * ============================================================ + */ + +async function auditProfileSystem( + guildId +) { + const { + stats, + achievements, + badges, + achievementConfig + } = getProfileCollections(); + + const [ + profileDocuments, + achievementDocuments, + activeAchievements, + legacyAchievements, + badgeDocuments, + definitions + ] = await Promise.all([ + stats.countDocuments({ + guildId + }), + + achievements.countDocuments({ + guildId + }), + + achievementConfig.countDocuments({ + active: { + $ne: false + } + }), + + achievementConfig.countDocuments({ + legacy: + true + }), + + badges.find({ + guildId + }).toArray(), + + achievementConfig + .find({}) + .toArray() + ]); + + const claimedBadges = + badgeDocuments.filter( + badge => + Boolean( + badge.ownerId + ) + ).length; + + const availableBadges = + badgeDocuments.length - + claimedBadges; + + const warnings = []; + + +/* + * ============================================================ + * BADGE VALIDATION + * ============================================================ + */ + + const badgeIds = + new Set(); + + for (const badge of badgeDocuments) { + if (!badge.badgeId) { + warnings.push( + `Badge document ${badge._id} has no badgeId.` + ); + + continue; + } + + if ( + badgeIds.has( + badge.badgeId + ) + ) { + warnings.push( + `Duplicate badgeId detected: ${badge.badgeId}` + ); + } + + badgeIds.add( + badge.badgeId + ); + } + + const expectedBadgeIds = [ + "first_100k_cookies", + "first_1m_cookies", + "first_100k_vault_stolen", + + "first_unique_steals_5", + "first_unique_steals_10", + "first_unique_steals_15", + + "first_slots_jackpot", + + "first_blackjack_natural", + "first_blackjack_allin_win", + + "first_roulette_zero", + "first_roulette_allin_win", + + "first_achievement_half", + "first_achievement_all" + ]; + + for (const badgeId of expectedBadgeIds) { + if ( + !badgeIds.has( + badgeId + ) + ) { + warnings.push( + `Expected guild badge is missing: ${badgeId}` + ); + } + } + + +/* + * ============================================================ + * ACHIEVEMENT CONFIG VALIDATION + * ============================================================ + */ + + const achievementIds = + new Set(); + + for (const achievement of definitions) { + if (!achievement.achievementId) { + warnings.push( + `Achievement config ${achievement._id} has no achievementId.` + ); + + continue; + } + + if ( + achievementIds.has( + achievement.achievementId + ) + ) { + warnings.push( + `Duplicate achievementId detected: ${achievement.achievementId}` + ); + } + + achievementIds.add( + achievement.achievementId + ); + + if ( + achievement.active !== + false + && + achievement.measure !== + "achievementPercentage" + && + !achievement.stat + ) { + warnings.push( + `Active achievement ${achievement.achievementId} has no stat path.` + ); + } + + if ( + achievement.target === + undefined + ) { + warnings.push( + `Achievement ${achievement.achievementId} has no target.` + ); + } + } + + +/* + * ============================================================ + * ORPHANED UNLOCKS + * ============================================================ + */ + + const unlockedDocuments = + await achievements + .find({ + guildId + }) + .toArray(); + + const orphanedUnlocks = + new Set(); + + for (const document of unlockedDocuments) { + const unlocked = + document.unlocked + ?? + {}; + + for ( + const achievementId + of Object.keys(unlocked) + ) { + if ( + !achievementIds.has( + achievementId + ) + ) { + orphanedUnlocks.add( + achievementId + ); + } + } + } + + for ( + const achievementId + of orphanedUnlocks + ) { + warnings.push( + `Unlocked achievement has no config definition: ${achievementId}` + ); + } + + +/* + * ============================================================ + * PROFILE SANITY + * ============================================================ + */ + + const profileDocs = + await stats + .find({ + guildId + }) + .toArray(); + + for (const profile of profileDocs) { + if (!profile.userId) { + warnings.push( + `Profile document ${profile._id} has no userId.` + ); + } + + if (!profile.games) { + warnings.push( + `Profile for ${profile.userId ?? profile._id} has no games object.` + ); + } + + if (!profile.economy) { + warnings.push( + `Profile for ${profile.userId ?? profile._id} has no economy object.` + ); + } + } + + return { + profileDocuments, + achievementDocuments, + + activeAchievements, + legacyAchievements, + + badges: + badgeDocuments.length, + + claimedBadges, + availableBadges, + + warnings + }; +} + +/* + * ============================================================ + * COMPATIBLITY HELPERS + * ============================================================ + */ + +const buildStatsEmbed = + buildProfileStatsEmbed; + +/* + * ============================================================ + * PAGINATION HELPERS + * ============================================================ + */ + +function paginateLines( + lines, + maxLength = + PROFILE_PAGE_CHARACTER_LIMIT +) { + const pages = []; + + let current = []; + + for (const line of lines) { + const candidate = + [ + ...current, + line + ].join("\n"); + + if ( + candidate.length > + maxLength + && + current.length > + 0 + ) { + pages.push( + current.join("\n") + ); + + current = [ + line + ]; + } + + else { + current.push( + line + ); + } + } + + if ( + current.length > + 0 + ) { + pages.push( + current.join("\n") + ); + } + + if ( + pages.length === + 0 + ) { + pages.push( + "Nothing to display." + ); + } + + return pages; +} + + +function clampPage( + page, + pageCount +) { + const numeric = + Number(page) || 0; + + return Math.max( + 0, + Math.min( + numeric, + Math.max( + 0, + pageCount - 1 + ) + ) + ); +} + + +function formatCategoryName( + category +) { + return String( + category ?? "other" + ) + .replace( + /[_-]+/g, + " " + ) + .replace( + /\b\w/g, + character => + character.toUpperCase() + ); +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + module.exports = { ensureProfileSystem, - trackProfileEvent, + + getProfileCollections, getProfileStats, + getDefaultStats, + + trackProfileEvent, + recordGameOutcome, + + evaluateAchievements, + evaluateBadges, + tryClaimBadge, buildProfileOverview, - buildStatsEmbed, - buildBadgesEmbed, - buildAchievementsEmbed, - buildProfileButtons, - getProfileContext, - getProfileContextFromFooter, + buildProfileStatsEmbed, + buildStatsEmbed, + + buildAchievementsEmbed, + buildBadgesEmbed, + + buildProfileButtons, + buildProfilePaginationButtons, + + buildProfileFooter, + parseProfileFooter, + getProfileTargetId, - requireProfileOwner -}; + getProfileSessionOwnerId, + requireProfileOwner, + + reconcileProfileAchievements, + auditProfileSystem +}; \ No newline at end of file