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
.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
)
});
}
};
+67 -6
View File
@@ -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
}
+6 -6
View File
@@ -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
+4 -1
View File
@@ -339,7 +339,10 @@ async function runRouletteBack(
return showRouletteChoices(
interaction,
bet,
false
false,
"run_it_back",
previousAllIn,
bet
);
}
+8 -5
View File
@@ -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)
)
];
}
+35 -148
View File
@@ -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(
?.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
};
+411
View File
@@ -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,