Files
ShaiBot/utils/profileSystem.js
T
DeSqBlocki 9e0870c1ed snapshot
2026-08-22 22:29:16 +02:00

1514 lines
32 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"
};
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
},
blackjack: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0
},
roulette: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0
},
heist: {
played: 0,
won: 0,
lost: 0,
pushed: 0,
cookiesWon: 0,
cookiesLost: 0
}
},
streaks: {
currentWin: 0,
bestWin: 0,
currentLoss: 0,
bestLoss: 0
}
};
}
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 trackGameResult(
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 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;
}
else if (profit < 0) {
increment[
`games.${game}.cookiesLost`
] = 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;
}
const currentValue =
readStatValue(
statsDocument,
definition
);
if (
currentValue <
Number(definition.target)
) {
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 =>
definition.measure !==
"achievementPercentage"
);
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,
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,
achievement
) {
try {
await interaction.followUp({
embeds: [
new EmbedBuilder()
.setTitle(
"🏆 Achievement Unlocked!"
)
.setDescription(
[
`${achievement.emoji} **${achievement.name}**`,
"",
achievement.description
].join("\n")
)
],
flags:
MessageFlags.Ephemeral
});
} catch {}
}
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" &&
event.jackpot ===
true
) {
await tryClaimBadge(
interaction,
userId,
"first_slots_jackpot"
);
}
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"
);
}
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();
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
) {
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:
`profile:${user.id}`
});
}
async function buildBadgesEmbed(
guildId,
user
) {
const { badges } =
getProfileCollections();
const documents =
await badges.find({
guildId
}).toArray();
const owned =
documents.filter(
badge =>
badge.ownerId ===
user.id
);
const claimedByOthers =
documents.filter(
badge =>
badge.ownerId &&
badge.ownerId !==
user.id
);
const unclaimed =
documents.filter(
badge =>
!badge.ownerId
);
return new EmbedBuilder()
.setTitle(
`🏅 ${user.globalName ?? user.username}'s Badges`
)
.setDescription(
[
"### Owned",
owned.length
? owned.map(
badge =>
`${badge.emoji} **${badge.name}**\n${badge.description}`
).join("\n\n")
: "*No unique badges yet.*",
"",
"### Claimed by Others",
claimedByOthers.length
? claimedByOthers.map(
badge =>
`${badge.emoji} **${badge.name}** — <@${badge.ownerId}>`
).join("\n")
: "*None yet.*",
"",
"### Still Available",
unclaimed.length
? unclaimed.map(
badge =>
`${badge.emoji} **${badge.name}**\n${badge.description}`
).join("\n\n")
: "*All unique badges have been claimed.*"
].join("\n")
)
.setFooter({
text:
`profile:${user.id}`
});
}
async function buildAchievementsEmbed(
guildId,
user,
page = 0
) {
const {
achievements,
achievementConfig
} =
getProfileCollections();
const [
unlockedDoc,
definitions
] =
await Promise.all([
achievements.findOne({
userId:
user.id,
guildId
}),
achievementConfig
.find({
active: {
$ne:
false
}
})
.sort({
category:
1,
target:
1
})
.toArray()
]);
const unlocked =
unlockedDoc?.unlocked ??
{};
/*
* Keep pages comfortably below Discord's
* 4096-character description limit.
*
* Using a character limit instead of a fixed
* number of achievements also protects us
* against achievements with long names or
* requirements.
*/
const MAX_PAGE_LENGTH =
3500;
const lines =
definitions.map(
achievement => {
const complete =
Boolean(
unlocked[
achievement
.achievementId
]
);
return [
complete
? "✅"
: "⬜",
" ",
achievement.emoji ??
"🏆",
" **",
achievement.name,
"** — ",
achievement.requirement
??
achievement.description
??
"Requirement unavailable."
].join("");
}
);
/*
* Build pages dynamically.
*/
const pages =
[];
let currentPage =
[];
for (
const line
of lines
) {
const test =
[
...currentPage,
line
].join(
"\n"
);
if (
test.length >
MAX_PAGE_LENGTH
&&
currentPage.length >
0
) {
pages.push(
currentPage.join(
"\n"
)
);
currentPage =
[
line
];
}
else {
currentPage.push(
line
);
}
}
if (
currentPage.length >
0
) {
pages.push(
currentPage.join(
"\n"
)
);
}
/*
* No achievements configured.
*/
if (
pages.length ===
0
) {
pages.push(
"No achievements configured."
);
}
/*
* Clamp page so old buttons can't request
* a page that no longer exists after DB
* changes.
*/
const safePage =
Math.max(
0,
Math.min(
Number(page) || 0,
pages.length - 1
)
);
const unlockedCount =
definitions.filter(
achievement =>
Boolean(
unlocked[
achievement
.achievementId
]
)
).length;
return new EmbedBuilder()
.setTitle(
`🏆 ${user.globalName ?? user.username}'s Achievements`
)
.setDescription(
pages[
safePage
]
)
.addFields({
name:
"Progress",
value:
`**${unlockedCount} / ${definitions.length}** achievements unlocked`
})
.setFooter({
text:
`profile:${user.id}:achievements:${safePage}:${pages.length}`
});
}
function buildProfileButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"profileOverview"
)
.setLabel(
"Overview"
)
.setEmoji(
"👤"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"profileBadges"
)
.setLabel(
"Badges"
)
.setEmoji(
"🏅"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"profileAchievements"
)
.setLabel(
"Achievements"
)
.setEmoji(
"🏆"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"abort"
)
.setLabel(
"Close"
)
.setEmoji(
"✖️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
function getProfileTargetId(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const [
type,
userId
] = footer?.split(":") ?? [];
return type === "profile"
? userId
: null;
}
module.exports = {
ensureProfileSystem,
trackProfileEvent,
getProfileStats,
buildProfileOverview,
buildBadgesEmbed,
buildAchievementsEmbed,
buildProfileButtons,
getProfileTargetId
};