1514 lines
32 KiB
JavaScript
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
|
|
};
|