3236 lines
65 KiB
JavaScript
3236 lines
65 KiB
JavaScript
const {
|
|
EmbedBuilder,
|
|
ActionRowBuilder,
|
|
ButtonBuilder,
|
|
ButtonStyle,
|
|
MessageFlags
|
|
} = require("discord.js");
|
|
|
|
const { mClient } = require("../index");
|
|
const {
|
|
getCookieBalance,
|
|
getUnixTime
|
|
} = require("./cookieEconomy");
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CONFIG
|
|
* ============================================================
|
|
*/
|
|
|
|
const DATABASE_NAME = process.env.M_DB;
|
|
|
|
const COLLECTIONS = {
|
|
stats: "stats_profiles",
|
|
achievements: "items_achievements",
|
|
badges: "guild_badges",
|
|
achievementConfig: "config_achievements"
|
|
};
|
|
|
|
const PROFILE_PAGE_CHARACTER_LIMIT = 3500;
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* COLLECTION ACCESS
|
|
* ============================================================
|
|
*/
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* INDEXES
|
|
* ============================================================
|
|
*/
|
|
|
|
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 }
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* DEFAULT STATS
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ENSURE DOCUMENTS
|
|
* ============================================================
|
|
*/
|
|
|
|
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 }
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GET PROFILE STATS
|
|
* ============================================================
|
|
*/
|
|
|
|
async function getProfileStats(userId, guildId) {
|
|
const { stats } = getProfileCollections();
|
|
|
|
const existing = await stats.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
return existing ?? getDefaultStats(
|
|
userId,
|
|
guildId
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE EVENTS
|
|
* ============================================================
|
|
*/
|
|
|
|
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":
|
|
Number(event.amount ?? 0)
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "cookie_loss":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.cookiesLostTotal":
|
|
Number(event.amount ?? 0)
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "cookies_given":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.cookiesGivenTotal":
|
|
Number(event.amount ?? 0)
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "steal_success":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.stolenTotal":
|
|
Number(event.amount ?? 0)
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenFrom":
|
|
event.targetUserId
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "stolen_from_me":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.stolenFromMeTotal":
|
|
Number(event.amount ?? 0)
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenBy":
|
|
event.thiefUserId
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "vault_steal":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.vaultStolenTotal":
|
|
Number(event.amount ?? 0)
|
|
}
|
|
}
|
|
);
|
|
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;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CENTRAL GAME RESULT
|
|
* ============================================================
|
|
*/
|
|
|
|
async function recordGameOutcome(
|
|
interaction,
|
|
userId,
|
|
{
|
|
game,
|
|
result,
|
|
profit = 0,
|
|
...metadata
|
|
}
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
if (!guildId || !userId) {
|
|
return;
|
|
}
|
|
|
|
const normalizedProfit = Number(
|
|
profit ?? 0
|
|
);
|
|
|
|
await ensureStatsDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
const { stats } = getProfileCollections();
|
|
|
|
/*
|
|
* One authoritative game event.
|
|
*/
|
|
await trackGameResult(
|
|
userId,
|
|
guildId,
|
|
{
|
|
game,
|
|
result,
|
|
profit: normalizedProfit,
|
|
...metadata
|
|
}
|
|
);
|
|
|
|
/*
|
|
* Global economy totals.
|
|
*
|
|
* Games using recordGameOutcome() should NOT also send
|
|
* cookie_gain / cookie_loss events for the same result.
|
|
*/
|
|
if (normalizedProfit > 0) {
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.cookiesObtainedTotal":
|
|
normalizedProfit
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
else if (normalizedProfit < 0) {
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.cookiesLostTotal":
|
|
Math.abs(normalizedProfit)
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const updatedStats = await getProfileStats(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
const event = {
|
|
type: "game_result",
|
|
game,
|
|
result,
|
|
profit: normalizedProfit,
|
|
...metadata
|
|
};
|
|
|
|
await evaluateAchievements(
|
|
interaction,
|
|
userId,
|
|
updatedStats
|
|
);
|
|
|
|
await evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
updatedStats,
|
|
event
|
|
);
|
|
|
|
return updatedStats;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GAME STATS
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ACHIEVEMENT EVALUATION
|
|
* ============================================================
|
|
*/
|
|
|
|
async function evaluateAchievements(
|
|
interaction,
|
|
userId,
|
|
statsDocument
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
if (!guildId) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
await ensureAchievementDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
const definitions =
|
|
await achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray();
|
|
|
|
let unlockedDocument =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
let unlocked =
|
|
unlockedDocument?.unlocked ?? {};
|
|
|
|
/*
|
|
* Normal DB-backed achievements first.
|
|
*/
|
|
const normalDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure !==
|
|
"achievementPercentage"
|
|
);
|
|
|
|
for (const definition of normalDefinitions) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
* Event-only achievements do not have a persisted
|
|
* stat path and are intentionally skipped here.
|
|
*/
|
|
if (!definition.stat) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Refresh after normal unlocks.
|
|
*/
|
|
unlockedDocument =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
unlocked =
|
|
unlockedDocument?.unlocked ?? {};
|
|
|
|
/*
|
|
* Collector achievements.
|
|
*/
|
|
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 percentage =
|
|
totalCollectible > 0
|
|
? unlockedCollectible /
|
|
totalCollectible
|
|
: 0;
|
|
|
|
for (const definition of collectorDefinitions) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
percentage <
|
|
Number(definition.target)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const unlockedNow =
|
|
await unlockAchievement(
|
|
interaction,
|
|
userId,
|
|
guildId,
|
|
definition
|
|
);
|
|
|
|
if (unlockedNow) {
|
|
unlocked[
|
|
definition.achievementId
|
|
] = getUnixTime();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* UNLOCK ACHIEVEMENT
|
|
* ============================================================
|
|
*/
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* STAT READER
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ACHIEVEMENT NOTIFICATION
|
|
* ============================================================
|
|
*
|
|
* Public by default.
|
|
*/
|
|
|
|
async function sendAchievementNotification(
|
|
interaction,
|
|
achievement
|
|
) {
|
|
const embed =
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
"🏆 Achievement Unlocked!"
|
|
)
|
|
.setDescription(
|
|
[
|
|
`${achievement.emoji ?? "🏆"} **${achievement.name}**`,
|
|
"",
|
|
achievement.description
|
|
??
|
|
achievement.requirement
|
|
??
|
|
"Achievement unlocked."
|
|
].join("\n")
|
|
)
|
|
.setTimestamp();
|
|
|
|
try {
|
|
if (
|
|
interaction.replied
|
|
||
|
|
interaction.deferred
|
|
) {
|
|
await interaction.followUp({
|
|
embeds: [embed]
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Do not consume the primary interaction reply.
|
|
*/
|
|
if (interaction.channel) {
|
|
await interaction.channel.send({
|
|
embeds: [embed]
|
|
});
|
|
}
|
|
}
|
|
|
|
catch (error) {
|
|
console.error(
|
|
`[Achievement] Failed to announce ${achievement.achievementId}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGE EVALUATION
|
|
* ============================================================
|
|
*/
|
|
|
|
async function evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
statsDocument,
|
|
event = {}
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
if (!guildId) {
|
|
return;
|
|
}
|
|
|
|
const balance =
|
|
await getCookieBalance(
|
|
userId
|
|
);
|
|
|
|
/*
|
|
* Economy badges.
|
|
*/
|
|
if (balance >= 100000) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_100k_cookies"
|
|
);
|
|
}
|
|
|
|
if (balance >= 1000000) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_1m_cookies"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Vault milestone.
|
|
*/
|
|
if (
|
|
(
|
|
statsDocument
|
|
?.economy
|
|
?.vaultStolenTotal
|
|
??
|
|
0
|
|
)
|
|
>=
|
|
100000
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_100k_vault_stolen"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Unique player theft milestones.
|
|
*/
|
|
const uniqueStealCount =
|
|
(
|
|
statsDocument
|
|
?.unique
|
|
?.playersStolenFrom
|
|
??
|
|
[]
|
|
).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"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Slots jackpot.
|
|
*/
|
|
if (
|
|
event.type ===
|
|
"game_result"
|
|
&&
|
|
event.game ===
|
|
"slots"
|
|
&&
|
|
event.jackpot ===
|
|
true
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_slots_jackpot"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Natural Blackjack.
|
|
*/
|
|
if (
|
|
event.type ===
|
|
"game_result"
|
|
&&
|
|
event.game ===
|
|
"blackjack"
|
|
&&
|
|
event.natural ===
|
|
true
|
|
&&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_blackjack_natural"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Blackjack All-In win.
|
|
*/
|
|
if (
|
|
event.type ===
|
|
"game_result"
|
|
&&
|
|
event.game ===
|
|
"blackjack"
|
|
&&
|
|
event.allIn ===
|
|
true
|
|
&&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_blackjack_allin_win"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Roulette Zero.
|
|
*/
|
|
if (
|
|
event.type ===
|
|
"game_result"
|
|
&&
|
|
event.game ===
|
|
"roulette"
|
|
&&
|
|
event.zero ===
|
|
true
|
|
&&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_roulette_zero"
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Roulette All-In.
|
|
*/
|
|
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
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* COLLECTOR BADGES
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CLAIM GLOBAL BADGE
|
|
* ============================================================
|
|
*/
|
|
|
|
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.",
|
|
""
|
|
];
|
|
|
|
if (
|
|
badge.metadata
|
|
?.unlockedAchievements !==
|
|
undefined
|
|
&&
|
|
badge.metadata
|
|
?.availableAchievements !==
|
|
undefined
|
|
) {
|
|
description.push(
|
|
`Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`,
|
|
""
|
|
);
|
|
}
|
|
|
|
description.push(
|
|
"*This badge can no longer be earned by another player.*"
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE FOOTER / SESSION STATE
|
|
* ============================================================
|
|
*
|
|
* profile:<sessionOwnerId>:<profileUserId>:<section>:<page>:<pageCount>
|
|
*/
|
|
|
|
function buildProfileFooter(
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section = "overview",
|
|
page = 0,
|
|
pageCount = 1
|
|
) {
|
|
return [
|
|
"profile",
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section,
|
|
page,
|
|
pageCount
|
|
].join(":");
|
|
}
|
|
|
|
|
|
function parseProfileFooter(
|
|
interaction
|
|
) {
|
|
const footer =
|
|
interaction.message
|
|
?.embeds?.[0]
|
|
?.footer
|
|
?.text;
|
|
|
|
const parts =
|
|
footer?.split(":") ?? [];
|
|
|
|
if (
|
|
parts[0] !==
|
|
"profile"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
* Current footer.
|
|
*/
|
|
if (
|
|
parts.length >=
|
|
6
|
|
) {
|
|
return {
|
|
sessionOwnerId:
|
|
parts[1],
|
|
|
|
profileUserId:
|
|
parts[2],
|
|
|
|
section:
|
|
parts[3],
|
|
|
|
page:
|
|
Number(parts[4]) || 0,
|
|
|
|
pageCount:
|
|
Number(parts[5]) || 1
|
|
};
|
|
}
|
|
|
|
/*
|
|
* Legacy footer:
|
|
*
|
|
* profile:<profileUserId>
|
|
*/
|
|
if (
|
|
parts.length >=
|
|
2
|
|
) {
|
|
return {
|
|
sessionOwnerId:
|
|
parts[1],
|
|
|
|
profileUserId:
|
|
parts[1],
|
|
|
|
section:
|
|
"overview",
|
|
|
|
page:
|
|
0,
|
|
|
|
pageCount:
|
|
1
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
function getProfileTargetId(
|
|
interaction
|
|
) {
|
|
return (
|
|
parseProfileFooter(
|
|
interaction
|
|
)?.profileUserId
|
|
??
|
|
null
|
|
);
|
|
}
|
|
|
|
|
|
function getProfileSessionOwnerId(
|
|
interaction
|
|
) {
|
|
return (
|
|
parseProfileFooter(
|
|
interaction
|
|
)?.sessionOwnerId
|
|
??
|
|
null
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE OWNERSHIP
|
|
* ============================================================
|
|
*/
|
|
|
|
async function requireProfileOwner(
|
|
interaction
|
|
) {
|
|
const session =
|
|
parseProfileFooter(
|
|
interaction
|
|
);
|
|
|
|
if (!session) {
|
|
await temporaryProfileError(
|
|
interaction,
|
|
"This profile session is no longer valid."
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
session.sessionOwnerId !==
|
|
interaction.user.id
|
|
) {
|
|
await temporaryProfileError(
|
|
interaction,
|
|
"This isn't your profile session."
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
|
|
async function temporaryProfileError(
|
|
interaction,
|
|
content
|
|
) {
|
|
const payload = {
|
|
content,
|
|
|
|
flags:
|
|
MessageFlags.Ephemeral
|
|
};
|
|
|
|
if (
|
|
interaction.replied
|
|
||
|
|
interaction.deferred
|
|
) {
|
|
return interaction.followUp(
|
|
payload
|
|
);
|
|
}
|
|
|
|
return interaction.reply(
|
|
payload
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE BUTTONS
|
|
* ============================================================
|
|
*/
|
|
|
|
function buildProfileButtons() {
|
|
return [
|
|
new ActionRowBuilder()
|
|
.addComponents(
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileOverview"
|
|
)
|
|
.setLabel(
|
|
"Overview"
|
|
)
|
|
.setEmoji(
|
|
"👤"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Primary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileStats"
|
|
)
|
|
.setLabel(
|
|
"Stats"
|
|
)
|
|
.setEmoji(
|
|
"📊"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileAchievements"
|
|
)
|
|
.setLabel(
|
|
"Achievements"
|
|
)
|
|
.setEmoji(
|
|
"🏆"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileBadges"
|
|
)
|
|
.setLabel(
|
|
"Badges"
|
|
)
|
|
.setEmoji(
|
|
"🏅"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileClose"
|
|
)
|
|
.setLabel(
|
|
"Close"
|
|
)
|
|
.setEmoji(
|
|
"✖️"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
)
|
|
)
|
|
];
|
|
}
|
|
|
|
|
|
function buildProfilePaginationButtons(
|
|
leftId,
|
|
rightId,
|
|
page,
|
|
pageCount
|
|
) {
|
|
return new ActionRowBuilder()
|
|
.addComponents(
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
leftId
|
|
)
|
|
.setLabel(
|
|
"◄"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
)
|
|
.setDisabled(
|
|
page <= 0
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
rightId
|
|
)
|
|
.setLabel(
|
|
"►"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
)
|
|
.setDisabled(
|
|
page >=
|
|
pageCount - 1
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE OVERVIEW
|
|
* ============================================================
|
|
*/
|
|
|
|
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 [
|
|
achievementDocument,
|
|
activeDefinitions,
|
|
ownedBadges
|
|
] = await Promise.all([
|
|
achievements.findOne({
|
|
userId:
|
|
user.id,
|
|
|
|
guildId
|
|
}),
|
|
|
|
achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray(),
|
|
|
|
badges.find({
|
|
guildId,
|
|
|
|
ownerId:
|
|
user.id
|
|
}).toArray()
|
|
]);
|
|
|
|
const unlocked =
|
|
achievementDocument?.unlocked ?? {};
|
|
|
|
const unlockedCount =
|
|
activeDefinitions.filter(
|
|
achievement =>
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const achievementCount =
|
|
activeDefinitions.length;
|
|
|
|
const completion =
|
|
achievementCount > 0
|
|
? (
|
|
unlockedCount /
|
|
achievementCount
|
|
) * 100
|
|
: 0;
|
|
|
|
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}\n**${completion.toFixed(1)}%**`,
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"🏅 Unique Badges",
|
|
|
|
value:
|
|
`${ownedBadges.length}`,
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"🎮 Games",
|
|
|
|
value:
|
|
[
|
|
`Played: **${stats.games?.total?.played ?? 0}**`,
|
|
`Won: **${stats.games?.total?.won ?? 0}**`,
|
|
`Lost: **${stats.games?.total?.lost ?? 0}**`,
|
|
`Pushes: **${stats.games?.total?.pushed ?? 0}**`
|
|
].join("\n"),
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"🔥 Best Streaks",
|
|
|
|
value:
|
|
[
|
|
`Wins: **${stats.streaks?.bestWin ?? 0}**`,
|
|
`Losses: **${stats.streaks?.bestLoss ?? 0}**`
|
|
].join("\n"),
|
|
|
|
inline:
|
|
true
|
|
}
|
|
)
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"overview",
|
|
0,
|
|
1
|
|
)
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* STATS PAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function buildProfileStatsEmbed(
|
|
guildId,
|
|
user,
|
|
sessionOwnerId =
|
|
user.id
|
|
) {
|
|
const stats =
|
|
await getProfileStats(
|
|
user.id,
|
|
guildId
|
|
);
|
|
|
|
const embed =
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
`📊 ${user.globalName ?? user.username}'s Stats`
|
|
)
|
|
.setThumbnail(
|
|
user.displayAvatarURL({
|
|
size: 256
|
|
})
|
|
);
|
|
|
|
const games = [
|
|
[
|
|
"🎰",
|
|
"Slots",
|
|
stats.games?.slots
|
|
],
|
|
|
|
[
|
|
"🃏",
|
|
"Blackjack",
|
|
stats.games?.blackjack
|
|
],
|
|
|
|
[
|
|
"🎡",
|
|
"Roulette",
|
|
stats.games?.roulette
|
|
],
|
|
|
|
[
|
|
"🏦",
|
|
"Heist",
|
|
stats.games?.heist
|
|
]
|
|
];
|
|
|
|
for (
|
|
const [
|
|
emoji,
|
|
name,
|
|
game
|
|
]
|
|
of games
|
|
) {
|
|
const played =
|
|
game?.played ?? 0;
|
|
|
|
const won =
|
|
game?.won ?? 0;
|
|
|
|
const lost =
|
|
game?.lost ?? 0;
|
|
|
|
const pushed =
|
|
game?.pushed ?? 0;
|
|
|
|
const cookiesWon =
|
|
game?.cookiesWon ?? 0;
|
|
|
|
const cookiesLost =
|
|
game?.cookiesLost ?? 0;
|
|
|
|
const net =
|
|
cookiesWon -
|
|
cookiesLost;
|
|
|
|
const decidedGames =
|
|
won +
|
|
lost;
|
|
|
|
const winRate =
|
|
decidedGames > 0
|
|
? (
|
|
won /
|
|
decidedGames
|
|
) * 100
|
|
: 0;
|
|
|
|
embed.addFields({
|
|
name:
|
|
`${emoji} ${name}`,
|
|
|
|
value:
|
|
[
|
|
`Played: **${played.toLocaleString()}**`,
|
|
`W / L / P: **${won} / ${lost} / ${pushed}**`,
|
|
`Win Rate: **${winRate.toFixed(1)}%**`,
|
|
`Won: **${cookiesWon.toLocaleString()} 🍪**`,
|
|
`Lost: **${cookiesLost.toLocaleString()} 🍪**`,
|
|
`Net: **${net >= 0 ? "+" : ""}${net.toLocaleString()} 🍪**`
|
|
].join("\n"),
|
|
|
|
inline:
|
|
true
|
|
});
|
|
}
|
|
|
|
embed.addFields({
|
|
name:
|
|
"🍪 Economy",
|
|
|
|
value:
|
|
[
|
|
`Obtained: **${(stats.economy?.cookiesObtainedTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Lost: **${(stats.economy?.cookiesLostTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Given: **${(stats.economy?.cookiesGivenTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Stolen: **${(stats.economy?.stolenTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Stolen from you: **${(stats.economy?.stolenFromMeTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Vault stolen: **${(stats.economy?.vaultStolenTotal ?? 0).toLocaleString()} 🍪**`
|
|
].join("\n")
|
|
});
|
|
|
|
embed.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"stats",
|
|
0,
|
|
1
|
|
)
|
|
});
|
|
|
|
return embed;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ACHIEVEMENTS PAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function buildAchievementsEmbed(
|
|
guildId,
|
|
user,
|
|
page = 0,
|
|
sessionOwnerId =
|
|
user.id
|
|
) {
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const [
|
|
unlockedDocument,
|
|
definitions
|
|
] = await Promise.all([
|
|
achievements.findOne({
|
|
userId:
|
|
user.id,
|
|
|
|
guildId
|
|
}),
|
|
|
|
achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.sort({
|
|
category: 1,
|
|
target: 1,
|
|
name: 1
|
|
})
|
|
.toArray()
|
|
]);
|
|
|
|
const unlocked =
|
|
unlockedDocument?.unlocked ?? {};
|
|
|
|
const lines = [];
|
|
|
|
let lastCategory = null;
|
|
|
|
for (const achievement of definitions) {
|
|
const complete =
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
);
|
|
|
|
const category =
|
|
achievement.category
|
|
??
|
|
"other";
|
|
|
|
if (
|
|
category !==
|
|
lastCategory
|
|
) {
|
|
lines.push(
|
|
`### ${formatCategoryName(category)}`
|
|
);
|
|
|
|
lastCategory =
|
|
category;
|
|
}
|
|
|
|
const hidden =
|
|
achievement.secret ===
|
|
true
|
|
&&
|
|
!complete;
|
|
|
|
if (hidden) {
|
|
lines.push(
|
|
"⬜ ❓ **???** — Hidden achievement."
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
lines.push(
|
|
[
|
|
complete
|
|
? "✅"
|
|
: "⬜",
|
|
|
|
" ",
|
|
|
|
achievement.emoji
|
|
??
|
|
"🏆",
|
|
|
|
" **",
|
|
|
|
achievement.name,
|
|
|
|
"** — ",
|
|
|
|
achievement.requirement
|
|
??
|
|
achievement.description
|
|
??
|
|
"Requirement unavailable."
|
|
].join("")
|
|
);
|
|
}
|
|
|
|
const pages =
|
|
paginateLines(
|
|
lines,
|
|
PROFILE_PAGE_CHARACTER_LIMIT
|
|
);
|
|
|
|
const safePage =
|
|
clampPage(
|
|
page,
|
|
pages.length
|
|
);
|
|
|
|
const unlockedCount =
|
|
definitions.filter(
|
|
achievement =>
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const embed =
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
`🏆 ${user.globalName ?? user.username}'s Achievements`
|
|
)
|
|
.setDescription(
|
|
pages[safePage]
|
|
??
|
|
"No achievements configured."
|
|
)
|
|
.addFields({
|
|
name:
|
|
"Progress",
|
|
|
|
value:
|
|
`**${unlockedCount} / ${definitions.length}** achievements unlocked`
|
|
})
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"achievements",
|
|
safePage,
|
|
pages.length
|
|
)
|
|
});
|
|
|
|
return {
|
|
embed,
|
|
page:
|
|
safePage,
|
|
pageCount:
|
|
pages.length
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGES PAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function buildBadgesEmbed(
|
|
guildId,
|
|
user,
|
|
page = 0,
|
|
sessionOwnerId =
|
|
user.id
|
|
) {
|
|
const { badges } =
|
|
getProfileCollections();
|
|
|
|
const documents =
|
|
await badges
|
|
.find({
|
|
guildId
|
|
})
|
|
.sort({
|
|
category: 1,
|
|
name: 1
|
|
})
|
|
.toArray();
|
|
|
|
const categories =
|
|
groupBadgesByCategory(
|
|
documents
|
|
);
|
|
|
|
const pages = [];
|
|
|
|
/*
|
|
* Page 1:
|
|
* badges owned by viewed user.
|
|
*/
|
|
const owned =
|
|
documents.filter(
|
|
badge =>
|
|
badge.ownerId ===
|
|
user.id
|
|
);
|
|
|
|
pages.push({
|
|
title:
|
|
"Owned Badges",
|
|
|
|
description:
|
|
owned.length
|
|
? owned
|
|
.map(
|
|
badge =>
|
|
formatBadgeLine(
|
|
badge,
|
|
user.id
|
|
)
|
|
)
|
|
.join("\n\n")
|
|
: "*No unique badges owned yet.*"
|
|
});
|
|
|
|
/*
|
|
* Remaining pages:
|
|
* derived from MongoDB categories.
|
|
*/
|
|
for (
|
|
const [
|
|
category,
|
|
categoryBadges
|
|
]
|
|
of categories
|
|
) {
|
|
pages.push({
|
|
title:
|
|
formatCategoryName(
|
|
category
|
|
),
|
|
|
|
description:
|
|
categoryBadges
|
|
.map(
|
|
badge =>
|
|
formatBadgeLine(
|
|
badge,
|
|
user.id
|
|
)
|
|
)
|
|
.join("\n\n")
|
|
});
|
|
}
|
|
|
|
const safePage =
|
|
clampPage(
|
|
page,
|
|
pages.length
|
|
);
|
|
|
|
const current =
|
|
pages[safePage]
|
|
??
|
|
{
|
|
title:
|
|
"Badges",
|
|
|
|
description:
|
|
"No badges configured."
|
|
};
|
|
|
|
const embed =
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
`🏅 ${user.globalName ?? user.username}'s Badges`
|
|
)
|
|
.setDescription(
|
|
[
|
|
`### ${current.title}`,
|
|
"",
|
|
current.description
|
|
].join("\n")
|
|
)
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"badges",
|
|
safePage,
|
|
pages.length
|
|
)
|
|
});
|
|
|
|
return {
|
|
embed,
|
|
page:
|
|
safePage,
|
|
pageCount:
|
|
pages.length
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGE DISPLAY HELPERS
|
|
* ============================================================
|
|
*/
|
|
|
|
function groupBadgesByCategory(
|
|
badges
|
|
) {
|
|
const categories =
|
|
new Map();
|
|
|
|
for (const badge of badges) {
|
|
const category =
|
|
badge.category
|
|
??
|
|
inferBadgeCategory(
|
|
badge.badgeId
|
|
);
|
|
|
|
if (
|
|
!categories.has(
|
|
category
|
|
)
|
|
) {
|
|
categories.set(
|
|
category,
|
|
[]
|
|
);
|
|
}
|
|
|
|
categories
|
|
.get(category)
|
|
.push(badge);
|
|
}
|
|
|
|
return categories;
|
|
}
|
|
|
|
|
|
function inferBadgeCategory(
|
|
badgeId = ""
|
|
) {
|
|
if (
|
|
badgeId.includes(
|
|
"slots"
|
|
)
|
|
) {
|
|
return "slots";
|
|
}
|
|
|
|
if (
|
|
badgeId.includes(
|
|
"blackjack"
|
|
)
|
|
) {
|
|
return "blackjack";
|
|
}
|
|
|
|
if (
|
|
badgeId.includes(
|
|
"roulette"
|
|
)
|
|
) {
|
|
return "roulette";
|
|
}
|
|
|
|
if (
|
|
badgeId.includes(
|
|
"achievement"
|
|
)
|
|
) {
|
|
return "collector";
|
|
}
|
|
|
|
if (
|
|
badgeId.includes(
|
|
"steal"
|
|
)
|
|
) {
|
|
return "stealing";
|
|
}
|
|
|
|
if (
|
|
badgeId.includes(
|
|
"vault"
|
|
)
|
|
) {
|
|
return "heist";
|
|
}
|
|
|
|
return "economy";
|
|
}
|
|
|
|
|
|
function formatBadgeLine(
|
|
badge,
|
|
profileUserId
|
|
) {
|
|
const hidden =
|
|
badge.secret ===
|
|
true
|
|
&&
|
|
!badge.ownerId;
|
|
|
|
if (hidden) {
|
|
return [
|
|
"❓ **???**",
|
|
"*Hidden global badge.*"
|
|
].join("\n");
|
|
}
|
|
|
|
let status;
|
|
|
|
if (
|
|
badge.ownerId ===
|
|
profileUserId
|
|
) {
|
|
status =
|
|
"✅ **Owned by this player**";
|
|
}
|
|
|
|
else if (
|
|
badge.ownerId
|
|
) {
|
|
status =
|
|
`🔒 Claimed by <@${badge.ownerId}>`;
|
|
}
|
|
|
|
else {
|
|
status =
|
|
"✨ **Still available**";
|
|
}
|
|
|
|
const lines = [
|
|
`${badge.emoji ?? "🏅"} **${badge.name ?? badge.badgeId}**`,
|
|
badge.description
|
|
??
|
|
"No description.",
|
|
status
|
|
];
|
|
|
|
if (badge.unlockedAt) {
|
|
lines.push(
|
|
`Claimed <t:${badge.unlockedAt}:R>`
|
|
);
|
|
}
|
|
|
|
if (
|
|
badge.metadata
|
|
?.unlockedAchievements !==
|
|
undefined
|
|
&&
|
|
badge.metadata
|
|
?.availableAchievements !==
|
|
undefined
|
|
) {
|
|
lines.push(
|
|
`Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**`
|
|
);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* RECONCILE ACHIEVEMENTS
|
|
* ============================================================
|
|
*/
|
|
|
|
async function reconcileProfileAchievements(
|
|
guildId,
|
|
{
|
|
userId = null,
|
|
dryRun = false
|
|
} = {}
|
|
) {
|
|
const {
|
|
stats,
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const profileFilter = {
|
|
guildId
|
|
};
|
|
|
|
if (userId) {
|
|
profileFilter.userId =
|
|
userId;
|
|
}
|
|
|
|
const [
|
|
profileDocuments,
|
|
definitions
|
|
] = await Promise.all([
|
|
stats.find(
|
|
profileFilter
|
|
).toArray(),
|
|
|
|
achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray()
|
|
]);
|
|
|
|
const normalDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure !==
|
|
"achievementPercentage"
|
|
&&
|
|
Boolean(
|
|
definition.stat
|
|
)
|
|
);
|
|
|
|
const eventOnlyDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure !==
|
|
"achievementPercentage"
|
|
&&
|
|
!definition.stat
|
|
);
|
|
|
|
let unlocksFound = 0;
|
|
let unlocksApplied = 0;
|
|
|
|
const details = [];
|
|
|
|
for (const profile of profileDocuments) {
|
|
const profileUserId =
|
|
profile.userId;
|
|
|
|
if (!profileUserId) {
|
|
continue;
|
|
}
|
|
|
|
const achievementDocument =
|
|
await achievements.findOne({
|
|
userId:
|
|
profileUserId,
|
|
|
|
guildId
|
|
});
|
|
|
|
const unlocked =
|
|
achievementDocument?.unlocked ?? {};
|
|
|
|
for (const definition of normalDefinitions) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const currentValue =
|
|
readStatValue(
|
|
profile,
|
|
definition
|
|
);
|
|
|
|
if (
|
|
currentValue <
|
|
Number(definition.target)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
unlocksFound++;
|
|
|
|
details.push({
|
|
userId:
|
|
profileUserId,
|
|
|
|
achievementId:
|
|
definition.achievementId,
|
|
|
|
achievementName:
|
|
definition.name
|
|
});
|
|
|
|
if (dryRun) {
|
|
continue;
|
|
}
|
|
|
|
await ensureAchievementDocument(
|
|
profileUserId,
|
|
guildId
|
|
);
|
|
|
|
const field =
|
|
`unlocked.${definition.achievementId}`;
|
|
|
|
const result =
|
|
await achievements.updateOne(
|
|
{
|
|
userId:
|
|
profileUserId,
|
|
|
|
guildId,
|
|
|
|
[field]: {
|
|
$exists:
|
|
false
|
|
}
|
|
},
|
|
{
|
|
$set: {
|
|
[field]:
|
|
getUnixTime()
|
|
}
|
|
}
|
|
);
|
|
|
|
if (
|
|
result.modifiedCount ===
|
|
1
|
|
) {
|
|
unlocksApplied++;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Collector achievements.
|
|
*/
|
|
const collectorDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
);
|
|
|
|
if (
|
|
collectorDefinitions.length ===
|
|
0
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const refreshed =
|
|
dryRun
|
|
? unlocked
|
|
: (
|
|
await achievements.findOne({
|
|
userId:
|
|
profileUserId,
|
|
|
|
guildId
|
|
})
|
|
)?.unlocked
|
|
??
|
|
{};
|
|
|
|
const collectible =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure !==
|
|
"achievementPercentage"
|
|
);
|
|
|
|
const achieved =
|
|
collectible.filter(
|
|
definition => {
|
|
if (
|
|
refreshed[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
dryRun
|
|
&&
|
|
definition.stat
|
|
) {
|
|
return (
|
|
readStatValue(
|
|
profile,
|
|
definition
|
|
)
|
|
>=
|
|
Number(
|
|
definition.target
|
|
)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
).length;
|
|
|
|
const percentage =
|
|
collectible.length > 0
|
|
? achieved /
|
|
collectible.length
|
|
: 0;
|
|
|
|
for (
|
|
const definition
|
|
of collectorDefinitions
|
|
) {
|
|
if (
|
|
refreshed[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
percentage <
|
|
Number(definition.target)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
unlocksFound++;
|
|
|
|
details.push({
|
|
userId:
|
|
profileUserId,
|
|
|
|
achievementId:
|
|
definition.achievementId,
|
|
|
|
achievementName:
|
|
definition.name
|
|
});
|
|
|
|
if (dryRun) {
|
|
continue;
|
|
}
|
|
|
|
const field =
|
|
`unlocked.${definition.achievementId}`;
|
|
|
|
const result =
|
|
await achievements.updateOne(
|
|
{
|
|
userId:
|
|
profileUserId,
|
|
|
|
guildId,
|
|
|
|
[field]: {
|
|
$exists:
|
|
false
|
|
}
|
|
},
|
|
{
|
|
$set: {
|
|
[field]:
|
|
getUnixTime()
|
|
}
|
|
}
|
|
);
|
|
|
|
if (
|
|
result.modifiedCount ===
|
|
1
|
|
) {
|
|
unlocksApplied++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
profilesChecked:
|
|
profileDocuments.length,
|
|
|
|
unlocksFound,
|
|
unlocksApplied,
|
|
|
|
skippedEventOnly:
|
|
eventOnlyDefinitions.length,
|
|
|
|
details
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE SYSTEM AUDIT
|
|
* ============================================================
|
|
*/
|
|
|
|
async function auditProfileSystem(
|
|
guildId
|
|
) {
|
|
const {
|
|
stats,
|
|
achievements,
|
|
badges,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const [
|
|
profileDocuments,
|
|
achievementDocuments,
|
|
activeAchievements,
|
|
legacyAchievements,
|
|
badgeDocuments,
|
|
definitions
|
|
] = await Promise.all([
|
|
stats.countDocuments({
|
|
guildId
|
|
}),
|
|
|
|
achievements.countDocuments({
|
|
guildId
|
|
}),
|
|
|
|
achievementConfig.countDocuments({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
}),
|
|
|
|
achievementConfig.countDocuments({
|
|
legacy:
|
|
true
|
|
}),
|
|
|
|
badges.find({
|
|
guildId
|
|
}).toArray(),
|
|
|
|
achievementConfig
|
|
.find({})
|
|
.toArray()
|
|
]);
|
|
|
|
const claimedBadges =
|
|
badgeDocuments.filter(
|
|
badge =>
|
|
Boolean(
|
|
badge.ownerId
|
|
)
|
|
).length;
|
|
|
|
const availableBadges =
|
|
badgeDocuments.length -
|
|
claimedBadges;
|
|
|
|
const warnings = [];
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGE VALIDATION
|
|
* ============================================================
|
|
*/
|
|
|
|
const badgeIds =
|
|
new Set();
|
|
|
|
for (const badge of badgeDocuments) {
|
|
if (!badge.badgeId) {
|
|
warnings.push(
|
|
`Badge document ${badge._id} has no badgeId.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
badgeIds.has(
|
|
badge.badgeId
|
|
)
|
|
) {
|
|
warnings.push(
|
|
`Duplicate badgeId detected: ${badge.badgeId}`
|
|
);
|
|
}
|
|
|
|
badgeIds.add(
|
|
badge.badgeId
|
|
);
|
|
}
|
|
|
|
const expectedBadgeIds = [
|
|
"first_100k_cookies",
|
|
"first_1m_cookies",
|
|
"first_100k_vault_stolen",
|
|
|
|
"first_unique_steals_5",
|
|
"first_unique_steals_10",
|
|
"first_unique_steals_15",
|
|
|
|
"first_slots_jackpot",
|
|
|
|
"first_blackjack_natural",
|
|
"first_blackjack_allin_win",
|
|
|
|
"first_roulette_zero",
|
|
"first_roulette_allin_win",
|
|
|
|
"first_achievement_half",
|
|
"first_achievement_all"
|
|
];
|
|
|
|
for (const badgeId of expectedBadgeIds) {
|
|
if (
|
|
!badgeIds.has(
|
|
badgeId
|
|
)
|
|
) {
|
|
warnings.push(
|
|
`Expected guild badge is missing: ${badgeId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ACHIEVEMENT CONFIG VALIDATION
|
|
* ============================================================
|
|
*/
|
|
|
|
const achievementIds =
|
|
new Set();
|
|
|
|
for (const achievement of definitions) {
|
|
if (!achievement.achievementId) {
|
|
warnings.push(
|
|
`Achievement config ${achievement._id} has no achievementId.`
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
achievementIds.has(
|
|
achievement.achievementId
|
|
)
|
|
) {
|
|
warnings.push(
|
|
`Duplicate achievementId detected: ${achievement.achievementId}`
|
|
);
|
|
}
|
|
|
|
achievementIds.add(
|
|
achievement.achievementId
|
|
);
|
|
|
|
if (
|
|
achievement.active !==
|
|
false
|
|
&&
|
|
achievement.measure !==
|
|
"achievementPercentage"
|
|
&&
|
|
!achievement.stat
|
|
) {
|
|
warnings.push(
|
|
`Active achievement ${achievement.achievementId} has no stat path.`
|
|
);
|
|
}
|
|
|
|
if (
|
|
achievement.target ===
|
|
undefined
|
|
) {
|
|
warnings.push(
|
|
`Achievement ${achievement.achievementId} has no target.`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ORPHANED UNLOCKS
|
|
* ============================================================
|
|
*/
|
|
|
|
const unlockedDocuments =
|
|
await achievements
|
|
.find({
|
|
guildId
|
|
})
|
|
.toArray();
|
|
|
|
const orphanedUnlocks =
|
|
new Set();
|
|
|
|
for (const document of unlockedDocuments) {
|
|
const unlocked =
|
|
document.unlocked
|
|
??
|
|
{};
|
|
|
|
for (
|
|
const achievementId
|
|
of Object.keys(unlocked)
|
|
) {
|
|
if (
|
|
!achievementIds.has(
|
|
achievementId
|
|
)
|
|
) {
|
|
orphanedUnlocks.add(
|
|
achievementId
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (
|
|
const achievementId
|
|
of orphanedUnlocks
|
|
) {
|
|
warnings.push(
|
|
`Unlocked achievement has no config definition: ${achievementId}`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE SANITY
|
|
* ============================================================
|
|
*/
|
|
|
|
const profileDocs =
|
|
await stats
|
|
.find({
|
|
guildId
|
|
})
|
|
.toArray();
|
|
|
|
for (const profile of profileDocs) {
|
|
if (!profile.userId) {
|
|
warnings.push(
|
|
`Profile document ${profile._id} has no userId.`
|
|
);
|
|
}
|
|
|
|
if (!profile.games) {
|
|
warnings.push(
|
|
`Profile for ${profile.userId ?? profile._id} has no games object.`
|
|
);
|
|
}
|
|
|
|
if (!profile.economy) {
|
|
warnings.push(
|
|
`Profile for ${profile.userId ?? profile._id} has no economy object.`
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
profileDocuments,
|
|
achievementDocuments,
|
|
|
|
activeAchievements,
|
|
legacyAchievements,
|
|
|
|
badges:
|
|
badgeDocuments.length,
|
|
|
|
claimedBadges,
|
|
availableBadges,
|
|
|
|
warnings
|
|
};
|
|
}
|
|
|
|
/*
|
|
* ============================================================
|
|
* COMPATIBLITY HELPERS
|
|
* ============================================================
|
|
*/
|
|
|
|
const buildStatsEmbed =
|
|
buildProfileStatsEmbed;
|
|
|
|
/*
|
|
* ============================================================
|
|
* PAGINATION HELPERS
|
|
* ============================================================
|
|
*/
|
|
|
|
function paginateLines(
|
|
lines,
|
|
maxLength =
|
|
PROFILE_PAGE_CHARACTER_LIMIT
|
|
) {
|
|
const pages = [];
|
|
|
|
let current = [];
|
|
|
|
for (const line of lines) {
|
|
const candidate =
|
|
[
|
|
...current,
|
|
line
|
|
].join("\n");
|
|
|
|
if (
|
|
candidate.length >
|
|
maxLength
|
|
&&
|
|
current.length >
|
|
0
|
|
) {
|
|
pages.push(
|
|
current.join("\n")
|
|
);
|
|
|
|
current = [
|
|
line
|
|
];
|
|
}
|
|
|
|
else {
|
|
current.push(
|
|
line
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
current.length >
|
|
0
|
|
) {
|
|
pages.push(
|
|
current.join("\n")
|
|
);
|
|
}
|
|
|
|
if (
|
|
pages.length ===
|
|
0
|
|
) {
|
|
pages.push(
|
|
"Nothing to display."
|
|
);
|
|
}
|
|
|
|
return pages;
|
|
}
|
|
|
|
|
|
function clampPage(
|
|
page,
|
|
pageCount
|
|
) {
|
|
const numeric =
|
|
Number(page) || 0;
|
|
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
numeric,
|
|
Math.max(
|
|
0,
|
|
pageCount - 1
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
function formatCategoryName(
|
|
category
|
|
) {
|
|
return String(
|
|
category ?? "other"
|
|
)
|
|
.replace(
|
|
/[_-]+/g,
|
|
" "
|
|
)
|
|
.replace(
|
|
/\b\w/g,
|
|
character =>
|
|
character.toUpperCase()
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* EXPORTS
|
|
* ============================================================
|
|
*/
|
|
|
|
module.exports = {
|
|
ensureProfileSystem,
|
|
|
|
getProfileCollections,
|
|
getProfileStats,
|
|
getDefaultStats,
|
|
|
|
trackProfileEvent,
|
|
recordGameOutcome,
|
|
|
|
evaluateAchievements,
|
|
evaluateBadges,
|
|
tryClaimBadge,
|
|
|
|
buildProfileOverview,
|
|
|
|
buildProfileStatsEmbed,
|
|
buildStatsEmbed,
|
|
|
|
buildAchievementsEmbed,
|
|
buildBadgesEmbed,
|
|
|
|
buildProfileButtons,
|
|
buildProfilePaginationButtons,
|
|
|
|
buildProfileFooter,
|
|
parseProfileFooter,
|
|
|
|
getProfileTargetId,
|
|
getProfileSessionOwnerId,
|
|
requireProfileOwner,
|
|
|
|
reconcileProfileAchievements,
|
|
auditProfileSystem
|
|
}; |