From c2ac7737c00803bdf9bfb6ee61662af4b93ab3a8 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Thu, 27 Aug 2026 18:52:56 +0200 Subject: [PATCH] snapshot rollout phase 1 --- .gitignore | 1 + buttons/crumbSaucer/profile.js | 58 ++++ commands/applications/profileAdmin.js | 75 ++++- utils/cookieHeist.js | 12 +- utils/crumbRoulette.js | 5 +- utils/crumbSaucer.js | 13 +- utils/permissions.js | 189 +++--------- utils/profileSystem.js | 413 +++++++++++++++++++++++++- 8 files changed, 595 insertions(+), 171 deletions(-) create mode 100644 buttons/crumbSaucer/profile.js diff --git a/.gitignore b/.gitignore index 37d7e73..c96e947 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .env +backup/ \ No newline at end of file diff --git a/buttons/crumbSaucer/profile.js b/buttons/crumbSaucer/profile.js new file mode 100644 index 0000000..b195c56 --- /dev/null +++ b/buttons/crumbSaucer/profile.js @@ -0,0 +1,58 @@ +const { + buildProfileOverview, + buildProfileButtons +} = require("../../utils/profileSystem"); + +const { + temporaryEphemeralReply +} = require("../../utils/cookieEconomy"); + +module.exports = { + name: "crumbSaucerProfile", + + async execute(interaction) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + const [ + type, + ownerId + ] = footer?.split(":") ?? []; + + + if ( + type !== "crumbSaucer" || + ownerId !== interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This isn't your Crumb Saucer session." + } + ); + } + + + const embed = + await buildProfileOverview( + interaction.guildId, + interaction.user, + interaction.user.id + ); + + + return interaction.update({ + embeds: [embed], + components: + buildProfileButtons( + "overview", + 0, + 1 + ) + }); + } +}; \ No newline at end of file diff --git a/commands/applications/profileAdmin.js b/commands/applications/profileAdmin.js index 4cd60ff..de57224 100644 --- a/commands/applications/profileAdmin.js +++ b/commands/applications/profileAdmin.js @@ -1,6 +1,5 @@ const { SlashCommandBuilder, - PermissionFlagsBits, MessageFlags, EmbedBuilder } = require("discord.js"); @@ -13,6 +12,13 @@ const { "../../utils/profileMaintenance" ); +const { + requireAdminAccess +} = require( + "../../utils/permissions" +); + + module.exports = { data: new SlashCommandBuilder() @@ -22,9 +28,6 @@ module.exports = { .setDescription( "Administrative profile maintenance tools" ) - .setDefaultMemberPermissions( - PermissionFlagsBits.Administrator - ) .addSubcommand( subcommand => subcommand @@ -69,14 +72,32 @@ module.exports = { async execute( interaction ) { + /* + * Allows: + * + * - Discord Administrators + * - BOT_AUTHOR_ID from .env + */ + + if ( + !await requireAdminAccess( + interaction + ) + ) { + return; + } + + const subcommand = interaction.options.getSubcommand(); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + if ( subcommand === "reconcile" @@ -86,21 +107,26 @@ module.exports = { "user" ); + const dryRun = interaction.options.getBoolean( "dry-run" ) ?? false; + if (user) { const result = await reconcileUserAchievements({ guildId: interaction.guildId, + userId: user.id, + dryRun }); + return interaction.editReply({ embeds: [ new EmbedBuilder() @@ -114,24 +140,30 @@ module.exports = { { 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 } @@ -140,13 +172,16 @@ module.exports = { }); } + const summary = await reconcileAllAchievements({ guildId: interaction.guildId, + dryRun }); + return interaction.editReply({ embeds: [ new EmbedBuilder() @@ -160,24 +195,30 @@ module.exports = { { 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 } @@ -186,15 +227,23 @@ module.exports = { }); } + const audit = await auditProfileData( interaction.guildId ); + const issuePreview = audit.issues - .slice(0, 10) - .join("\n"); + .slice( + 0, + 10 + ) + .join( + "\n" + ); + return interaction.editReply({ embeds: [ @@ -206,48 +255,60 @@ module.exports = { { 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 } @@ -260,4 +321,4 @@ module.exports = { ] }); } -}; +}; \ No newline at end of file diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js index 1617570..7f058df 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -24,30 +24,30 @@ const HEIST_COOLDOWN = const HEIST_METHODS = { Sneak: { - baseChance: 0.65, + baseChance: 0.60, minStealPercent: 0.01, maxStealPercent: 0.03, - maxStealAmount: 1500, + maxStealAmount: 5000, minPenaltyPercent: 0.01, maxPenaltyPercent: 0.04, maxPenaltyAmount: 750 }, Hack: { - baseChance: 0.45, + baseChance: 0.40, minStealPercent: 0.03, maxStealPercent: 0.06, - maxStealAmount: 3500, + maxStealAmount: 10000, minPenaltyPercent: 0.03, maxPenaltyPercent: 0.07, maxPenaltyAmount: 2000 }, Smash: { - baseChance: 0.25, + baseChance: 0.20, minStealPercent: 0.06, maxStealPercent: 0.12, - maxStealAmount: 7500, + maxStealAmount: 15000, minPenaltyPercent: 0.06, maxPenaltyPercent: 0.12, maxPenaltyAmount: 5000 diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index 2c154eb..e4ba23c 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -339,7 +339,10 @@ async function runRouletteBack( return showRouletteChoices( interaction, bet, - false + false, + "run_it_back", + previousAllIn, + bet ); } diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js index 056efd5..5c89275 100644 --- a/utils/crumbSaucer.js +++ b/utils/crumbSaucer.js @@ -105,7 +105,7 @@ function buildMainMenuButtons() { .setCustomId("crumbSaucerBlackjack") .setLabel("Blackjack") .setEmoji("🃏") - .setStyle(ButtonStyle.Success), + .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId("crumbSaucerRoulette") @@ -126,19 +126,22 @@ function buildMainMenuButtons() { .setCustomId("crumbSaucerCollectMenu") .setLabel("Cookie Bakery") .setEmoji("🍪") - .setStyle(ButtonStyle.Success), - + .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId("crumbSaucerBankruptcy") .setLabel("Bankruptcy") .setEmoji("🛟") .setStyle(ButtonStyle.Secondary), - + new ButtonBuilder() + .setCustomId("crumbSaucerProfile") + .setLabel("Profile") + .setEmoji("👤") + .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId("sessionClose") .setLabel("Close") .setEmoji("✖️") - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Danger) ) ]; } diff --git a/utils/permissions.js b/utils/permissions.js index bc299eb..a58ed6b 100644 --- a/utils/permissions.js +++ b/utils/permissions.js @@ -1,118 +1,46 @@ const { PermissionFlagsBits, MessageFlags -} = require( - "discord.js" -); +} = 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 - ); + 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 - ) + return Boolean( + authorId && + userId && + String(userId) === String(authorId) ); } -/* - * ============================================================ - * GUILD ADMINISTRATOR - * ============================================================ - */ - function isAdministrator( interaction ) { - if ( - !interaction + return ( + interaction ?.memberPermissions - ) { - return false; - } - - - return interaction - .memberPermissions - .has( - PermissionFlagsBits.Administrator - ); + ?.has( + PermissionFlagsBits.Administrator + ) + ?? + false + ); } -/* - * ============================================================ - * 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 @@ -125,57 +53,6 @@ function canUseAdminCommands( } -/* - * ============================================================ - * 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 ) { @@ -188,29 +65,39 @@ async function requireAdminAccess( } - await denyAdminAccess( - interaction - ); + const payload = { + content: + "You don't have permission to use this command.", + + flags: + MessageFlags.Ephemeral + }; + + + if ( + interaction.replied || + interaction.deferred + ) { + await interaction.followUp( + payload + ); + } + + else { + await interaction.reply( + payload + ); + } return false; } -/* - * ============================================================ - * EXPORTS - * ============================================================ - */ - module.exports = { getBotAuthorId, - isBotAuthor, isAdministrator, - canUseAdminCommands, - requireAdminAccess, - - denyAdminAccess + requireAdminAccess }; \ No newline at end of file diff --git a/utils/profileSystem.js b/utils/profileSystem.js index 6e661a1..93eb4b1 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -18,6 +18,352 @@ const COLLECTIONS = { achievementConfig: "config_achievements" }; +const PROFILE_TIMEZONE = + process.env.PROFILE_TIMEZONE ?? + "Europe/Berlin"; + +const WEEKDAYS = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" +]; +function getActivityDateContext( + date = new Date() +) { + const formatter = + new Intl.DateTimeFormat( + "en-CA", + { + timeZone: + PROFILE_TIMEZONE, + + year: + "numeric", + + month: + "2-digit", + + day: + "2-digit", + + weekday: + "long", + + hour: + "2-digit", + + hourCycle: + "h23" + } + ); + + const parts = + Object.fromEntries( + formatter + .formatToParts(date) + .filter( + part => + part.type !== + "literal" + ) + .map( + part => [ + part.type, + part.value + ] + ) + ); + + const dateKey = + `${parts.year}-${parts.month}-${parts.day}`; + + const weekday = + String( + parts.weekday + ).toLowerCase(); + + const hour = + Number( + parts.hour + ); + + return { + dateKey, + yesterdayKey: + getPreviousDateKey( + dateKey + ), + + weekday, + hour, + + weekKey: + getIsoWeekKey( + dateKey + ), + + timeBucket: + getTimeBucket( + hour + ) + }; +} + + +function getPreviousDateKey( + dateKey +) { + const date = + new Date( + `${dateKey}T00:00:00Z` + ); + + date.setUTCDate( + date.getUTCDate() - 1 + ); + + return date + .toISOString() + .slice( + 0, + 10 + ); +} + + +function getIsoWeekKey( + dateKey +) { + const date = + new Date( + `${dateKey}T00:00:00Z` + ); + + const day = + date.getUTCDay() || 7; + + date.setUTCDate( + date.getUTCDate() + + 4 - + day + ); + + const year = + date.getUTCFullYear(); + + const yearStart = + new Date( + Date.UTC( + year, + 0, + 1 + ) + ); + + const week = + Math.ceil( + ( + ( + date - + yearStart + ) + / + 86400000 + + + 1 + ) + / + 7 + ); + + return ( + `${year}-W${String(week).padStart(2, "0")}` + ); +} + + +function getTimeBucket( + hour +) { + if ( + hour >= 0 && + hour <= 5 + ) { + return "overnight"; + } + + if ( + hour >= 6 && + hour <= 11 + ) { + return "morning"; + } + + if ( + hour >= 12 && + hour <= 17 + ) { + return "afternoon"; + } + + return "evening"; +} + +async function trackGameActivity( + userId, + guildId +) { + const { stats } = + getProfileCollections(); + + const { + dateKey, + yesterdayKey, + weekday, + weekKey, + timeBucket + } = + getActivityDateContext(); + + + /* + * Every completed game increments its weekday + * and time-bucket counters. + */ + + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + [`activity.gamesByWeekday.${weekday}`]: + 1, + + [`activity.gamesByTimeBucket.${timeBucket}`]: + 1 + } + } + ); + + + /* + * Calendar-day state is only updated once per + * player per local calendar day. + * + * The filter also protects against two games + * resolving at nearly the same moment. + */ + + await stats.updateOne( + { + userId, + guildId, + + "activity.lastGameDate": { + $ne: + dateKey + } + }, + [ + { + $set: { + "activity.currentDailyStreak": { + $cond: [ + { + $eq: [ + "$activity.lastGameDate", + yesterdayKey + ] + }, + + { + $add: [ + { + $ifNull: [ + "$activity.currentDailyStreak", + 0 + ] + }, + 1 + ] + }, + + 1 + ] + }, + + "activity.currentWeekDays": { + $cond: [ + { + $eq: [ + "$activity.currentWeekKey", + weekKey + ] + }, + + { + $setUnion: [ + { + $ifNull: [ + "$activity.currentWeekDays", + [] + ] + }, + + [ + weekday + ] + ] + }, + + [ + weekday + ] + ] + }, + + "activity.currentWeekKey": + weekKey, + + "activity.lastActiveDate": + dateKey, + + "activity.lastGameDate": + dateKey + } + }, + + { + $set: { + "activity.bestDailyStreak": { + $max: [ + { + $ifNull: [ + "$activity.bestDailyStreak", + 0 + ] + }, + + { + $ifNull: [ + "$activity.currentDailyStreak", + 0 + ] + } + ] + } + } + } + ] + ); +} + function getProfileCollections() { if (!DATABASE_NAME) { throw new Error("M_DB is not configured in .env"); @@ -142,7 +488,37 @@ function getDefaultStats(userId, guildId) { special: { allInRunItBack: 0 - } + }, + + activity: { + currentDailyStreak: 0, + bestDailyStreak: 0, + + lastActiveDate: null, + lastGameDate: null, + + currentWeekKey: null, + currentWeekDays: [], + + gamesByWeekday: { + monday: 0, + tuesday: 0, + wednesday: 0, + thursday: 0, + friday: 0, + saturday: 0, + sunday: 0 + }, + + gamesByTimeBucket: { + overnight: 0, + morning: 0, + afternoon: 0, + evening: 0 + } + }, + + profileSchemaVersion: 2 }; } @@ -293,11 +669,17 @@ async function trackProfileEvent( break; case "game_result": + await trackGameActivity( + userId, + guildId + ); + await trackGameResult( userId, guildId, event ); + break; case "game_start": @@ -1391,6 +1773,15 @@ async function buildStatsEmbed( const streaks = stats.streaks ?? {}; + const activity = + stats.activity ?? {}; + + const gamesByWeekday = + activity.gamesByWeekday ?? {}; + + const gamesByTimeBucket = + activity.gamesByTimeBucket ?? {}; + return new EmbedBuilder() .setTitle( `📊 ${user.globalName ?? user.username}'s Stats` @@ -1430,6 +1821,23 @@ async function buildStatsEmbed( ].join("\n"), inline: true }, + { + name: "📅 Activity", + + value: [ + `Daily streak: **${activity.currentDailyStreak ?? 0} days**`, + `Best streak: **${activity.bestDailyStreak ?? 0} days**`, + "", + `Mon **${gamesByWeekday.monday ?? 0}** • Tue **${gamesByWeekday.tuesday ?? 0}** • Wed **${gamesByWeekday.wednesday ?? 0}**`, + `Thu **${gamesByWeekday.thursday ?? 0}** • Fri **${gamesByWeekday.friday ?? 0}**`, + `Sat **${gamesByWeekday.saturday ?? 0}** • Sun **${gamesByWeekday.sunday ?? 0}**`, + "", + `🌙 Overnight **${gamesByTimeBucket.overnight ?? 0}**`, + `🌅 Morning **${gamesByTimeBucket.morning ?? 0}**`, + `☀️ Afternoon **${gamesByTimeBucket.afternoon ?? 0}**`, + `🌆 Evening **${gamesByTimeBucket.evening ?? 0}**` + ].join("\n") + }, { name: "🎮 Per Game", value: @@ -2238,6 +2646,9 @@ module.exports = { getDefaultStats, readStatValue, + getActivityDateContext, + trackGameActivity, + buildProfileOverview, buildStatsEmbed, buildBadgesEmbed,