2406 lines
43 KiB
JavaScript
2406 lines
43 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"
|
|
};
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* 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
|
|
)
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* INITIALIZE
|
|
* ============================================================
|
|
*/
|
|
|
|
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 STATS DOCUMENT
|
|
* ============================================================
|
|
*/
|
|
|
|
async function ensureStatsDocument(
|
|
userId,
|
|
guildId
|
|
) {
|
|
const {
|
|
stats
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$setOnInsert:
|
|
getDefaultStats(
|
|
userId,
|
|
guildId
|
|
)
|
|
},
|
|
{
|
|
upsert:
|
|
true
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ENSURE ACHIEVEMENT DOCUMENT
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* TRACK PROFILE EVENT
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
) {
|
|
/*
|
|
* Lifetime gain.
|
|
*/
|
|
|
|
case "cookie_gain":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.cookiesObtainedTotal":
|
|
event.amount
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Lifetime loss.
|
|
*/
|
|
|
|
case "cookie_loss":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.cookiesLostTotal":
|
|
event.amount
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Voluntary giving.
|
|
*/
|
|
|
|
case "cookies_given":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.cookiesGivenTotal":
|
|
event.amount
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Successful player theft.
|
|
*/
|
|
|
|
case "steal_success":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.stolenTotal":
|
|
event.amount
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenFrom":
|
|
event.targetUserId
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Victim side.
|
|
*/
|
|
|
|
case "stolen_from_me":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.stolenFromMeTotal":
|
|
event.amount
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenBy":
|
|
event.thiefUserId
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Successful Bot Vault Heist.
|
|
*/
|
|
|
|
case "vault_steal":
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.vaultStolenTotal":
|
|
event.amount
|
|
}
|
|
}
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
/*
|
|
* Game result.
|
|
*/
|
|
|
|
case "game_result":
|
|
|
|
await trackGameResult(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
/*
|
|
* Always evaluate from fresh DB state.
|
|
*/
|
|
|
|
const updatedStats =
|
|
await getProfileStats(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
|
|
await evaluateAchievements(
|
|
interaction,
|
|
userId,
|
|
updatedStats
|
|
);
|
|
|
|
|
|
await evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
updatedStats,
|
|
event
|
|
);
|
|
|
|
|
|
return updatedStats;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* GAME RESULT TRACKING
|
|
* ============================================================
|
|
*/
|
|
|
|
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 = {};
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* WIN
|
|
* ========================================================
|
|
*/
|
|
|
|
if (
|
|
result ===
|
|
"win"
|
|
) {
|
|
increment[
|
|
"games.total.won"
|
|
] =
|
|
1;
|
|
|
|
|
|
increment[
|
|
`games.${game}.won`
|
|
] =
|
|
1;
|
|
|
|
|
|
increment[
|
|
"streaks.currentWin"
|
|
] =
|
|
1;
|
|
|
|
|
|
set[
|
|
"streaks.currentLoss"
|
|
] =
|
|
0;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* LOSS
|
|
* ========================================================
|
|
*/
|
|
|
|
else if (
|
|
result ===
|
|
"loss"
|
|
) {
|
|
increment[
|
|
"games.total.lost"
|
|
] =
|
|
1;
|
|
|
|
|
|
increment[
|
|
`games.${game}.lost`
|
|
] =
|
|
1;
|
|
|
|
|
|
increment[
|
|
"streaks.currentLoss"
|
|
] =
|
|
1;
|
|
|
|
|
|
set[
|
|
"streaks.currentWin"
|
|
] =
|
|
0;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* PUSH
|
|
* ========================================================
|
|
*/
|
|
|
|
else if (
|
|
result ===
|
|
"push"
|
|
) {
|
|
increment[
|
|
"games.total.pushed"
|
|
] =
|
|
1;
|
|
|
|
|
|
increment[
|
|
`games.${game}.pushed`
|
|
] =
|
|
1;
|
|
|
|
|
|
/*
|
|
* Push leaves streaks unchanged.
|
|
*/
|
|
}
|
|
|
|
|
|
else {
|
|
return;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* REALIZED NET COOKIE RESULT
|
|
* ========================================================
|
|
*
|
|
* Positive:
|
|
*
|
|
* cookiesWon += profit
|
|
*
|
|
* Negative:
|
|
*
|
|
* cookiesLost += abs(profit)
|
|
*
|
|
* Push:
|
|
*
|
|
* normally profit = 0
|
|
*/
|
|
|
|
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
|
|
}
|
|
: {})
|
|
}
|
|
);
|
|
|
|
|
|
/*
|
|
* Update best streaks.
|
|
*/
|
|
|
|
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;
|
|
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
await ensureAchievementDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
|
|
/*
|
|
* Only active achievements count toward
|
|
* normal evaluation and collector progress.
|
|
*/
|
|
|
|
const definitions =
|
|
await achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne:
|
|
false
|
|
}
|
|
})
|
|
.toArray();
|
|
|
|
|
|
let unlockedDoc =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
|
|
let unlocked =
|
|
unlockedDoc?.unlocked
|
|
??
|
|
{};
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* NORMAL ACHIEVEMENTS FIRST
|
|
* ========================================================
|
|
*
|
|
* Collector achievements are evaluated afterwards,
|
|
* because unlocking one normal achievement can itself
|
|
* push the user over 50% or 100%.
|
|
*/
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Refresh after normal unlocks.
|
|
*/
|
|
|
|
unlockedDoc =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
|
|
unlocked =
|
|
unlockedDoc?.unlocked
|
|
??
|
|
{};
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* COLLECTOR ACHIEVEMENTS
|
|
* ========================================================
|
|
*/
|
|
|
|
const collectorDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
);
|
|
|
|
|
|
/*
|
|
* Collector achievements should NOT count themselves
|
|
* toward the denominator.
|
|
*
|
|
* Otherwise adding "50%" and "100%" achievements changes
|
|
* the percentage they are measuring.
|
|
*/
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* 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;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* READ STAT VALUE
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
* ============================================================
|
|
*/
|
|
|
|
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 {
|
|
/*
|
|
* Unlock stays persisted even if the Discord
|
|
* interaction can no longer accept follow-ups.
|
|
*/
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGE EVALUATION
|
|
* ============================================================
|
|
*
|
|
* Existing badges remain supported here.
|
|
*
|
|
* We can expand this next with:
|
|
*
|
|
* - first Slots jackpot
|
|
* - first natural Blackjack
|
|
* - first Roulette Zero
|
|
* - first 5 / 10 / 15 unique steals
|
|
* - Achievement Hunter
|
|
* - Completionist
|
|
*/
|
|
|
|
async function evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
statsDocument,
|
|
event =
|
|
{}
|
|
) {
|
|
const guildId =
|
|
interaction.guildId;
|
|
|
|
|
|
if (!guildId) {
|
|
return;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ECONOMY BADGES
|
|
* ========================================================
|
|
*/
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* VAULT BADGE
|
|
* ========================================================
|
|
*/
|
|
|
|
if (
|
|
(
|
|
statsDocument
|
|
?.economy
|
|
?.vaultStolenTotal
|
|
??
|
|
0
|
|
)
|
|
>=
|
|
100000
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_100k_vault_stolen"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* UNIQUE PLAYER THEFT BADGES
|
|
* ========================================================
|
|
*/
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* SLOT 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
|
|
* ========================================================
|
|
*/
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ACHIEVEMENT COLLECTOR BADGES
|
|
* ========================================================
|
|
*/
|
|
|
|
await evaluateCollectorBadges(
|
|
interaction,
|
|
userId
|
|
);
|
|
}
|
|
|
|
async function evaluateCollectorBadges(
|
|
interaction,
|
|
userId
|
|
) {
|
|
const guildId =
|
|
interaction.guildId;
|
|
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
/*
|
|
* Don't count Achievement Hunter / Completionist
|
|
* themselves toward the percentage.
|
|
*/
|
|
|
|
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;
|
|
|
|
|
|
/*
|
|
* First player to 50%.
|
|
*/
|
|
|
|
if (
|
|
percentage >=
|
|
0.5
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_achievement_half",
|
|
{
|
|
unlockedAchievements:
|
|
unlockedCount,
|
|
|
|
availableAchievements:
|
|
activeAchievements.length
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* First player to 100%.
|
|
*/
|
|
|
|
if (
|
|
percentage >=
|
|
1
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_achievement_all",
|
|
{
|
|
unlockedAchievements:
|
|
unlockedCount,
|
|
|
|
availableAchievements:
|
|
activeAchievements.length
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* CLAIM BADGE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
badgeId,
|
|
metadata =
|
|
null
|
|
) {
|
|
const {
|
|
badges
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
const guildId =
|
|
interaction.guildId;
|
|
|
|
|
|
if (
|
|
!guildId
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
/*
|
|
* Build the fields that should be written
|
|
* only when this badge is actually claimed.
|
|
*/
|
|
|
|
const update = {
|
|
ownerId:
|
|
userId,
|
|
|
|
unlockedAt:
|
|
getUnixTime()
|
|
};
|
|
|
|
|
|
/*
|
|
* Optional historical context.
|
|
*
|
|
* Example for Completionist:
|
|
*
|
|
* {
|
|
* unlockedAchievements: 64,
|
|
* availableAchievements: 64
|
|
* }
|
|
*/
|
|
|
|
if (
|
|
metadata
|
|
&&
|
|
typeof metadata ===
|
|
"object"
|
|
) {
|
|
update.metadata =
|
|
metadata;
|
|
}
|
|
|
|
|
|
/*
|
|
* Atomic claim.
|
|
*
|
|
* Only an unclaimed badge can be updated.
|
|
*
|
|
* If two users qualify at essentially the
|
|
* same time, MongoDB lets only one update
|
|
* ownerId from null.
|
|
*/
|
|
|
|
const badge =
|
|
await badges.findOneAndUpdate(
|
|
{
|
|
guildId,
|
|
badgeId,
|
|
|
|
ownerId:
|
|
null
|
|
},
|
|
{
|
|
$set:
|
|
update
|
|
},
|
|
{
|
|
returnDocument:
|
|
"after"
|
|
}
|
|
);
|
|
|
|
|
|
/*
|
|
* Badge does not exist, was already claimed,
|
|
* or this user did not win the atomic claim.
|
|
*/
|
|
|
|
if (
|
|
!badge
|
|
||
|
|
badge.ownerId !==
|
|
userId
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* PUBLIC ANNOUNCEMENT
|
|
* ========================================================
|
|
*/
|
|
|
|
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.*"
|
|
];
|
|
|
|
|
|
/*
|
|
* Add collector history if applicable.
|
|
*/
|
|
|
|
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) {
|
|
/*
|
|
* The badge claim itself is already persisted.
|
|
*
|
|
* A Discord send failure should never undo it.
|
|
*/
|
|
|
|
console.error(
|
|
`[Badge] Failed to announce ${badgeId} for ${userId}:`,
|
|
error
|
|
);
|
|
}
|
|
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE OVERVIEW
|
|
* ============================================================
|
|
*/
|
|
|
|
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}`
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* BADGES PAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
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}`
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* ACHIEVEMENTS PAGE
|
|
* ============================================================
|
|
*/
|
|
|
|
async function buildAchievementsEmbed(
|
|
guildId,
|
|
user
|
|
) {
|
|
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
|
|
??
|
|
{};
|
|
|
|
|
|
const lines =
|
|
definitions.map(
|
|
achievement => {
|
|
const complete =
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
);
|
|
|
|
|
|
return [
|
|
complete
|
|
? "✅"
|
|
: "⬜",
|
|
|
|
" ",
|
|
|
|
achievement.emoji,
|
|
|
|
" **",
|
|
|
|
achievement.name,
|
|
|
|
"** — ",
|
|
|
|
achievement.requirement
|
|
].join("");
|
|
}
|
|
);
|
|
|
|
|
|
return new EmbedBuilder()
|
|
.setTitle(
|
|
`🏆 ${user.globalName ?? user.username}'s Achievements`
|
|
)
|
|
|
|
.setDescription(
|
|
lines.join("\n")
|
|
||
|
|
"No achievements configured."
|
|
)
|
|
|
|
.setFooter({
|
|
text:
|
|
`profile:${user.id}`
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE BUTTONS
|
|
* ============================================================
|
|
*/
|
|
|
|
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
|
|
)
|
|
)
|
|
];
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* PROFILE TARGET
|
|
* ============================================================
|
|
*/
|
|
|
|
function getProfileTargetId(
|
|
interaction
|
|
) {
|
|
const footer =
|
|
interaction.message
|
|
?.embeds?.[0]
|
|
?.footer
|
|
?.text;
|
|
|
|
|
|
const [
|
|
type,
|
|
userId
|
|
] =
|
|
footer?.split(":")
|
|
??
|
|
[];
|
|
|
|
|
|
if (
|
|
type !==
|
|
"profile"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
|
|
return userId;
|
|
}
|
|
|
|
|
|
/*
|
|
* ============================================================
|
|
* EXPORTS
|
|
* ============================================================
|
|
*/
|
|
|
|
module.exports = {
|
|
ensureProfileSystem,
|
|
|
|
trackProfileEvent,
|
|
getProfileStats,
|
|
|
|
buildProfileOverview,
|
|
buildBadgesEmbed,
|
|
buildAchievementsEmbed,
|
|
buildProfileButtons,
|
|
|
|
getProfileTargetId
|
|
}; |