diff --git a/buttons/profile/achievements.js b/buttons/profile/achievements.js index 66e675c..e4ce1b5 100644 --- a/buttons/profile/achievements.js +++ b/buttons/profile/achievements.js @@ -1,51 +1,54 @@ const { - requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileAchievements", + name: + "profileAchievements", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildAchievementsEmbed( - interaction.guildId, - user, - 0, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileAchievementsLeft", - "profileAchievementsRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildAchievementsEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + 0 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "achievements", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/achievementsLeft.js b/buttons/profile/achievementsLeft.js index 5871882..7e72918 100644 --- a/buttons/profile/achievementsLeft.js +++ b/buttons/profile/achievementsLeft.js @@ -1,53 +1,54 @@ -// buttons/profile/achievementsLeft.js - const { - requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileAchievementsLeft", + name: + "profileAchievementsLeft", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildAchievementsEmbed( - interaction.guildId, - user, - session.page - 1, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileAchievementsLeft", - "profileAchievementsRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildAchievementsEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + context.page - 1 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "achievements", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/achievementsRight.js b/buttons/profile/achievementsRight.js index ad55a9b..e35a23d 100644 --- a/buttons/profile/achievementsRight.js +++ b/buttons/profile/achievementsRight.js @@ -1,53 +1,54 @@ -// buttons/profile/achievementsRight.js - const { - requireProfileOwner, buildAchievementsEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileAchievementsRight", + name: + "profileAchievementsRight", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildAchievementsEmbed( - interaction.guildId, - user, - session.page + 1, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileAchievementsLeft", - "profileAchievementsRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildAchievementsEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + context.page + 1 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "achievements", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/badges.js b/buttons/profile/badges.js index 94f995a..75d5251 100644 --- a/buttons/profile/badges.js +++ b/buttons/profile/badges.js @@ -1,51 +1,54 @@ const { - requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileBadges", + name: + "profileBadges", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildBadgesEmbed( - interaction.guildId, - user, - 0, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileBadgesLeft", - "profileBadgesRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildBadgesEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + 0 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "badges", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/badgesLeft.js b/buttons/profile/badgesLeft.js index 87c4ac5..ad3f3b7 100644 --- a/buttons/profile/badgesLeft.js +++ b/buttons/profile/badgesLeft.js @@ -1,53 +1,54 @@ -// buttons/profile/badgesLeft.js - const { - requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileBadgesLeft", + name: + "profileBadgesLeft", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildBadgesEmbed( - interaction.guildId, - user, - session.page - 1, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileBadgesLeft", - "profileBadgesRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildBadgesEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + context.page - 1 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "badges", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/badgesRight.js b/buttons/profile/badgesRight.js index 61208fe..8716229 100644 --- a/buttons/profile/badgesRight.js +++ b/buttons/profile/badgesRight.js @@ -1,53 +1,54 @@ -// buttons/profile/badgesRight.js - const { - requireProfileOwner, buildBadgesEmbed, buildProfileButtons, - buildProfilePaginationButtons -} = require("../../utils/profileSystem"); + getProfileContextFromFooter, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileBadgesRight", + name: + "profileBadgesRight", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); - - const result = await buildBadgesEmbed( - interaction.guildId, - user, - session.page + 1, - session.sessionOwnerId - ); - - const components = [ - ...buildProfileButtons() - ]; - - if (result.pageCount > 1) { - components.push( - buildProfilePaginationButtons( - "profileBadgesLeft", - "profileBadgesRight", - result.page, - result.pageCount - ) + const user = + await interaction.client.users.fetch( + context.profileUserId + ); + + const embed = + await buildBadgesEmbed( + interaction.guildId, + user, + context.sessionOwnerId, + context.page + 1 + ); + + const pageContext = + getProfileContextFromFooter( + embed.data.footer?.text ); - } return interaction.update({ - embeds: [result.embed], - components + embeds: [embed], + components: + buildProfileButtons( + "badges", + pageContext?.page ?? 0, + pageContext?.pageCount ?? 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/buttons/profile/stats.js b/buttons/profile/stats.js index 733a5d4..53c56a2 100644 --- a/buttons/profile/stats.js +++ b/buttons/profile/stats.js @@ -1,34 +1,47 @@ const { - requireProfileOwner, - buildProfileStatsEmbed, - buildProfileButtons -} = require("../../utils/profileSystem"); + buildStatsEmbed, + buildProfileButtons, + requireProfileOwner +} = require( + "../../utils/profileSystem" +); module.exports = { - name: "profileStats", + name: + "profileStats", - async execute(interaction) { - const session = await requireProfileOwner( - interaction - ); + async execute( + interaction + ) { + const context = + await requireProfileOwner( + interaction + ); - if (!session) { + if (!context) { return; } - const user = await interaction.client.users.fetch( - session.profileUserId - ); + const user = + await interaction.client.users.fetch( + context.profileUserId + ); - const embed = await buildProfileStatsEmbed( - interaction.guildId, - user, - session.sessionOwnerId - ); + const embed = + await buildStatsEmbed( + interaction.guildId, + user, + context.sessionOwnerId + ); return interaction.update({ embeds: [embed], - components: buildProfileButtons() + components: + buildProfileButtons( + "stats", + 0, + 1 + ) }); } -}; \ No newline at end of file +}; diff --git a/commands/applications/profile.js b/commands/applications/profile.js index ca6d41a..02d4ce4 100644 --- a/commands/applications/profile.js +++ b/commands/applications/profile.js @@ -1,46 +1,63 @@ -const { SlashCommandBuilder } = require("discord.js"); +const { + SlashCommandBuilder +} = require( + "discord.js" +); + const { buildProfileOverview, buildProfileButtons -} = require("../../utils/profileSystem"); -const { - createInteractionSession -} = require("../../utils/interactionSession"); +} = require( + "../../utils/profileSystem" +); module.exports = { - data: new SlashCommandBuilder() - .setName("profile") - .setDescription("View a player profile") - .addUserOption(option => - option - .setName("user") - .setDescription("Player to view") - ), + data: + new SlashCommandBuilder() + .setName( + "profile" + ) + .setDescription( + "View a player profile" + ) + .addUserOption( + option => + option + .setName( + "user" + ) + .setDescription( + "Player to view" + ) + ), - async execute(interaction) { - const user = interaction.options.getUser("user") ?? interaction.user; + async execute( + interaction + ) { + const user = + interaction.options.getUser( + "user" + ) ?? + interaction.user; - const embed = await buildProfileOverview( - interaction.guildId, - user, - interaction.user.id - ); + const sessionOwnerId = + interaction.user.id; - await interaction.reply({ + const embed = + await buildProfileOverview( + interaction.guildId, + user, + sessionOwnerId + ); + + return interaction.reply({ embeds: [embed], - components: buildProfileButtons() + components: + buildProfileButtons( + "overview", + 0, + 1 + ) }); - - const message = await interaction.fetchReply(); - - await createInteractionSession( - interaction, - { - messageId: message.id, - ownerId: interaction.user.id, - kind: "profile", - targetId: user.id - } - ); } }; diff --git a/commands/applications/profileAdmin.js b/commands/applications/profileAdmin.js index bb3752f..4cd60ff 100644 --- a/commands/applications/profileAdmin.js +++ b/commands/applications/profileAdmin.js @@ -1,415 +1,263 @@ const { SlashCommandBuilder, - MessageFlags -} = require( - "discord.js" -); + PermissionFlagsBits, + MessageFlags, + EmbedBuilder +} = require("discord.js"); const { - requireAdminAccess + reconcileUserAchievements, + reconcileAllAchievements, + auditProfileData } = require( - "../../utils/permissions" + "../../utils/profileMaintenance" ); -const { - reconcileProfileAchievements, - auditProfileSystem -} = require( - "../../utils/profileSystem" -); - - -/* - * ============================================================ - * COMMAND - * ============================================================ - */ - module.exports = { data: new SlashCommandBuilder() .setName( "profileadmin" ) - .setDescription( - "Profile and achievement administration." + "Administrative profile maintenance tools" + ) + .setDefaultMemberPermissions( + PermissionFlagsBits.Administrator ) - - /* - * ================================================= - * RECONCILE - * ================================================= - */ - .addSubcommand( subcommand => subcommand .setName( "reconcile" ) - .setDescription( - "Re-evaluate stored statistics for achievements." + "Backfill database-trackable achievements" ) - .addUserOption( option => option .setName( "user" ) - .setDescription( - "Only reconcile this user's profile." - ) - - .setRequired( - false + "Only reconcile this user; omit for everyone" ) ) - .addBooleanOption( option => option .setName( "dry-run" ) - .setDescription( - "Show what would change without unlocking anything." - ) - - .setRequired( - false + "Show what would change without writing it" ) ) ) - - /* - * ================================================= - * AUDIT - * ================================================= - */ - .addSubcommand( subcommand => subcommand .setName( "audit" ) - .setDescription( - "Audit the profile and achievement system." + "Audit stored profile statistics for inconsistencies" ) ), - - /* - * ======================================================== - * 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(); + interaction.options.getSubcommand(); + await interaction.deferReply({ + flags: + MessageFlags.Ephemeral + }); - switch ( - subcommand + if ( + subcommand === + "reconcile" ) { - case "reconcile": - - return runReconcile( - interaction + const user = + interaction.options.getUser( + "user" ); + const dryRun = + interaction.options.getBoolean( + "dry-run" + ) ?? false; - case "audit": + if (user) { + const result = + await reconcileUserAchievements({ + guildId: + interaction.guildId, + userId: + user.id, + dryRun + }); - return runAudit( - interaction - ); - - - default: - - return interaction.reply({ - content: - "Unknown profile administration action.", - - flags: - MessageFlags.Ephemeral + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle( + "๐Ÿ› ๏ธ Profile Reconciliation" + ) + .setDescription( + `${dryRun ? "Dry run for" : "Reconciled"} ${user}.` + ) + .addFields( + { + name: + "New unlocks", + value: + `**${result.unlocked}**`, + inline: + true + }, + { + name: + "Definitions checked", + value: + `**${result.checked}**`, + inline: + true + }, + { + name: + "Event-only skipped", + value: + `**${result.skippedEventOnly}**`, + inline: + true + } + ) + ] }); - } - } -}; - - -/* - * ============================================================ - * 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.*` - ); } + + const summary = + await reconcileAllAchievements({ + guildId: + interaction.guildId, + dryRun + }); + + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle( + "๐Ÿ› ๏ธ Profile Reconciliation" + ) + .setDescription( + `${dryRun ? "Dry run completed" : "Reconciliation completed"} for stored profile users.` + ) + .addFields( + { + name: + "Users", + value: + `**${summary.users}**`, + inline: + true + }, + { + name: + "New unlocks", + value: + `**${summary.unlocked}**`, + inline: + true + }, + { + name: + "Event-only checks skipped", + value: + `**${summary.skippedEventOnly}**`, + inline: + true + } + ) + ] + }); } - - 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( + const audit = + await auditProfileData( 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." - ); - } - + const issuePreview = + audit.issues + .slice(0, 10) + .join("\n"); 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." + embeds: [ + new EmbedBuilder() + .setTitle( + "๐Ÿ”Ž Profile Data Audit" + ) + .addFields( + { + name: + "Stats documents", + value: + `**${audit.statDocuments}**`, + inline: + true + }, + { + name: + "Achievement documents", + value: + `**${audit.achievementDocuments}**`, + inline: + true + }, + { + name: + "Active achievements", + value: + `**${audit.activeAchievements}**`, + inline: + true + }, + { + name: + "Global badges", + value: + `**${audit.badgeDocuments}** (${audit.claimedBadges} claimed)`, + inline: + true + }, + { + name: + "Orphan achievement docs", + value: + `**${audit.orphanAchievementDocs}**`, + inline: + true + }, + { + name: + "Issues", + value: + `**${audit.issueCount}**`, + inline: + true + } + ) + .setDescription( + issuePreview + ? `First issues:\n${issuePreview}` + : "No structural inconsistencies found." + ) + ] }); } -} \ No newline at end of file +}; diff --git a/commands/applications/records.js b/commands/applications/records.js index a48d203..3c4b4a7 100644 --- a/commands/applications/records.js +++ b/commands/applications/records.js @@ -16,7 +16,7 @@ module.exports = { "records" ) .setDescription( - "View Shai Cookie and Crumb Saucer records" + "View Olymp Cookie and Crumb Saucer records" ) .addStringOption( option => diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index e512bbe..1617570 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -27,30 +27,30 @@ const HEIST_METHODS = { baseChance: 0.65, minStealPercent: 0.01, maxStealPercent: 0.03, - maxStealAmount: 5000, + maxStealAmount: 1500, minPenaltyPercent: 0.01, maxPenaltyPercent: 0.04, - maxPenaltyAmount: 1500 + maxPenaltyAmount: 750 }, Hack: { baseChance: 0.45, minStealPercent: 0.03, maxStealPercent: 0.06, - maxStealAmount: 12500, + maxStealAmount: 3500, minPenaltyPercent: 0.03, maxPenaltyPercent: 0.07, - maxPenaltyAmount: 4000 + maxPenaltyAmount: 2000 }, Smash: { baseChance: 0.25, minStealPercent: 0.06, maxStealPercent: 0.12, - maxStealAmount: 25000, + maxStealAmount: 7500, minPenaltyPercent: 0.06, maxPenaltyPercent: 0.12, - maxPenaltyAmount: 10000 + maxPenaltyAmount: 5000 } }; diff --git a/utils/crumbBlackjack.js b/utils/crumbBlackjack.js index ca69c66..92ca855 100644 --- a/utils/crumbBlackjack.js +++ b/utils/crumbBlackjack.js @@ -18,7 +18,8 @@ const { } = require("./cookieEconomy"); const { - recordGameOutcome + recordGameOutcome, + trackProfileEvent } = require("./profileSystem"); const GAME_COLLECTION = @@ -112,7 +113,9 @@ async function startBlackjack( interaction, bet, allowCustomBet = false, - allIn = false + source = "normal", + previousAllIn = false, + previousBet = 0 ) { if ( !allowCustomBet && @@ -196,6 +199,9 @@ async function startBlackjack( getCookieBalance(houseId) ]); + const allIn = + bet === playerBalance; + if (playerBalance < bet) { return temporaryEphemeralReply( interaction, @@ -276,6 +282,11 @@ async function startBlackjack( doubled: false, allIn, + vaultAtBet: + playerBalance, + source, + previousAllIn, + previousBet, deck, playerCards, dealerCards, @@ -285,6 +296,20 @@ async function startBlackjack( await games.insertOne(game); + await trackProfileEvent( + interaction, + playerId, + { + type: "game_start", + game: "blackjack", + source, + bet, + allIn, + previousAllIn, + previousBet + } + ); + const playerBlackjack = isBlackjack(playerCards); @@ -370,7 +395,7 @@ async function startBlackjackAllIn( interaction, balance, true, - true + "all_in" ); } @@ -405,6 +430,9 @@ async function runBlackjackBack( const bet = Number(parts[2]); + const previousAllIn = + parts[3] === "1"; + if ( ownerId !== interaction.user.id @@ -435,7 +463,9 @@ async function runBlackjackBack( interaction, bet, true, - false + "run_it_back", + previousAllIn, + bet ); } @@ -803,6 +833,32 @@ async function finishBlackjack( game, result ) { + const { db } = getCollections(); + + const claim = await db + .collection(GAME_COLLECTION) + .updateOne( + { + gameId: game.gameId, + state: "Playing" + }, + { + $set: { + state: "Resolving" + } + } + ); + + if (claim.modifiedCount !== 1) { + return temporaryEphemeralReply( + interaction, + { + content: + "This Blackjack hand is already being resolved." + } + ); + } + const houseId = interaction.client.user.id; @@ -845,6 +901,23 @@ async function finishBlackjack( `[Blackjack] House cannot cover payout ${payout} for ${game.userId}` ); + await db + .collection(GAME_COLLECTION) + .updateOne( + { + gameId: game.gameId, + state: "Resolving" + }, + { + $set: { + state: "Finished", + result: "PayoutError", + payout: 0, + finishedAt: getUnixTime() + } + } + ); + return interaction.update({ embeds: [ new EmbedBuilder() @@ -856,7 +929,7 @@ async function finishBlackjack( ) .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.originalBet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet}:${game.allIn ? 1 : 0}` }) ], components: @@ -871,9 +944,6 @@ async function finishBlackjack( ); } - const { db } = - getCollections(); - await db .collection( GAME_COLLECTION @@ -883,7 +953,7 @@ async function finishBlackjack( gameId: game.gameId, state: - "Playing" + "Resolving" }, { $set: { @@ -1010,7 +1080,7 @@ async function finishBlackjack( ) .setFooter({ text: - `crumbBlackjackResult:${game.userId}:${game.originalBet}` + `crumbBlackjackResult:${game.userId}:${game.originalBet}:${game.allIn ? 1 : 0}` }); return interaction.update({ diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index b976488..2c154eb 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -12,7 +12,8 @@ const { } = require("./cookieEconomy"); const { - recordGameOutcome + recordGameOutcome, + trackProfileEvent } = require("./profileSystem"); const ROULETTE_BETS = [ @@ -206,7 +207,10 @@ async function startRouletteAllIn( async function showRouletteChoices( interaction, bet, - allIn = false + requestedAllIn = false, + source = "normal", + previousAllIn = false, + previousBet = 0 ) { if ( !Number.isSafeInteger(bet) || @@ -262,7 +266,7 @@ async function showRouletteChoices( }) .setFooter({ text: - `crumbRouletteBet:${interaction.user.id}:${bet}:${allIn ? 1 : 0}` + `crumbRouletteBet:${interaction.user.id}:${bet}:${requestedAllIn ? 1 : 0}:${source}:${previousAllIn ? 1 : 0}:${previousBet}` }); return interaction.update({ @@ -303,6 +307,9 @@ async function runRouletteBack( const bet = Number(parts[2]); + const previousAllIn = + parts[3] === "1"; + if ( ownerId !== interaction.user.id @@ -383,8 +390,14 @@ async function playRoulette( const bet = Number(parts[2]); - const allIn = - parts[3] === "1"; + const source = + parts[4] || "normal"; + + const previousAllIn = + parts[5] === "1"; + + const previousBet = + Number(parts[6] ?? 0); if ( ownerId !== @@ -476,6 +489,23 @@ async function playRoulette( throw error; } + const allIn = + bet === playerBalance; + + await trackProfileEvent( + interaction, + playerId, + { + type: "game_start", + game: "roulette", + source, + bet, + allIn, + previousAllIn, + previousBet + } + ); + await interaction.update({ embeds: [ buildSpinEmbed( @@ -594,9 +624,7 @@ async function playRoulette( won && choiceKey === "Zero", - allIn: - won && - allIn + allIn } ); @@ -664,7 +692,7 @@ async function playRoulette( ) .setFooter({ text: - `crumbRouletteResult:${playerId}:${bet}` + `crumbRouletteResult:${playerId}:${bet}:${allIn ? 1 : 0}` }); return interaction.editReply({ diff --git a/utils/crumbSlots.js b/utils/crumbSlots.js index 7532691..fa17bb3 100644 --- a/utils/crumbSlots.js +++ b/utils/crumbSlots.js @@ -13,7 +13,8 @@ const { } = require("./cookieEconomy"); const { - recordGameOutcome + recordGameOutcome, + trackProfileEvent } = require("./profileSystem"); const SLOT_BETS = [ @@ -104,7 +105,8 @@ async function showSlots(interaction) { async function playSlots( interaction, - bet + bet, + source = "normal" ) { const ownerId = getSlotsSessionOwner( @@ -197,6 +199,17 @@ async function playSlots( throw error; } + await trackProfileEvent( + interaction, + playerId, + { + type: "game_start", + game: "slots", + source, + bet + } + ); + await interaction.update({ embeds: [ buildSpinningEmbed( @@ -321,6 +334,10 @@ async function playSlots( ? "loss" : "push", profit, + triplet: + outcome.type === "triple" + ? outcome.symbol + : null, jackpot: outcome.type === "triple" && @@ -460,7 +477,8 @@ async function runSlotsBack( return playSlots( interaction, - bet + bet, + "run_it_back" ); } diff --git a/utils/profileSystem.js b/utils/profileSystem.js index ea3fea0..0e0499f 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -7,17 +7,7 @@ const { } = require("discord.js"); const { mClient } = require("../index"); -const { - getCookieBalance, - getUnixTime -} = require("./cookieEconomy"); - - -/* - * ============================================================ - * CONFIG - * ============================================================ - */ +const { getCookieBalance, getUnixTime } = require("./cookieEconomy"); const DATABASE_NAME = process.env.M_DB; @@ -28,15 +18,6 @@ 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"); @@ -53,13 +34,6 @@ function getProfileCollections() { }; } - -/* - * ============================================================ - * INDEXES - * ============================================================ - */ - async function ensureProfileSystem() { const { stats, @@ -89,13 +63,6 @@ async function ensureProfileSystem() { ); } - -/* - * ============================================================ - * DEFAULT STATS - * ============================================================ - */ - function getDefaultStats(userId, guildId) { return { userId, @@ -129,7 +96,9 @@ function getDefaultStats(userId, guildId) { lost: 0, pushed: 0, cookiesWon: 0, - cookiesLost: 0 + cookiesLost: 0, + currentRunItBackStreak: 0, + bestRunItBackStreak: 0 }, blackjack: { @@ -138,7 +107,9 @@ function getDefaultStats(userId, guildId) { lost: 0, pushed: 0, cookiesWon: 0, - cookiesLost: 0 + cookiesLost: 0, + currentRunItBackStreak: 0, + bestRunItBackStreak: 0 }, roulette: { @@ -147,7 +118,9 @@ function getDefaultStats(userId, guildId) { lost: 0, pushed: 0, cookiesWon: 0, - cookiesLost: 0 + cookiesLost: 0, + currentRunItBackStreak: 0, + bestRunItBackStreak: 0 }, heist: { @@ -165,17 +138,14 @@ function getDefaultStats(userId, guildId) { bestWin: 0, currentLoss: 0, bestLoss: 0 + }, + + special: { + allInRunItBack: 0 } }; } - -/* - * ============================================================ - * ENSURE DOCUMENTS - * ============================================================ - */ - async function ensureStatsDocument(userId, guildId) { const { stats } = getProfileCollections(); @@ -191,7 +161,6 @@ async function ensureStatsDocument(userId, guildId) { ); } - async function ensureAchievementDocument(userId, guildId) { const { achievements } = getProfileCollections(); @@ -208,34 +177,18 @@ async function ensureAchievementDocument(userId, guildId) { ); } - -/* - * ============================================================ - * GET PROFILE STATS - * ============================================================ - */ - async function getProfileStats(userId, guildId) { const { stats } = getProfileCollections(); - const existing = await stats.findOne({ + return await stats.findOne({ userId, guildId - }); - - return existing ?? getDefaultStats( + }) ?? getDefaultStats( userId, guildId ); } - -/* - * ============================================================ - * PROFILE EVENTS - * ============================================================ - */ - async function trackProfileEvent( interaction, userId, @@ -243,7 +196,11 @@ async function trackProfileEvent( ) { const guildId = interaction.guildId; - if (!guildId || !userId || !event?.type) { + if ( + !guildId || + !userId || + !event?.type + ) { return; } @@ -261,7 +218,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesObtainedTotal": - Number(event.amount ?? 0) + event.amount } } ); @@ -273,7 +230,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesLostTotal": - Number(event.amount ?? 0) + event.amount } } ); @@ -285,7 +242,7 @@ async function trackProfileEvent( { $inc: { "economy.cookiesGivenTotal": - Number(event.amount ?? 0) + event.amount } } ); @@ -297,9 +254,8 @@ async function trackProfileEvent( { $inc: { "economy.stolenTotal": - Number(event.amount ?? 0) + event.amount }, - $addToSet: { "unique.playersStolenFrom": event.targetUserId @@ -314,9 +270,8 @@ async function trackProfileEvent( { $inc: { "economy.stolenFromMeTotal": - Number(event.amount ?? 0) + event.amount }, - $addToSet: { "unique.playersStolenBy": event.thiefUserId @@ -331,7 +286,7 @@ async function trackProfileEvent( { $inc: { "economy.vaultStolenTotal": - Number(event.amount ?? 0) + event.amount } } ); @@ -345,14 +300,23 @@ async function trackProfileEvent( ); break; + case "game_start": + await trackGameStart( + userId, + guildId, + event + ); + break; + default: return; } - const updatedStats = await getProfileStats( - userId, - guildId - ); + const updatedStats = + await getProfileStats( + userId, + guildId + ); await evaluateAchievements( interaction, @@ -371,12 +335,6 @@ async function trackProfileEvent( } -/* - * ============================================================ - * CENTRAL GAME RESULT - * ============================================================ - */ - async function recordGameOutcome( interaction, userId, @@ -387,102 +345,104 @@ async function recordGameOutcome( ...metadata } ) { - const guildId = interaction.guildId; - - if (!guildId || !userId) { - return; - } - - const normalizedProfit = Number( - profit ?? 0 - ); - - await ensureStatsDocument( + return trackProfileEvent( + interaction, userId, - guildId - ); - - const { stats } = getProfileCollections(); - - /* - * One authoritative game event. - */ - await trackGameResult( - userId, - guildId, { + type: "game_result", game, result, - profit: normalizedProfit, + profit, ...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; } +async function trackGameStart( + userId, + guildId, + event +) { + const { stats } = getProfileCollections(); + + const game = String( + event.game ?? "" + ).toLowerCase(); + + if (!["slots", "roulette", "blackjack"].includes(game)) { + return; + } + + const source = String( + event.source ?? "normal" + ).toLowerCase(); + + const currentPath = + `games.${game}.currentRunItBackStreak`; + + const bestPath = + `games.${game}.bestRunItBackStreak`; + + if (source !== "run_it_back") { + await stats.updateOne( + { userId, guildId }, + { + $set: { + [currentPath]: 0 + } + } + ); + + return; + } + + const updated = await stats.findOneAndUpdate( + { userId, guildId }, + { + $inc: { + [currentPath]: 1 + } + }, + { + returnDocument: "after" + } + ); + + const currentStreak = + updated?.games?.[game]?.currentRunItBackStreak ?? 0; + + const set = {}; + + const previousBet = Number( + event.previousBet ?? 0 + ); + + const currentBet = Number( + event.bet ?? 0 + ); + + const chainedAllIn = + event.previousAllIn === true && + previousBet > 0 && + currentBet === previousBet; + + if (chainedAllIn) { + set["special.allInRunItBack"] = 1; + } + + await stats.updateOne( + { userId, guildId }, + { + $max: { + [bestPath]: currentStreak + }, + ...(Object.keys(set).length + ? { $set: set } + : {}) + } + ); +} -/* - * ============================================================ - * GAME STATS - * ============================================================ - */ async function trackGameResult( userId, @@ -525,7 +485,6 @@ async function trackGameResult( increment["games.total.won"] = 1; increment[`games.${game}.won`] = 1; increment["streaks.currentWin"] = 1; - set["streaks.currentLoss"] = 0; } @@ -533,7 +492,6 @@ async function trackGameResult( increment["games.total.lost"] = 1; increment[`games.${game}.lost`] = 1; increment["streaks.currentLoss"] = 1; - set["streaks.currentWin"] = 0; } @@ -551,12 +509,20 @@ async function trackGameResult( increment[ `games.${game}.cookiesWon` ] = profit; + + increment[ + "economy.cookiesObtainedTotal" + ] = profit; } else if (profit < 0) { increment[ `games.${game}.cookiesLost` ] = Math.abs(profit); + + increment[ + "economy.cookiesLostTotal" + ] = Math.abs(profit); } } @@ -580,22 +546,17 @@ 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, @@ -603,10 +564,6 @@ async function evaluateAchievements( ) { const guildId = interaction.guildId; - if (!guildId) { - return; - } - const { achievements, achievementConfig @@ -626,18 +583,15 @@ async function evaluateAchievements( }) .toArray(); - let unlockedDocument = + let unlockedDoc = await achievements.findOne({ userId, guildId }); let unlocked = - unlockedDocument?.unlocked ?? {}; + unlockedDoc?.unlocked ?? {}; - /* - * Normal DB-backed achievements first. - */ const normalDefinitions = definitions.filter( definition => @@ -646,26 +600,15 @@ async function evaluateAchievements( ); for (const definition of normalDefinitions) { - if ( - unlocked[ - definition.achievementId - ] - ) { + if (unlocked[definition.achievementId]) { continue; } - /* - * Event-only achievements do not have a persisted - * stat path and are intentionally skipped here. - */ - if (!definition.stat) { - continue; - } - - const currentValue = readStatValue( - statsDocument, - definition - ); + const currentValue = + readStatValue( + statsDocument, + definition + ); if ( currentValue < @@ -689,21 +632,15 @@ async function evaluateAchievements( } } - /* - * Refresh after normal unlocks. - */ - unlockedDocument = + unlockedDoc = await achievements.findOne({ userId, guildId }); unlocked = - unlockedDocument?.unlocked ?? {}; + unlockedDoc?.unlocked ?? {}; - /* - * Collector achievements. - */ const collectorDefinitions = definitions.filter( definition => @@ -715,7 +652,8 @@ async function evaluateAchievements( definitions.filter( definition => definition.measure !== - "achievementPercentage" + "achievementPercentage" && + Boolean(definition.stat) ); const totalCollectible = @@ -731,23 +669,19 @@ async function evaluateAchievements( ) ).length; - const percentage = + const achievementPercentage = totalCollectible > 0 ? unlockedCollectible / totalCollectible : 0; for (const definition of collectorDefinitions) { - if ( - unlocked[ - definition.achievementId - ] - ) { + if (unlocked[definition.achievementId]) { continue; } if ( - percentage < + achievementPercentage < Number(definition.target) ) { continue; @@ -769,13 +703,6 @@ async function evaluateAchievements( } } - -/* - * ============================================================ - * UNLOCK ACHIEVEMENT - * ============================================================ - */ - async function unlockAchievement( interaction, userId, @@ -793,7 +720,6 @@ async function unlockAchievement( { userId, guildId, - [field]: { $exists: false } @@ -818,13 +744,6 @@ async function unlockAchievement( return true; } - -/* - * ============================================================ - * STAT READER - * ============================================================ - */ - function readStatValue( document, definition @@ -851,80 +770,32 @@ 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 { - 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 - ); - } + await interaction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle( + "๐Ÿ† Achievement Unlocked!" + ) + .setDescription( + [ + `${achievement.emoji} **${achievement.name}**`, + "", + achievement.description + ].join("\n") + ) + ] + }); + } catch {} } - -/* - * ============================================================ - * BADGE EVALUATION - * ============================================================ - */ - async function evaluateBadges( interaction, userId, @@ -942,9 +813,6 @@ async function evaluateBadges( userId ); - /* - * Economy badges. - */ if (balance >= 100000) { await tryClaimBadge( interaction, @@ -961,19 +829,12 @@ async function evaluateBadges( ); } - /* - * Vault milestone. - */ if ( ( statsDocument ?.economy - ?.vaultStolenTotal - ?? - 0 - ) - >= - 100000 + ?.vaultStolenTotal ?? 0 + ) >= 100000 ) { await tryClaimBadge( interaction, @@ -982,17 +843,13 @@ async function evaluateBadges( ); } - /* - * Unique player theft milestones. - */ + const uniquePlayersStolenFrom = + statsDocument + ?.unique + ?.playersStolenFrom ?? []; + const uniqueStealCount = - ( - statsDocument - ?.unique - ?.playersStolenFrom - ?? - [] - ).length; + uniquePlayersStolenFrom.length; if (uniqueStealCount >= 5) { await tryClaimBadge( @@ -1018,39 +875,37 @@ async function evaluateBadges( ); } - /* - * Slots jackpot. - */ if ( - event.type === - "game_result" - && - event.game === - "slots" - && - event.jackpot === - true + event.type === "game_result" && + event.game === "slots" ) { - await tryClaimBadge( - interaction, - userId, - "first_slots_jackpot" - ); + const tripletBadges = { + "๐Ÿ‹": "first_slots_triple_lemon", + "๐Ÿ’": "first_slots_triple_cherry", + "๐Ÿช": "first_slots_triple_cookie", + "โญ": "first_slots_triple_star", + "๐Ÿ’Ž": "first_slots_jackpot" + }; + + const badgeId = + tripletBadges[event.triplet]; + + if (badgeId) { + await tryClaimBadge( + interaction, + userId, + badgeId + ); + } } - /* - * Natural Blackjack. - */ if ( event.type === - "game_result" - && + "game_result" && event.game === - "blackjack" - && + "blackjack" && event.natural === - true - && + true && event.result === "win" ) { @@ -1061,19 +916,13 @@ 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" ) { @@ -1084,19 +933,13 @@ async function evaluateBadges( ); } - /* - * Roulette Zero. - */ if ( event.type === - "game_result" - && + "game_result" && event.game === - "roulette" - && + "roulette" && event.zero === - true - && + true && event.result === "win" ) { @@ -1107,19 +950,13 @@ async function evaluateBadges( ); } - /* - * Roulette All-In. - */ if ( event.type === - "game_result" - && + "game_result" && event.game === - "roulette" - && + "roulette" && event.allIn === - true - && + true && event.result === "win" ) { @@ -1130,19 +967,26 @@ async function evaluateBadges( ); } + if ( + event.type === "game_start" && + event.source === "run_it_back" && + event.previousAllIn === true && + Number(event.previousBet) > 0 && + Number(event.bet) === Number(event.previousBet) + ) { + await tryClaimBadge( + interaction, + userId, + "first_all_in_run_it_back" + ); + } + await evaluateCollectorBadges( interaction, userId ); } - -/* - * ============================================================ - * COLLECTOR BADGES - * ============================================================ - */ - async function evaluateCollectorBadges( interaction, userId @@ -1155,18 +999,21 @@ async function evaluateCollectorBadges( } = getProfileCollections(); const activeAchievements = - await achievementConfig - .find({ - active: { - $ne: false - }, - - measure: { - $ne: - "achievementPercentage" - } - }) - .toArray(); + ( + await achievementConfig + .find({ + active: { + $ne: false + }, + measure: { + $ne: + "achievementPercentage" + } + }) + .toArray() + ).filter(achievement => + Boolean(achievement.stat) + ); if ( activeAchievements.length === @@ -1206,7 +1053,6 @@ async function evaluateCollectorBadges( { unlockedAchievements: unlockedCount, - availableAchievements: activeAchievements.length } @@ -1221,7 +1067,6 @@ async function evaluateCollectorBadges( { unlockedAchievements: unlockedCount, - availableAchievements: activeAchievements.length } @@ -1229,13 +1074,6 @@ async function evaluateCollectorBadges( } } - -/* - * ============================================================ - * CLAIM GLOBAL BADGE - * ============================================================ - */ - async function tryClaimBadge( interaction, userId, @@ -1257,8 +1095,7 @@ async function tryClaimBadge( }; if ( - metadata - && + metadata && typeof metadata === "object" ) { @@ -1282,8 +1119,7 @@ async function tryClaimBadge( ); if ( - !badge - || + !badge || badge.ownerId !== userId ) { @@ -1295,31 +1131,28 @@ async function tryClaimBadge( "", `<@${userId}> claimed a guild-unique badge!`, "", - badge.description - ?? - "A unique badge has been claimed.", - "" + badge.description ?? + "A unique badge has been claimed.", + "", + "*This badge can no longer be earned by another player.*" ]; if ( badge.metadata ?.unlockedAchievements !== - undefined - && + undefined && badge.metadata ?.availableAchievements !== undefined ) { - description.push( + description.splice( + description.length - 1, + 0, `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: [ @@ -1337,9 +1170,7 @@ async function tryClaimBadge( .setTimestamp() ] }); - } - - catch (error) { + } catch (error) { console.error( `[Badge] Failed to announce ${badgeId} for ${userId}:`, error @@ -1349,333 +1180,10 @@ 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("sessionClose") - .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( @@ -1695,57 +1203,32 @@ async function buildProfileOverview( } = getProfileCollections(); const [ - achievementDocument, - activeDefinitions, + achievementDoc, + achievementCount, ownedBadges ] = await Promise.all([ achievements.findOne({ - userId: - user.id, - + userId: user.id, guildId }), - achievementConfig - .find({ - active: { - $ne: false - } - }) - .toArray(), + achievementConfig.countDocuments({ + active: { + $ne: false + } + }), badges.find({ guildId, - - ownerId: - user.id + ownerId: user.id }).toArray() ]); - const unlocked = - achievementDocument?.unlocked ?? {}; - const unlockedCount = - activeDefinitions.filter( - achievement => - Boolean( - unlocked[ - achievement.achievementId - ] - ) + Object.keys( + achievementDoc?.unlocked ?? {} ).length; - const achievementCount = - activeDefinitions.length; - - const completion = - achievementCount > 0 - ? ( - unlockedCount / - achievementCount - ) * 100 - : 0; - return new EmbedBuilder() .setTitle( `๐Ÿ‘ค ${user.globalName ?? user.username}'s Profile` @@ -1757,66 +1240,40 @@ async function buildProfileOverview( ) .addFields( { - name: - "๐Ÿช Cookies", - + name: "๐Ÿช Cookies", value: balance.toLocaleString(), - - inline: - true + inline: true }, - { - name: - "๐Ÿ† Achievements", - + name: "๐Ÿ† Achievements", value: - `${unlockedCount} / ${achievementCount}\n**${completion.toFixed(1)}%**`, - - inline: - true + `${unlockedCount} / ${achievementCount}`, + inline: true }, - { - name: - "๐Ÿ… Unique Badges", - + name: "๐Ÿ… Unique Badges", value: `${ownedBadges.length}`, - - inline: - true + 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: "๐ŸŽฎ Games", + value: [ + `Played: **${stats.games.total.played}**`, + `Won: **${stats.games.total.won}**`, + `Lost: **${stats.games.total.lost}**`, + `Pushes: **${stats.games.total.pushed}**` + ].join("\n"), + inline: true }, - { - name: - "๐Ÿ”ฅ Best Streaks", - - value: - [ - `Wins: **${stats.streaks?.bestWin ?? 0}**`, - `Losses: **${stats.streaks?.bestLoss ?? 0}**` - ].join("\n"), - - inline: - true + name: "๐Ÿ”ฅ Best Streaks", + value: [ + `Wins: **${stats.streaks.bestWin}**`, + `Losses: **${stats.streaks.bestLoss}**` + ].join("\n"), + inline: true } ) .setFooter({ @@ -1831,18 +1288,10 @@ async function buildProfileOverview( }); } - -/* - * ============================================================ - * STATS PAGE - * ============================================================ - */ - -async function buildProfileStatsEmbed( +async function buildStatsEmbed( guildId, user, - sessionOwnerId = - user.id + sessionOwnerId = user.id ) { const stats = await getProfileStats( @@ -1850,146 +1299,280 @@ async function buildProfileStatsEmbed( guildId ); - const embed = - new EmbedBuilder() - .setTitle( - `๐Ÿ“Š ${user.globalName ?? user.username}'s Stats` - ) - .setThumbnail( - user.displayAvatarURL({ - size: 256 - }) - ); - - const games = [ - [ - "๐ŸŽฐ", - "Slots", - stats.games?.slots - ], - - [ - "๐Ÿƒ", - "Blackjack", - stats.games?.blackjack - ], - - [ - "๐ŸŽก", - "Roulette", - stats.games?.roulette - ], - - [ - "๐Ÿฆ", - "Heist", - stats.games?.heist - ] - ]; - - for ( - const [ - emoji, - name, - game - ] - of games - ) { + const gameLines = [ + ["๐ŸŽฐ Slots", stats.games?.slots], + ["๐Ÿƒ Blackjack", stats.games?.blackjack], + ["๐ŸŽก Roulette", stats.games?.roulette], + ["๐Ÿฆ Heist", stats.games?.heist] + ].map(([name, game = {}]) => { const played = - game?.played ?? 0; + Number(game.played ?? 0); const won = - game?.won ?? 0; + Number(game.won ?? 0); const lost = - game?.lost ?? 0; + Number(game.lost ?? 0); const pushed = - game?.pushed ?? 0; + Number(game.pushed ?? 0); const cookiesWon = - game?.cookiesWon ?? 0; + Number(game.cookiesWon ?? 0); const cookiesLost = - game?.cookiesLost ?? 0; - - const net = - cookiesWon - - cookiesLost; + Number(game.cookiesLost ?? 0); const decidedGames = - won + - lost; + won + lost; const winRate = decidedGames > 0 - ? ( - won / - decidedGames - ) * 100 + ? (won / decidedGames) * 100 : 0; - embed.addFields({ - name: - `${emoji} ${name}`, + const net = + cookiesWon - cookiesLost; - 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()} ๐Ÿช**` + return [ + `**${name}**`, + `Played **${played}** โ€ข W **${won}** โ€ข L **${lost}** โ€ข Push **${pushed}**`, + `Win rate: **${winRate.toFixed(1)}%**`, + `Won **${cookiesWon.toLocaleString()} ๐Ÿช** โ€ข Lost **${cookiesLost.toLocaleString()} ๐Ÿช**`, + `Net: **${net >= 0 ? "+" : ""}${net.toLocaleString()} ๐Ÿช**` + ].join("\n"); + }); + + const economy = + stats.economy ?? {}; + + const unique = + stats.unique ?? {}; + + const streaks = + stats.streaks ?? {}; + + return new EmbedBuilder() + .setTitle( + `๐Ÿ“Š ${user.globalName ?? user.username}'s Stats` + ) + .setThumbnail( + user.displayAvatarURL({ + size: 256 + }) + ) + .addFields( + { + name: "๐Ÿช Economy", + value: [ + `Obtained: **${Number(economy.cookiesObtainedTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Lost: **${Number(economy.cookiesLostTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Given: **${Number(economy.cookiesGivenTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Stolen: **${Number(economy.stolenTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Stolen from you: **${Number(economy.stolenFromMeTotal ?? 0).toLocaleString()} ๐Ÿช**`, + `Vault stolen: **${Number(economy.vaultStolenTotal ?? 0).toLocaleString()} ๐Ÿช**` + ].join("\n") + }, + { + name: "๐Ÿฅท Unique Players", + value: [ + `Stolen from: **${unique.playersStolenFrom?.length ?? 0}**`, + `Stolen by: **${unique.playersStolenBy?.length ?? 0}**` ].join("\n"), - - inline: - true + inline: true + }, + { + name: "๐Ÿ”ฅ Streaks", + value: [ + `Current wins: **${streaks.currentWin ?? 0}**`, + `Best wins: **${streaks.bestWin ?? 0}**`, + `Current losses: **${streaks.currentLoss ?? 0}**`, + `Best losses: **${streaks.bestLoss ?? 0}**` + ].join("\n"), + inline: true + }, + { + name: "๐ŸŽฎ Per Game", + value: + gameLines.join("\n\n") + } + ) + .setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "stats", + 0, + 1 + ) }); - } - - embed.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") - }); - - embed.setFooter({ - text: - buildProfileFooter( - sessionOwnerId, - user.id, - "stats", - 0, - 1 - ) - }); - - return embed; } +async function buildBadgesEmbed( + guildId, + user, + sessionOwnerId = user.id, + page = 0 +) { + const { badges } = + getProfileCollections(); -/* - * ============================================================ - * ACHIEVEMENTS PAGE - * ============================================================ - */ + const documents = + await badges.find({ + guildId + }).sort({ + category: 1, + name: 1 + }).toArray(); + + const owned = + documents.filter( + badge => + badge.ownerId === + user.id + ); + + const pages = []; + + pages.push({ + title: "Owned", + content: + owned.length + ? owned.map( + badge => + formatBadgeLine( + badge, + user.id, + true + ) + ).join("\n\n") + : "*No unique badges yet.*" + }); + + const categoryOrder = [ + "economy", + "stealing", + "slots", + "blackjack", + "roulette", + "heist", + "collector" + ]; + + const categories = + [...new Set( + documents.map( + badge => + badge.category ?? + "other" + ) + )].sort( + (a, b) => { + const ai = + categoryOrder.indexOf(a); + const bi = + categoryOrder.indexOf(b); + + if ( + ai === -1 && + bi === -1 + ) { + return a.localeCompare(b); + } + + if (ai === -1) { + return 1; + } + + if (bi === -1) { + return -1; + } + + return ai - bi; + } + ); + + for (const category of categories) { + const categoryBadges = + documents.filter( + badge => + (badge.category ?? "other") === + category + ); + + if (!categoryBadges.length) { + continue; + } + + const chunks = + paginateLines( + categoryBadges.map( + badge => + formatBadgeLine( + badge, + user.id, + false + ) + ), + 3200, + "\n\n" + ); + + for ( + let index = 0; + index < chunks.length; + index++ + ) { + pages.push({ + title: + `${formatCategoryName(category)}${chunks.length > 1 ? ` ${index + 1}/${chunks.length}` : ""}`, + content: + chunks[index] + }); + } + } + + const safePage = + clampPage( + page, + pages.length + ); + + const selected = + pages[safePage] ?? { + title: "Badges", + content: + "No badges configured." + }; + + return new EmbedBuilder() + .setTitle( + `๐Ÿ… ${user.globalName ?? user.username}'s Badges` + ) + .setDescription( + [ + `### ${selected.title}`, + selected.content + ].join("\n") + ) + .setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "badges", + safePage, + pages.length + ) + }); +} async function buildAchievementsEmbed( guildId, user, - page = 0, - sessionOwnerId = - user.id + sessionOwnerId = user.id, + page = 0 ) { const { achievements, @@ -1997,13 +1580,12 @@ async function buildAchievementsEmbed( } = getProfileCollections(); const [ - unlockedDocument, - definitions + unlockedDoc, + definitions, + statsDocument ] = await Promise.all([ achievements.findOne({ - userId: - user.id, - + userId: user.id, guildId }), @@ -2018,14 +1600,41 @@ async function buildAchievementsEmbed( target: 1, name: 1 }) - .toArray() + .toArray(), + + getProfileStats( + user.id, + guildId + ) ]); const unlocked = - unlockedDocument?.unlocked ?? {}; + unlockedDoc?.unlocked ?? {}; + + const collectibleDefinitions = + definitions.filter( + definition => + definition.measure !== + "achievementPercentage" + ); + + const unlockedCollectible = + collectibleDefinitions.filter( + definition => + Boolean( + unlocked[ + definition.achievementId + ] + ) + ).length; + + const collectorPercentage = + collectibleDefinitions.length > 0 + ? unlockedCollectible / + collectibleDefinitions.length + : 0; const lines = []; - let lastCategory = null; for (const achievement of definitions) { @@ -2037,59 +1646,75 @@ async function buildAchievementsEmbed( ); const category = - achievement.category - ?? + achievement.category ?? "other"; - if ( - category !== - lastCategory - ) { + if (category !== lastCategory) { + if (lines.length) { + lines.push(""); + } + lines.push( `### ${formatCategoryName(category)}` ); - lastCategory = - category; + lastCategory = category; } - const hidden = - achievement.secret === - true - && - !complete; - - if (hidden) { + if ( + achievement.secret === true && + !complete + ) { lines.push( - "โฌœ โ“ **???** โ€” Hidden achievement." + "โฌœ โ“ **???** โ€” Hidden achievement" ); - continue; } + let currentValue; + let targetValue = + Number(achievement.target ?? 0); + let progressText = ""; + + if ( + achievement.measure === + "achievementPercentage" + ) { + currentValue = + collectorPercentage; + + progressText = + ` โ€ข **${Math.min(100, currentValue * 100).toFixed(0)}% / ${Math.min(100, targetValue * 100).toFixed(0)}%**`; + } + + else if (achievement.stat) { + currentValue = + readStatValue( + statsDocument, + achievement + ); + + progressText = + ` โ€ข **${Number(currentValue).toLocaleString()} / ${targetValue.toLocaleString()}**`; + } + + const requirement = + achievement.requirement ?? + achievement.description ?? + "Requirement unavailable."; + lines.push( [ - complete - ? "โœ…" - : "โฌœ", - + complete ? "โœ…" : "โฌœ", " ", - - achievement.emoji - ?? - "๐Ÿ†", - + achievement.emoji ?? "๐Ÿ†", " **", - achievement.name, - "** โ€” ", - - achievement.requirement - ?? - achievement.description - ?? - "Requirement unavailable." + requirement, + complete + ? "" + : progressText ].join("") ); } @@ -2097,9 +1722,16 @@ async function buildAchievementsEmbed( const pages = paginateLines( lines, - PROFILE_PAGE_CHARACTER_LIMIT + 3400, + "\n" ); + if (!pages.length) { + pages.push( + "No achievements configured." + ); + } + const safePage = clampPage( page, @@ -2116,1054 +1748,374 @@ async function buildAchievementsEmbed( ) ).length; - const embed = - new EmbedBuilder() - .setTitle( - `๐Ÿ† ${user.globalName ?? user.username}'s Achievements` - ) - .setDescription( - pages[safePage] - ?? - "No achievements configured." - ) - .addFields({ - name: - "Progress", + const completion = + definitions.length > 0 + ? (unlockedCount / definitions.length) * 100 + : 0; - value: - `**${unlockedCount} / ${definitions.length}** achievements unlocked` - }) - .setFooter({ - text: - buildProfileFooter( - sessionOwnerId, - user.id, - "achievements", - safePage, - pages.length - ) - }); - - return { - embed, - page: - safePage, - pageCount: - pages.length - }; -} - - -/* - * ============================================================ - * BADGES PAGE - * ============================================================ - */ - -async function buildBadgesEmbed( - guildId, - user, - page = 0, - sessionOwnerId = - user.id -) { - const { badges } = - getProfileCollections(); - - const documents = - await badges - .find({ - guildId - }) - .sort({ - category: 1, - name: 1 - }) - .toArray(); - - const categories = - groupBadgesByCategory( - documents - ); - - const pages = []; - - /* - * 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") + return new EmbedBuilder() + .setTitle( + `๐Ÿ† ${user.globalName ?? user.username}'s Achievements` + ) + .setDescription( + pages[safePage] + ) + .addFields({ + name: "Progress", + value: + `**${unlockedCount} / ${definitions.length}** unlocked โ€ข **${completion.toFixed(1)}%** complete` + }) + .setFooter({ + text: + buildProfileFooter( + sessionOwnerId, + user.id, + "achievements", + safePage, + pages.length + ) }); - } +} - 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 +function buildProfileButtons( + section = "overview", + page = 0, + pageCount = 1 +) { + const rows = [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "profileOverview" ) - }); + .setLabel( + "Overview" + ) + .setEmoji( + "๐Ÿ‘ค" + ) + .setStyle( + section === "overview" + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileStats" + ) + .setLabel( + "Stats" + ) + .setEmoji( + "๐Ÿ“Š" + ) + .setStyle( + section === "stats" + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileBadges" + ) + .setLabel( + "Badges" + ) + .setEmoji( + "๐Ÿ…" + ) + .setStyle( + section === "badges" + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileAchievements" + ) + .setLabel( + "Achievements" + ) + .setEmoji( + "๐Ÿ†" + ) + .setStyle( + section === "achievements" + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "profileClose" + ) + .setLabel( + "Close" + ) + .setEmoji( + "โœ–๏ธ" + ) + .setStyle( + ButtonStyle.Danger + ) + ) + ]; + + if ( + [ + "badges", + "achievements" + ].includes(section) && + pageCount > 1 + ) { + const prefix = + section === "badges" + ? "profileBadges" + : "profileAchievements"; + + rows.push( + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + `${prefix}Left` + ) + .setLabel( + "Previous" + ) + .setEmoji( + "โ—€๏ธ" + ) + .setDisabled( + page <= 0 + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + `${prefix}Page` + ) + .setLabel( + `${page + 1} / ${pageCount}` + ) + .setDisabled( + true + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + `${prefix}Right` + ) + .setLabel( + "Next" + ) + .setEmoji( + "โ–ถ๏ธ" + ) + .setDisabled( + page >= + pageCount - 1 + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ); + } + + return rows; +} + +function buildProfileFooter( + sessionOwnerId, + profileUserId, + section, + page, + pageCount +) { + return [ + "profile", + sessionOwnerId, + profileUserId, + section, + page, + pageCount + ].join(":"); +} + +function getProfileContext( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + return getProfileContextFromFooter( + footer + ); +} + +function getProfileContextFromFooter( + footer +) { + const [ + type, + sessionOwnerId, + profileUserId, + section = "overview", + rawPage = "0", + rawPageCount = "1" + ] = footer?.split(":") ?? []; + + if ( + type !== "profile" || + !sessionOwnerId || + !profileUserId + ) { + return null; + } return { - embed, + sessionOwnerId, + profileUserId, + section, page: - safePage, + Number(rawPage) || 0, pageCount: - pages.length + Math.max( + 1, + Number(rawPageCount) || 1 + ) }; } - -/* - * ============================================================ - * BADGE DISPLAY HELPERS - * ============================================================ - */ - -function groupBadgesByCategory( - badges +function getProfileTargetId( + interaction ) { - const categories = - new Map(); - - for (const badge of badges) { - const category = - badge.category - ?? - inferBadgeCategory( - badge.badgeId - ); - - if ( - !categories.has( - category - ) - ) { - categories.set( - category, - [] - ); - } - - categories - .get(category) - .push(badge); - } - - return categories; + return getProfileContext( + interaction + )?.profileUserId ?? null; } - -function inferBadgeCategory( - badgeId = "" +async function requireProfileOwner( + interaction ) { - 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 formatBadgeLine( - badge, - profileUserId -) { - const hidden = - badge.secret === - true - && - !badge.ownerId; - - if (hidden) { - return [ - "โ“ **???**", - "*Hidden global badge.*" - ].join("\n"); - } - - let status; - - if ( - badge.ownerId === - profileUserId - ) { - status = - "โœ… **Owned by this player**"; - } - - else if ( - badge.ownerId - ) { - status = - `๐Ÿ”’ Claimed by <@${badge.ownerId}>`; - } - - else { - status = - "โœจ **Still available**"; - } - - const lines = [ - `${badge.emoji ?? "๐Ÿ…"} **${badge.name ?? badge.badgeId}**`, - badge.description - ?? - "No description.", - status - ]; - - if (badge.unlockedAt) { - lines.push( - `Claimed ` + const context = + getProfileContext( + interaction ); + + if (!context) { + await replyProfileError( + interaction, + "This profile session is no longer valid." + ); + + return null; } if ( - badge.metadata - ?.unlockedAchievements !== - undefined - && - badge.metadata - ?.availableAchievements !== - undefined + context.sessionOwnerId !== + interaction.user.id ) { - lines.push( - `Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**` + await replyProfileError( + interaction, + "This isn't your profile session." ); + + return null; } - return lines.join("\n"); + return context; } - -/* - * ============================================================ - * RECONCILE ACHIEVEMENTS - * ============================================================ - */ - -async function reconcileProfileAchievements( - guildId, - { - userId = null, - dryRun = false - } = {} +async function replyProfileError( + interaction, + content ) { - const { - stats, - achievements, - achievementConfig - } = getProfileCollections(); - - const profileFilter = { - guildId + const payload = { + content, + flags: + MessageFlags.Ephemeral }; - 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 + if ( + interaction.replied || + interaction.deferred ) { - warnings.push( - `Unlocked achievement has no config definition: ${achievementId}` + return interaction.followUp( + payload ); } - -/* - * ============================================================ - * 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 - }; + return interaction.reply( + payload + ); } -/* - * ============================================================ - * COMPATIBLITY HELPERS - * ============================================================ - */ - -const buildStatsEmbed = - buildProfileStatsEmbed; - -/* - * ============================================================ - * PAGINATION HELPERS - * ============================================================ - */ - function paginateLines( lines, - maxLength = - PROFILE_PAGE_CHARACTER_LIMIT + maxLength = 3400, + separator = "\n" ) { const pages = []; - let current = []; for (const line of lines) { const candidate = - [ - ...current, - line - ].join("\n"); + [...current, line].join( + separator + ); if ( candidate.length > - maxLength - && - current.length > - 0 + maxLength && + current.length ) { pages.push( - current.join("\n") + current.join( + separator + ) ); - current = [ - line - ]; + current = [line]; } else { - current.push( - line - ); + current.push(line); } } - if ( - current.length > - 0 - ) { + if (current.length) { pages.push( - current.join("\n") - ); - } - - if ( - pages.length === - 0 - ) { - pages.push( - "Nothing to display." + current.join( + separator + ) ); } return pages; } - function clampPage( page, pageCount ) { - const numeric = - Number(page) || 0; - return Math.max( 0, Math.min( - numeric, + Number(page) || 0, Math.max( 0, pageCount - 1 @@ -3172,63 +2124,89 @@ function clampPage( ); } - function formatCategoryName( category ) { - return String( - category ?? "other" - ) - .replace( - /[_-]+/g, - " " - ) - .replace( - /\b\w/g, - character => - character.toUpperCase() - ); + 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 +) { + const emoji = + badge.emoji ?? "๐Ÿ…"; -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ + const description = + badge.description ?? + "No description available."; + + if (ownedPage) { + return `${emoji} **${badge.name}**\n${description}`; + } + + let status; + + if ( + badge.ownerId === + profileUserId + ) { + status = "โœ… **Owned by this player**"; + } + + else if (badge.ownerId) { + status = `๐Ÿ”’ Claimed by <@${badge.ownerId}>`; + } + + else { + status = "๐ŸŸข **Available**"; + } + + return [ + `${emoji} **${badge.name}**`, + description, + status + ].join("\n"); +} module.exports = { ensureProfileSystem, - - getProfileCollections, - getProfileStats, - getDefaultStats, - trackProfileEvent, recordGameOutcome, - - evaluateAchievements, - evaluateBadges, - tryClaimBadge, + getProfileStats, + getProfileCollections, + getDefaultStats, + readStatValue, buildProfileOverview, - - buildProfileStatsEmbed, buildStatsEmbed, - - buildAchievementsEmbed, buildBadgesEmbed, - + buildAchievementsEmbed, buildProfileButtons, - buildProfilePaginationButtons, - - buildProfileFooter, - parseProfileFooter, + getProfileContext, + getProfileContextFromFooter, getProfileTargetId, - getProfileSessionOwnerId, - requireProfileOwner, - - reconcileProfileAchievements, - auditProfileSystem -}; \ No newline at end of file + requireProfileOwner +};