Files
ShaiBot/utils/profileSystem.js
T
2026-08-27 19:19:58 +02:00

2971 lines
63 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags
} = require("discord.js");
const { mClient } = require("../index");
const { getCookieBalance, getUnixTime } = require("./cookieEconomy");
const DATABASE_NAME = process.env.M_DB;
const COLLECTIONS = {
stats: "stats_profiles",
achievements: "items_achievements",
badges: "guild_badges",
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");
}
const db = mClient.db(DATABASE_NAME);
return {
db,
stats: db.collection(COLLECTIONS.stats),
achievements: db.collection(COLLECTIONS.achievements),
badges: db.collection(COLLECTIONS.badges),
achievementConfig: db.collection(COLLECTIONS.achievementConfig)
};
}
async function ensureProfileSystem() {
const {
stats,
achievements,
badges,
achievementConfig
} = getProfileCollections();
await stats.createIndex(
{ userId: 1, guildId: 1 },
{ unique: true }
);
await achievements.createIndex(
{ userId: 1, guildId: 1 },
{ unique: true }
);
await badges.createIndex(
{ guildId: 1, badgeId: 1 },
{ unique: true }
);
await achievementConfig.createIndex(
{ achievementId: 1 },
{ unique: true }
);
}
function getDefaultStats(userId, guildId) {
return {
userId,
guildId,
economy: {
cookiesObtainedTotal: 0,
cookiesLostTotal: 0,
cookiesGivenTotal: 0,
stolenTotal: 0,
stolenFromMeTotal: 0,
vaultStolenTotal: 0
},
unique: {
playersStolenFrom: [],
playersStolenBy: []
},
games: {
total: {
played: 0,
won: 0,
lost: 0,
pushed: 0
},
slots: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0,
currentRunItBackStreak: 0,
bestRunItBackStreak: 0
},
blackjack: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0,
currentRunItBackStreak: 0,
bestRunItBackStreak: 0
},
roulette: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0,
currentRunItBackStreak: 0,
bestRunItBackStreak: 0
},
heist: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0
}
},
streaks: {
currentWin: 0,
bestWin: 0,
currentLoss: 0,
bestLoss: 0
},
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
};
}
async function ensureStatsDocument(userId, guildId) {
const { stats } = getProfileCollections();
await stats.updateOne(
{ userId, guildId },
{
$setOnInsert: getDefaultStats(
userId,
guildId
)
},
{ upsert: true }
);
}
async function ensureAchievementDocument(userId, guildId) {
const { achievements } = getProfileCollections();
await achievements.updateOne(
{ userId, guildId },
{
$setOnInsert: {
userId,
guildId,
unlocked: {}
}
},
{ upsert: true }
);
}
async function getProfileStats(userId, guildId) {
const { stats } = getProfileCollections();
return await stats.findOne({
userId,
guildId
}) ?? getDefaultStats(
userId,
guildId
);
}
async function trackProfileEvent(
interaction,
userId,
event
) {
const guildId = interaction.guildId;
if (
!guildId ||
!userId ||
!event?.type
) {
return;
}
await ensureStatsDocument(
userId,
guildId
);
const { stats } = getProfileCollections();
switch (event.type) {
case "cookie_gain":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.cookiesObtainedTotal":
event.amount
}
}
);
break;
case "cookie_loss":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.cookiesLostTotal":
event.amount
}
}
);
break;
case "cookies_given":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.cookiesGivenTotal":
event.amount
}
}
);
break;
case "steal_success":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.stolenTotal":
event.amount
},
$addToSet: {
"unique.playersStolenFrom":
event.targetUserId
}
}
);
break;
case "stolen_from_me":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.stolenFromMeTotal":
event.amount
},
$addToSet: {
"unique.playersStolenBy":
event.thiefUserId
}
}
);
break;
case "vault_steal":
await stats.updateOne(
{ userId, guildId },
{
$inc: {
"economy.vaultStolenTotal":
event.amount
}
}
);
break;
case "game_result":
await trackGameActivity(
userId,
guildId
);
await trackGameResult(
userId,
guildId,
event
);
break;
case "game_start":
await trackGameStart(
userId,
guildId,
event
);
break;
default:
return;
}
const updatedStats =
await getProfileStats(
userId,
guildId
);
await evaluateAchievements(
interaction,
userId,
updatedStats
);
await evaluateBadges(
interaction,
userId,
updatedStats,
event
);
return updatedStats;
}
async function recordGameOutcome(
interaction,
userId,
{
game,
result,
profit = 0,
...metadata
}
) {
return trackProfileEvent(
interaction,
userId,
{
type: "game_result",
game,
result,
profit,
...metadata
}
);
}
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 }
: {})
}
);
}
async function trackGameResult(
userId,
guildId,
event
) {
const { stats } = getProfileCollections();
const game = String(
event.game ?? ""
).toLowerCase();
if (
![
"slots",
"blackjack",
"roulette",
"heist"
].includes(game)
) {
return;
}
const result = String(
event.result ?? ""
).toLowerCase();
const profit = Number(
event.profit ?? 0
);
const increment = {
"games.total.played": 1,
[`games.${game}.played`]: 1
};
const set = {};
if (result === "win") {
increment["games.total.won"] = 1;
increment[`games.${game}.won`] = 1;
increment["streaks.currentWin"] = 1;
set["streaks.currentLoss"] = 0;
}
else if (result === "loss") {
increment["games.total.lost"] = 1;
increment[`games.${game}.lost`] = 1;
increment["streaks.currentLoss"] = 1;
set["streaks.currentWin"] = 0;
}
else if (result === "push") {
increment["games.total.pushed"] = 1;
increment[`games.${game}.pushed`] = 1;
}
else {
return;
}
if (Number.isFinite(profit)) {
if (profit > 0) {
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);
}
}
await stats.updateOne(
{ userId, guildId },
{
$inc: increment,
...(Object.keys(set).length
? { $set: set }
: {})
}
);
const current = await stats.findOne({
userId,
guildId
});
await stats.updateOne(
{ userId, guildId },
{
$max: {
"streaks.bestWin":
current?.streaks
?.currentWin ?? 0,
"streaks.bestLoss":
current?.streaks
?.currentLoss ?? 0
}
}
);
}
async function evaluateAchievements(
interaction,
userId,
statsDocument
) {
const guildId = interaction.guildId;
const {
achievements,
achievementConfig
} = getProfileCollections();
await ensureAchievementDocument(
userId,
guildId
);
const definitions =
await achievementConfig
.find({
active: {
$ne: false
}
})
.toArray();
let unlockedDoc =
await achievements.findOne({
userId,
guildId
});
let unlocked =
unlockedDoc?.unlocked ?? {};
const normalDefinitions =
definitions.filter(
definition =>
definition.measure !==
"achievementPercentage"
);
for (const definition of normalDefinitions) {
if (unlocked[definition.achievementId]) {
continue;
}
if (
!achievementRequirementsMet(
statsDocument,
definition
)
) {
continue;
}
const unlockedNow =
await unlockAchievement(
interaction,
userId,
guildId,
definition
);
if (unlockedNow) {
unlocked[
definition.achievementId
] = getUnixTime();
}
}
unlockedDoc =
await achievements.findOne({
userId,
guildId
});
unlocked =
unlockedDoc?.unlocked ?? {};
const collectorDefinitions =
definitions.filter(
definition =>
definition.measure ===
"achievementPercentage"
);
const collectibleDefinitions =
definitions.filter(
definition =>
isStatBackfillableAchievement(
definition
)
);
const totalCollectible =
collectibleDefinitions.length;
const unlockedCollectible =
collectibleDefinitions.filter(
definition =>
Boolean(
unlocked[
definition.achievementId
]
)
).length;
const achievementPercentage =
totalCollectible > 0
? unlockedCollectible /
totalCollectible
: 0;
for (const definition of collectorDefinitions) {
if (unlocked[definition.achievementId]) {
continue;
}
if (
achievementPercentage <
Number(definition.target)
) {
continue;
}
const unlockedNow =
await unlockAchievement(
interaction,
userId,
guildId,
definition
);
if (unlockedNow) {
unlocked[
definition.achievementId
] = getUnixTime();
}
}
}
async function unlockAchievement(
interaction,
userId,
guildId,
definition
) {
const { achievements } =
getProfileCollections();
const field =
`unlocked.${definition.achievementId}`;
const result =
await achievements.updateOne(
{
userId,
guildId,
[field]: {
$exists: false
}
},
{
$set: {
[field]:
getUnixTime()
}
}
);
if (result.modifiedCount !== 1) {
return false;
}
await sendAchievementNotification(
interaction,
userId,
definition
);
return true;
}
function readStatValue(
document,
definition
) {
if (!definition.stat) {
return 0;
}
const value =
definition.stat
.split(".")
.reduce(
(current, key) =>
current?.[key],
document
);
if (
definition.measure ===
"arrayLength"
) {
return Array.isArray(value)
? value.length
: 0;
}
return Number(value ?? 0);
}
async function sendAchievementNotification(
interaction,
userId,
achievement
) {
const embed =
new EmbedBuilder()
.setTitle(
"🏆 Achievement Unlocked!"
)
.setDescription(
[
`<@${userId}> earned an achievement!`,
"",
`${achievement.emoji ?? "🏆"} **${achievement.name}**`,
"",
achievement.description ??
achievement.requirement ??
"Achievement unlocked."
].join("\n")
)
.setTimestamp();
try {
/*
* If the interaction has already been acknowledged,
* followUp is safe.
*/
if (
interaction.replied ||
interaction.deferred
) {
return await interaction.followUp({
embeds: [embed]
});
}
/*
* Otherwise don't consume the interaction's primary
* reply. The game/button handler still needs it.
*
* Send the public achievement announcement directly
* to the channel instead.
*/
if (interaction.channel) {
return await interaction.channel.send({
embeds: [embed]
});
}
}
catch (error) {
console.error(
`[Achievement] Failed to announce ${achievement.achievementId} for ${userId}:`,
error
);
}
return null;
}
async function evaluateBadges(
interaction,
userId,
statsDocument,
event = {}
) {
const guildId = interaction.guildId;
if (!guildId) {
return;
}
const balance =
await getCookieBalance(
userId
);
if (balance >= 100000) {
await tryClaimBadge(
interaction,
userId,
"first_100k_cookies"
);
}
if (balance >= 1000000) {
await tryClaimBadge(
interaction,
userId,
"first_1m_cookies"
);
}
if (
(
statsDocument
?.economy
?.vaultStolenTotal ?? 0
) >= 100000
) {
await tryClaimBadge(
interaction,
userId,
"first_100k_vault_stolen"
);
}
const uniquePlayersStolenFrom =
statsDocument
?.unique
?.playersStolenFrom ?? [];
const uniqueStealCount =
uniquePlayersStolenFrom.length;
if (uniqueStealCount >= 5) {
await tryClaimBadge(
interaction,
userId,
"first_unique_steals_5"
);
}
if (uniqueStealCount >= 10) {
await tryClaimBadge(
interaction,
userId,
"first_unique_steals_10"
);
}
if (uniqueStealCount >= 15) {
await tryClaimBadge(
interaction,
userId,
"first_unique_steals_15"
);
}
if (
event.type === "game_result" &&
event.game === "slots"
) {
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
);
}
}
if (
event.type ===
"game_result" &&
event.game ===
"blackjack" &&
event.natural ===
true &&
event.result ===
"win"
) {
await tryClaimBadge(
interaction,
userId,
"first_blackjack_natural"
);
}
if (
event.type ===
"game_result" &&
event.game ===
"blackjack" &&
event.allIn ===
true &&
event.result ===
"win"
) {
await tryClaimBadge(
interaction,
userId,
"first_blackjack_allin_win"
);
}
if (
event.type ===
"game_result" &&
event.game ===
"roulette" &&
event.zero ===
true &&
event.result ===
"win"
) {
await tryClaimBadge(
interaction,
userId,
"first_roulette_zero"
);
}
if (
event.type ===
"game_result" &&
event.game ===
"roulette" &&
event.allIn ===
true &&
event.result ===
"win"
) {
await tryClaimBadge(
interaction,
userId,
"first_roulette_allin_win"
);
}
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
);
}
async function evaluateCollectorBadges(
interaction,
userId
) {
const guildId = interaction.guildId;
const {
achievements,
achievementConfig
} = getProfileCollections();
const activeAchievements =
(
await achievementConfig
.find({
active: {
$ne: false
},
measure: {
$ne:
"achievementPercentage"
}
})
.toArray()
).filter(achievement =>
Boolean(achievement.stat)
);
if (
activeAchievements.length ===
0
) {
return;
}
const achievementDocument =
await achievements.findOne({
userId,
guildId
});
const unlocked =
achievementDocument?.unlocked ?? {};
const unlockedCount =
activeAchievements.filter(
achievement =>
Boolean(
unlocked[
achievement.achievementId
]
)
).length;
const percentage =
unlockedCount /
activeAchievements.length;
if (percentage >= 0.5) {
await tryClaimBadge(
interaction,
userId,
"first_achievement_half",
{
unlockedAchievements:
unlockedCount,
availableAchievements:
activeAchievements.length
}
);
}
if (percentage >= 1) {
await tryClaimBadge(
interaction,
userId,
"first_achievement_all",
{
unlockedAchievements:
unlockedCount,
availableAchievements:
activeAchievements.length
}
);
}
}
async function tryClaimBadge(
interaction,
userId,
badgeId,
metadata = null
) {
const { badges } =
getProfileCollections();
const guildId = interaction.guildId;
if (!guildId) {
return false;
}
const update = {
ownerId: userId,
unlockedAt: getUnixTime()
};
if (
metadata &&
typeof metadata ===
"object"
) {
update.metadata = metadata;
}
const badge =
await badges.findOneAndUpdate(
{
guildId,
badgeId,
ownerId: null
},
{
$set: update
},
{
returnDocument:
"after"
}
);
if (
!badge ||
badge.ownerId !==
userId
) {
return false;
}
const description = [
`${badge.emoji ?? "🏅"} **${badge.name ?? badge.badgeId}**`,
"",
`<@${userId}> claimed a guild-unique badge!`,
"",
badge.description ??
"A unique badge has been claimed.",
"",
"*This badge can no longer be earned by another player.*"
];
if (
badge.metadata
?.unlockedAchievements !==
undefined &&
badge.metadata
?.availableAchievements !==
undefined
) {
description.splice(
description.length - 1,
0,
`Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`,
""
);
}
try {
await interaction.channel.send({
embeds: [
new EmbedBuilder()
.setTitle(
"🏅 Unique Badge Claimed!"
)
.setDescription(
description.join("\n")
)
.setFooter({
text:
`badge:${badge.badgeId}`
})
.setTimestamp()
]
});
} catch (error) {
console.error(
`[Badge] Failed to announce ${badgeId} for ${userId}:`,
error
);
}
return true;
}
async function buildProfileOverview(
guildId,
user,
sessionOwnerId = user.id
) {
const stats =
await getProfileStats(
user.id,
guildId
);
const balance =
await getCookieBalance(
user.id
);
const {
achievements,
badges,
achievementConfig
} = getProfileCollections();
const [
achievementDoc,
achievementCount,
ownedBadges
] = await Promise.all([
achievements.findOne({
userId: user.id,
guildId
}),
achievementConfig.countDocuments({
active: {
$ne: false
}
}),
badges.find({
guildId,
ownerId: user.id
}).toArray()
]);
const unlockedCount =
Object.keys(
achievementDoc?.unlocked ?? {}
).length;
return new EmbedBuilder()
.setTitle(
`👤 ${user.globalName ?? user.username}'s Profile`
)
.setThumbnail(
user.displayAvatarURL({
size: 256
})
)
.addFields(
{
name: "🍪 Cookies",
value:
balance.toLocaleString(),
inline: true
},
{
name: "🏆 Achievements",
value:
`${unlockedCount} / ${achievementCount}`,
inline: true
},
{
name: "🏅 Unique Badges",
value:
`${ownedBadges.length}`,
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}**`,
`Losses: **${stats.streaks.bestLoss}**`
].join("\n"),
inline: true
}
)
.setFooter({
text:
buildProfileFooter(
sessionOwnerId,
user.id,
"overview",
0,
1
)
});
}
async function buildStatsEmbed(
guildId,
user,
sessionOwnerId = user.id
) {
const stats =
await getProfileStats(
user.id,
guildId
);
const gameLines = [
["🎰 Slots", stats.games?.slots],
["🃏 Blackjack", stats.games?.blackjack],
["🎡 Roulette", stats.games?.roulette],
["🏦 Heist", stats.games?.heist]
].map(([name, game = {}]) => {
const played =
Number(game.played ?? 0);
const won =
Number(game.won ?? 0);
const lost =
Number(game.lost ?? 0);
const pushed =
Number(game.pushed ?? 0);
const cookiesWon =
Number(game.cookiesWon ?? 0);
const cookiesLost =
Number(game.cookiesLost ?? 0);
const decidedGames =
won + lost;
const winRate =
decidedGames > 0
? (won / decidedGames) * 100
: 0;
const net =
cookiesWon - cookiesLost;
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 ?? {};
const activity =
stats.activity ?? {};
const gamesByWeekday =
activity.gamesByWeekday ?? {};
const gamesByTimeBucket =
activity.gamesByTimeBucket ?? {};
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
},
{
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: "📅 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:
gameLines.join("\n\n")
}
)
.setFooter({
text:
buildProfileFooter(
sessionOwnerId,
user.id,
"stats",
0,
1
)
});
}
async function buildBadgesEmbed(
guildId,
user,
sessionOwnerId = user.id,
page = 0
) {
const { badges } =
getProfileCollections();
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,
sessionOwnerId = user.id,
page = 0
) {
const {
achievements,
achievementConfig
} = getProfileCollections();
const [
unlockedDoc,
definitions,
statsDocument
] = await Promise.all([
achievements.findOne({
userId: user.id,
guildId
}),
achievementConfig
.find({
active: {
$ne: false
}
})
.sort({
category: 1,
target: 1,
name: 1
})
.toArray(),
getProfileStats(
user.id,
guildId
)
]);
const 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) {
const complete =
Boolean(
unlocked[
achievement.achievementId
]
);
const category =
achievement.category ??
"other";
if (category !== lastCategory) {
if (lines.length) {
lines.push("");
}
lines.push(
`### ${formatCategoryName(category)}`
);
lastCategory = category;
}
if (
achievement.secret === true &&
!complete
) {
lines.push(
"⬜ ❓ **???** — 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.measure === "all" ||
achievement.measure === "any"
) {
const {
completed,
total
} =
getCompositeProgress(
statsDocument,
achievement
);
progressText =
` • **${completed} / ${total}**`;
}
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 ? "✅" : "⬜",
" ",
achievement.emoji ?? "🏆",
" **",
achievement.name,
"** — ",
requirement,
complete
? ""
: progressText
].join("")
);
}
const pages =
paginateLines(
lines,
3400,
"\n"
);
if (!pages.length) {
pages.push(
"No achievements configured."
);
}
const safePage =
clampPage(
page,
pages.length
);
const unlockedCount =
definitions.filter(
achievement =>
Boolean(
unlocked[
achievement.achievementId
]
)
).length;
const completion =
definitions.length > 0
? (unlockedCount / definitions.length) * 100
: 0;
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
)
});
}
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 {
sessionOwnerId,
profileUserId,
section,
page:
Number(rawPage) || 0,
pageCount:
Math.max(
1,
Number(rawPageCount) || 1
)
};
}
function getProfileTargetId(
interaction
) {
return getProfileContext(
interaction
)?.profileUserId ?? null;
}
async function requireProfileOwner(
interaction
) {
const context =
getProfileContext(
interaction
);
if (!context) {
await replyProfileError(
interaction,
"This profile session is no longer valid."
);
return null;
}
if (
context.sessionOwnerId !==
interaction.user.id
) {
await replyProfileError(
interaction,
"This isn't your profile session."
);
return null;
}
return context;
}
async function replyProfileError(
interaction,
content
) {
const payload = {
content,
flags:
MessageFlags.Ephemeral
};
if (
interaction.replied ||
interaction.deferred
) {
return interaction.followUp(
payload
);
}
return interaction.reply(
payload
);
}
function paginateLines(
lines,
maxLength = 3400,
separator = "\n"
) {
const pages = [];
let current = [];
for (const line of lines) {
const candidate =
[...current, line].join(
separator
);
if (
candidate.length >
maxLength &&
current.length
) {
pages.push(
current.join(
separator
)
);
current = [line];
}
else {
current.push(line);
}
}
if (current.length) {
pages.push(
current.join(
separator
)
);
}
return pages;
}
function clampPage(
page,
pageCount
) {
return Math.max(
0,
Math.min(
Number(page) || 0,
Math.max(
0,
pageCount - 1
)
)
);
}
function formatCategoryName(
category
) {
const labels = {
economy:
"🍪 Economy",
stealing:
"🥷 Stealing",
slots:
"🎰 Slots",
blackjack:
"🃏 Blackjack",
roulette:
"🎡 Roulette",
heist:
"🏦 Heist",
collector:
"🏆 Collector",
other:
"🏅 Other",
activity:
"📅 Activity",
};
return labels[category] ??
`🏅 ${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
function formatBadgeLine(
badge,
profileUserId,
ownedPage
) {
const emoji =
badge.emoji ?? "🏅";
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");
}
function readStatPath(
document,
stat
) {
if (!stat) {
return undefined;
}
return String(stat)
.split(".")
.reduce(
(current, key) =>
current?.[key],
document
);
}
function evaluateCondition(
document,
condition
) {
if (
!condition ||
!condition.stat
) {
return false;
}
const actual =
readStatPath(
document,
condition.stat
);
const expected =
condition.value;
const operator =
String(
condition.operator ??
"gte"
).toLowerCase();
switch (operator) {
case "eq":
return (
actual ===
expected
);
case "neq":
return (
actual !==
expected
);
case "gt":
return (
Number(actual ?? 0) >
Number(expected ?? 0)
);
case "gte":
return (
Number(actual ?? 0) >=
Number(expected ?? 0)
);
case "lt":
return (
Number(actual ?? 0) <
Number(expected ?? 0)
);
case "lte":
return (
Number(actual ?? 0) <=
Number(expected ?? 0)
);
case "includes":
return (
Array.isArray(actual) &&
actual.includes(
expected
)
);
case "not_includes":
return (
Array.isArray(actual) &&
!actual.includes(
expected
)
);
case "truthy":
return Boolean(
actual
);
case "falsy":
return !Boolean(
actual
);
default:
return false;
}
}
function evaluateCompositeAchievement(
document,
definition
) {
const conditions =
Array.isArray(
definition.conditions
)
? definition.conditions
: [];
if (!conditions.length) {
return false;
}
if (
definition.measure ===
"all"
) {
return conditions.every(
condition =>
evaluateCondition(
document,
condition
)
);
}
if (
definition.measure ===
"any"
) {
return conditions.some(
condition =>
evaluateCondition(
document,
condition
)
);
}
return false;
}
function isStatBackfillableAchievement(
definition
) {
if (!definition) {
return false;
}
if (
definition.measure ===
"achievementPercentage"
) {
return false;
}
if (
definition.measure === "all" ||
definition.measure === "any"
) {
return (
Array.isArray(
definition.conditions
) &&
definition.conditions.length > 0
);
}
return Boolean(
definition.stat
);
}
function achievementRequirementsMet(
document,
definition
) {
if (!definition) {
return false;
}
if (
definition.measure === "all" ||
definition.measure === "any"
) {
return evaluateCompositeAchievement(
document,
definition
);
}
if (!definition.stat) {
return false;
}
const currentValue =
readStatValue(
document,
definition
);
return (
currentValue >=
Number(
definition.target ?? 0
)
);
}
function getCompositeProgress(
statsDocument,
definition
) {
const conditions =
Array.isArray(
definition.conditions
)
? definition.conditions
: [];
if (!conditions.length) {
return {
completed: 0,
total: 0
};
}
const completed =
conditions.filter(
condition =>
evaluateCondition(
statsDocument,
condition
)
).length;
return {
completed,
total:
conditions.length
};
}
module.exports = {
ensureProfileSystem,
trackProfileEvent,
recordGameOutcome,
getProfileStats,
getProfileCollections,
getDefaultStats,
getCompositeProgress,
readStatValue,
readStatPath,
evaluateCondition,
evaluateCompositeAchievement,
achievementRequirementsMet,
isStatBackfillableAchievement,
getActivityDateContext,
trackGameActivity,
buildProfileOverview,
buildStatsEmbed,
buildBadgesEmbed,
buildAchievementsEmbed,
buildProfileButtons,
getProfileContext,
getProfileContextFromFooter,
getProfileTargetId,
requireProfileOwner
};