snapshot rollout phase 1

This commit is contained in:
DeSqBlocki
2026-08-27 18:52:56 +02:00
parent 6a519f3a2d
commit c2ac7737c0
8 changed files with 595 additions and 171 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules node_modules
.env .env
backup/
+58
View File
@@ -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
)
});
}
};
+68 -7
View File
@@ -1,6 +1,5 @@
const { const {
SlashCommandBuilder, SlashCommandBuilder,
PermissionFlagsBits,
MessageFlags, MessageFlags,
EmbedBuilder EmbedBuilder
} = require("discord.js"); } = require("discord.js");
@@ -13,6 +12,13 @@ const {
"../../utils/profileMaintenance" "../../utils/profileMaintenance"
); );
const {
requireAdminAccess
} = require(
"../../utils/permissions"
);
module.exports = { module.exports = {
data: data:
new SlashCommandBuilder() new SlashCommandBuilder()
@@ -22,9 +28,6 @@ module.exports = {
.setDescription( .setDescription(
"Administrative profile maintenance tools" "Administrative profile maintenance tools"
) )
.setDefaultMemberPermissions(
PermissionFlagsBits.Administrator
)
.addSubcommand( .addSubcommand(
subcommand => subcommand =>
subcommand subcommand
@@ -69,14 +72,32 @@ module.exports = {
async execute( async execute(
interaction interaction
) { ) {
/*
* Allows:
*
* - Discord Administrators
* - BOT_AUTHOR_ID from .env
*/
if (
!await requireAdminAccess(
interaction
)
) {
return;
}
const subcommand = const subcommand =
interaction.options.getSubcommand(); interaction.options.getSubcommand();
await interaction.deferReply({ await interaction.deferReply({
flags: flags:
MessageFlags.Ephemeral MessageFlags.Ephemeral
}); });
if ( if (
subcommand === subcommand ===
"reconcile" "reconcile"
@@ -86,21 +107,26 @@ module.exports = {
"user" "user"
); );
const dryRun = const dryRun =
interaction.options.getBoolean( interaction.options.getBoolean(
"dry-run" "dry-run"
) ?? false; ) ?? false;
if (user) { if (user) {
const result = const result =
await reconcileUserAchievements({ await reconcileUserAchievements({
guildId: guildId:
interaction.guildId, interaction.guildId,
userId: userId:
user.id, user.id,
dryRun dryRun
}); });
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
@@ -114,24 +140,30 @@ module.exports = {
{ {
name: name:
"New unlocks", "New unlocks",
value: value:
`**${result.unlocked}**`, `**${result.unlocked}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Definitions checked", "Definitions checked",
value: value:
`**${result.checked}**`, `**${result.checked}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Event-only skipped", "Event-only skipped",
value: value:
`**${result.skippedEventOnly}**`, `**${result.skippedEventOnly}**`,
inline: inline:
true true
} }
@@ -140,13 +172,16 @@ module.exports = {
}); });
} }
const summary = const summary =
await reconcileAllAchievements({ await reconcileAllAchievements({
guildId: guildId:
interaction.guildId, interaction.guildId,
dryRun dryRun
}); });
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
@@ -160,24 +195,30 @@ module.exports = {
{ {
name: name:
"Users", "Users",
value: value:
`**${summary.users}**`, `**${summary.users}**`,
inline: inline:
true true
}, },
{ {
name: name:
"New unlocks", "New unlocks",
value: value:
`**${summary.unlocked}**`, `**${summary.unlocked}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Event-only checks skipped", "Event-only checks skipped",
value: value:
`**${summary.skippedEventOnly}**`, `**${summary.skippedEventOnly}**`,
inline: inline:
true true
} }
@@ -186,15 +227,23 @@ module.exports = {
}); });
} }
const audit = const audit =
await auditProfileData( await auditProfileData(
interaction.guildId interaction.guildId
); );
const issuePreview = const issuePreview =
audit.issues audit.issues
.slice(0, 10) .slice(
.join("\n"); 0,
10
)
.join(
"\n"
);
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [
@@ -206,48 +255,60 @@ module.exports = {
{ {
name: name:
"Stats documents", "Stats documents",
value: value:
`**${audit.statDocuments}**`, `**${audit.statDocuments}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Achievement documents", "Achievement documents",
value: value:
`**${audit.achievementDocuments}**`, `**${audit.achievementDocuments}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Active achievements", "Active achievements",
value: value:
`**${audit.activeAchievements}**`, `**${audit.activeAchievements}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Global badges", "Global badges",
value: value:
`**${audit.badgeDocuments}** (${audit.claimedBadges} claimed)`, `**${audit.badgeDocuments}** (${audit.claimedBadges} claimed)`,
inline: inline:
true true
}, },
{ {
name: name:
"Orphan achievement docs", "Orphan achievement docs",
value: value:
`**${audit.orphanAchievementDocs}**`, `**${audit.orphanAchievementDocs}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Issues", "Issues",
value: value:
`**${audit.issueCount}**`, `**${audit.issueCount}**`,
inline: inline:
true true
} }
@@ -260,4 +321,4 @@ module.exports = {
] ]
}); });
} }
}; };
+6 -6
View File
@@ -24,30 +24,30 @@ const HEIST_COOLDOWN =
const HEIST_METHODS = { const HEIST_METHODS = {
Sneak: { Sneak: {
baseChance: 0.65, baseChance: 0.60,
minStealPercent: 0.01, minStealPercent: 0.01,
maxStealPercent: 0.03, maxStealPercent: 0.03,
maxStealAmount: 1500, maxStealAmount: 5000,
minPenaltyPercent: 0.01, minPenaltyPercent: 0.01,
maxPenaltyPercent: 0.04, maxPenaltyPercent: 0.04,
maxPenaltyAmount: 750 maxPenaltyAmount: 750
}, },
Hack: { Hack: {
baseChance: 0.45, baseChance: 0.40,
minStealPercent: 0.03, minStealPercent: 0.03,
maxStealPercent: 0.06, maxStealPercent: 0.06,
maxStealAmount: 3500, maxStealAmount: 10000,
minPenaltyPercent: 0.03, minPenaltyPercent: 0.03,
maxPenaltyPercent: 0.07, maxPenaltyPercent: 0.07,
maxPenaltyAmount: 2000 maxPenaltyAmount: 2000
}, },
Smash: { Smash: {
baseChance: 0.25, baseChance: 0.20,
minStealPercent: 0.06, minStealPercent: 0.06,
maxStealPercent: 0.12, maxStealPercent: 0.12,
maxStealAmount: 7500, maxStealAmount: 15000,
minPenaltyPercent: 0.06, minPenaltyPercent: 0.06,
maxPenaltyPercent: 0.12, maxPenaltyPercent: 0.12,
maxPenaltyAmount: 5000 maxPenaltyAmount: 5000
+4 -1
View File
@@ -339,7 +339,10 @@ async function runRouletteBack(
return showRouletteChoices( return showRouletteChoices(
interaction, interaction,
bet, bet,
false false,
"run_it_back",
previousAllIn,
bet
); );
} }
+8 -5
View File
@@ -105,7 +105,7 @@ function buildMainMenuButtons() {
.setCustomId("crumbSaucerBlackjack") .setCustomId("crumbSaucerBlackjack")
.setLabel("Blackjack") .setLabel("Blackjack")
.setEmoji("🃏") .setEmoji("🃏")
.setStyle(ButtonStyle.Success), .setStyle(ButtonStyle.Primary),
new ButtonBuilder() new ButtonBuilder()
.setCustomId("crumbSaucerRoulette") .setCustomId("crumbSaucerRoulette")
@@ -126,19 +126,22 @@ function buildMainMenuButtons() {
.setCustomId("crumbSaucerCollectMenu") .setCustomId("crumbSaucerCollectMenu")
.setLabel("Cookie Bakery") .setLabel("Cookie Bakery")
.setEmoji("🍪") .setEmoji("🍪")
.setStyle(ButtonStyle.Success), .setStyle(ButtonStyle.Secondary),
new ButtonBuilder() new ButtonBuilder()
.setCustomId("crumbSaucerBankruptcy") .setCustomId("crumbSaucerBankruptcy")
.setLabel("Bankruptcy") .setLabel("Bankruptcy")
.setEmoji("🛟") .setEmoji("🛟")
.setStyle(ButtonStyle.Secondary), .setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId("crumbSaucerProfile")
.setLabel("Profile")
.setEmoji("👤")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder() new ButtonBuilder()
.setCustomId("sessionClose") .setCustomId("sessionClose")
.setLabel("Close") .setLabel("Close")
.setEmoji("✖️") .setEmoji("✖️")
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Danger)
) )
]; ];
} }
+38 -151
View File
@@ -1,118 +1,46 @@
const { const {
PermissionFlagsBits, PermissionFlagsBits,
MessageFlags MessageFlags
} = require( } = require("discord.js");
"discord.js"
);
/*
* ============================================================
* CONFIG
* ============================================================
*
* .env:
*
* BOT_AUTHOR_ID=123456789012345678
*
* Do not hard-code the author ID into commands.
*/
function getBotAuthorId() { function getBotAuthorId() {
return ( return process.env.BOT_AUTHOR_ID ?? null;
process.env.BOT_AUTHOR_ID
??
null
);
} }
/*
* ============================================================
* BOT AUTHOR
* ============================================================
*/
function isBotAuthor( function isBotAuthor(
userId userId
) { ) {
const authorId = const authorId =
getBotAuthorId(); getBotAuthorId();
return Boolean(
if ( authorId &&
!authorId userId &&
|| String(userId) === String(authorId)
!userId
) {
return false;
}
return (
String(
userId
)
===
String(
authorId
)
); );
} }
/*
* ============================================================
* GUILD ADMINISTRATOR
* ============================================================
*/
function isAdministrator( function isAdministrator(
interaction interaction
) { ) {
if ( return (
!interaction interaction
?.memberPermissions ?.memberPermissions
) { ?.has(
return false; PermissionFlagsBits.Administrator
} )
??
false
return interaction );
.memberPermissions
.has(
PermissionFlagsBits.Administrator
);
} }
/*
* ============================================================
* ADMIN COMMAND ACCESS
* ============================================================
*
* Access is granted when:
*
* - the user is the configured bot author
* OR
* - the user has Discord Administrator
*
* Bot author is checked first so the owner can use admin
* utilities even without Discord Administrator in the guild.
*/
function canUseAdminCommands( function canUseAdminCommands(
interaction interaction
) { ) {
if (
!interaction
?.user
?.id
) {
return false;
}
return ( return (
isBotAuthor( isBotAuthor(
interaction.user.id 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( async function requireAdminAccess(
interaction interaction
) { ) {
@@ -188,29 +65,39 @@ async function requireAdminAccess(
} }
await denyAdminAccess( const payload = {
interaction 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; return false;
} }
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = { module.exports = {
getBotAuthorId, getBotAuthorId,
isBotAuthor, isBotAuthor,
isAdministrator, isAdministrator,
canUseAdminCommands, canUseAdminCommands,
requireAdminAccess, requireAdminAccess
denyAdminAccess
}; };
+412 -1
View File
@@ -18,6 +18,352 @@ const COLLECTIONS = {
achievementConfig: "config_achievements" 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() { function getProfileCollections() {
if (!DATABASE_NAME) { if (!DATABASE_NAME) {
throw new Error("M_DB is not configured in .env"); throw new Error("M_DB is not configured in .env");
@@ -142,7 +488,37 @@ function getDefaultStats(userId, guildId) {
special: { special: {
allInRunItBack: 0 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; break;
case "game_result": case "game_result":
await trackGameActivity(
userId,
guildId
);
await trackGameResult( await trackGameResult(
userId, userId,
guildId, guildId,
event event
); );
break; break;
case "game_start": case "game_start":
@@ -1391,6 +1773,15 @@ async function buildStatsEmbed(
const streaks = const streaks =
stats.streaks ?? {}; stats.streaks ?? {};
const activity =
stats.activity ?? {};
const gamesByWeekday =
activity.gamesByWeekday ?? {};
const gamesByTimeBucket =
activity.gamesByTimeBucket ?? {};
return new EmbedBuilder() return new EmbedBuilder()
.setTitle( .setTitle(
`📊 ${user.globalName ?? user.username}'s Stats` `📊 ${user.globalName ?? user.username}'s Stats`
@@ -1430,6 +1821,23 @@ async function buildStatsEmbed(
].join("\n"), ].join("\n"),
inline: true 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", name: "🎮 Per Game",
value: value:
@@ -2238,6 +2646,9 @@ module.exports = {
getDefaultStats, getDefaultStats,
readStatValue, readStatValue,
getActivityDateContext,
trackGameActivity,
buildProfileOverview, buildProfileOverview,
buildStatsEmbed, buildStatsEmbed,
buildBadgesEmbed, buildBadgesEmbed,