4022 lines
82 KiB
JavaScript
4022 lines
82 KiB
JavaScript
const {
|
|
EmbedBuilder,
|
|
ActionRowBuilder,
|
|
ButtonBuilder,
|
|
ButtonStyle,
|
|
MessageFlags
|
|
} = require("discord.js");
|
|
|
|
const { mClient } = require("../index");
|
|
const { getCookieBalance, getUnixTime } = require("./cookieEconomy");
|
|
|
|
const DATABASE_NAME = process.env.M_DB;
|
|
|
|
const COLLECTIONS = {
|
|
stats: "stats_profiles",
|
|
achievements: "items_achievements",
|
|
badges: "guild_badges",
|
|
achievementConfig: "config_achievements"
|
|
};
|
|
|
|
const PROFILE_TIMEZONE =
|
|
process.env.PROFILE_TIMEZONE ??
|
|
"Europe/Berlin";
|
|
|
|
const WEEKDAYS = [
|
|
"monday",
|
|
"tuesday",
|
|
"wednesday",
|
|
"thursday",
|
|
"friday",
|
|
"saturday",
|
|
"sunday"
|
|
];
|
|
function getActivityDateContext(
|
|
date = new Date()
|
|
) {
|
|
const formatter =
|
|
new Intl.DateTimeFormat(
|
|
"en-CA",
|
|
{
|
|
timeZone:
|
|
PROFILE_TIMEZONE,
|
|
|
|
year:
|
|
"numeric",
|
|
|
|
month:
|
|
"2-digit",
|
|
|
|
day:
|
|
"2-digit",
|
|
|
|
weekday:
|
|
"long",
|
|
|
|
hour:
|
|
"2-digit",
|
|
|
|
hourCycle:
|
|
"h23"
|
|
}
|
|
);
|
|
|
|
const parts =
|
|
Object.fromEntries(
|
|
formatter
|
|
.formatToParts(date)
|
|
.filter(
|
|
part =>
|
|
part.type !==
|
|
"literal"
|
|
)
|
|
.map(
|
|
part => [
|
|
part.type,
|
|
part.value
|
|
]
|
|
)
|
|
);
|
|
|
|
const dateKey =
|
|
`${parts.year}-${parts.month}-${parts.day}`;
|
|
|
|
const weekday =
|
|
String(
|
|
parts.weekday
|
|
).toLowerCase();
|
|
|
|
const hour =
|
|
Number(
|
|
parts.hour
|
|
);
|
|
|
|
return {
|
|
dateKey,
|
|
yesterdayKey:
|
|
getPreviousDateKey(
|
|
dateKey
|
|
),
|
|
|
|
weekday,
|
|
hour,
|
|
|
|
weekKey:
|
|
getIsoWeekKey(
|
|
dateKey
|
|
),
|
|
|
|
timeBucket:
|
|
getTimeBucket(
|
|
hour
|
|
)
|
|
};
|
|
}
|
|
|
|
|
|
function getPreviousDateKey(
|
|
dateKey
|
|
) {
|
|
const date =
|
|
new Date(
|
|
`${dateKey}T00:00:00Z`
|
|
);
|
|
|
|
date.setUTCDate(
|
|
date.getUTCDate() - 1
|
|
);
|
|
|
|
return date
|
|
.toISOString()
|
|
.slice(
|
|
0,
|
|
10
|
|
);
|
|
}
|
|
|
|
|
|
function getIsoWeekKey(
|
|
dateKey
|
|
) {
|
|
const date =
|
|
new Date(
|
|
`${dateKey}T00:00:00Z`
|
|
);
|
|
|
|
const day =
|
|
date.getUTCDay() || 7;
|
|
|
|
date.setUTCDate(
|
|
date.getUTCDate() +
|
|
4 -
|
|
day
|
|
);
|
|
|
|
const year =
|
|
date.getUTCFullYear();
|
|
|
|
const yearStart =
|
|
new Date(
|
|
Date.UTC(
|
|
year,
|
|
0,
|
|
1
|
|
)
|
|
);
|
|
|
|
const week =
|
|
Math.ceil(
|
|
(
|
|
(
|
|
date -
|
|
yearStart
|
|
)
|
|
/
|
|
86400000
|
|
+
|
|
1
|
|
)
|
|
/
|
|
7
|
|
);
|
|
|
|
return (
|
|
`${year}-W${String(week).padStart(2, "0")}`
|
|
);
|
|
}
|
|
|
|
|
|
function getTimeBucket(
|
|
hour
|
|
) {
|
|
if (
|
|
hour >= 0 &&
|
|
hour <= 5
|
|
) {
|
|
return "overnight";
|
|
}
|
|
|
|
if (
|
|
hour >= 6 &&
|
|
hour <= 11
|
|
) {
|
|
return "morning";
|
|
}
|
|
|
|
if (
|
|
hour >= 12 &&
|
|
hour <= 17
|
|
) {
|
|
return "afternoon";
|
|
}
|
|
|
|
return "evening";
|
|
}
|
|
|
|
async function trackGameActivity(
|
|
userId,
|
|
guildId
|
|
) {
|
|
const { stats } =
|
|
getProfileCollections();
|
|
|
|
const {
|
|
dateKey,
|
|
yesterdayKey,
|
|
weekday,
|
|
weekKey,
|
|
timeBucket
|
|
} =
|
|
getActivityDateContext();
|
|
|
|
|
|
/*
|
|
* Every completed game increments its weekday
|
|
* and time-bucket counters.
|
|
*/
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
[`activity.gamesByWeekday.${weekday}`]:
|
|
1,
|
|
|
|
[`activity.gamesByTimeBucket.${timeBucket}`]:
|
|
1
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
/*
|
|
* Calendar-day state is only updated once per
|
|
* player per local calendar day.
|
|
*
|
|
* The filter also protects against two games
|
|
* resolving at nearly the same moment.
|
|
*/
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId,
|
|
|
|
"activity.lastGameDate": {
|
|
$ne:
|
|
dateKey
|
|
}
|
|
},
|
|
[
|
|
{
|
|
$set: {
|
|
"activity.currentDailyStreak": {
|
|
$cond: [
|
|
{
|
|
$eq: [
|
|
"$activity.lastGameDate",
|
|
yesterdayKey
|
|
]
|
|
},
|
|
|
|
{
|
|
$add: [
|
|
{
|
|
$ifNull: [
|
|
"$activity.currentDailyStreak",
|
|
0
|
|
]
|
|
},
|
|
1
|
|
]
|
|
},
|
|
|
|
1
|
|
]
|
|
},
|
|
|
|
"activity.currentWeekDays": {
|
|
$cond: [
|
|
{
|
|
$eq: [
|
|
"$activity.currentWeekKey",
|
|
weekKey
|
|
]
|
|
},
|
|
|
|
{
|
|
$setUnion: [
|
|
{
|
|
$ifNull: [
|
|
"$activity.currentWeekDays",
|
|
[]
|
|
]
|
|
},
|
|
|
|
[
|
|
weekday
|
|
]
|
|
]
|
|
},
|
|
|
|
[
|
|
weekday
|
|
]
|
|
]
|
|
},
|
|
|
|
"activity.currentWeekKey":
|
|
weekKey,
|
|
|
|
"activity.lastActiveDate":
|
|
dateKey,
|
|
|
|
"activity.lastGameDate":
|
|
dateKey
|
|
}
|
|
},
|
|
|
|
{
|
|
$set: {
|
|
"activity.bestDailyStreak": {
|
|
$max: [
|
|
{
|
|
$ifNull: [
|
|
"$activity.bestDailyStreak",
|
|
0
|
|
]
|
|
},
|
|
|
|
{
|
|
$ifNull: [
|
|
"$activity.currentDailyStreak",
|
|
0
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
function getProfileCollections() {
|
|
if (!DATABASE_NAME) {
|
|
throw new Error("M_DB is not configured in .env");
|
|
}
|
|
|
|
const db = mClient.db(DATABASE_NAME);
|
|
|
|
return {
|
|
db,
|
|
stats: db.collection(COLLECTIONS.stats),
|
|
achievements: db.collection(COLLECTIONS.achievements),
|
|
badges: db.collection(COLLECTIONS.badges),
|
|
achievementConfig: db.collection(COLLECTIONS.achievementConfig)
|
|
};
|
|
}
|
|
|
|
async function ensureProfileSystem() {
|
|
const {
|
|
stats,
|
|
achievements,
|
|
badges,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
await stats.createIndex(
|
|
{ userId: 1, guildId: 1 },
|
|
{ unique: true }
|
|
);
|
|
|
|
await achievements.createIndex(
|
|
{ userId: 1, guildId: 1 },
|
|
{ unique: true }
|
|
);
|
|
|
|
await badges.createIndex(
|
|
{ guildId: 1, badgeId: 1 },
|
|
{ unique: true }
|
|
);
|
|
|
|
await achievementConfig.createIndex(
|
|
{ achievementId: 1 },
|
|
{ unique: true }
|
|
);
|
|
}
|
|
|
|
async function evaluateSocialRelationshipFlags(
|
|
userId,
|
|
guildId,
|
|
event
|
|
) {
|
|
const {
|
|
stats
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
const document =
|
|
await stats.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
|
|
if (!document) {
|
|
return;
|
|
}
|
|
|
|
|
|
const social =
|
|
document.social ?? {};
|
|
|
|
|
|
const set = {};
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* ET TU, CRUMB?
|
|
* ========================================================
|
|
*
|
|
* Successfully steal from somebody who has
|
|
* previously gifted Cookies to you.
|
|
*/
|
|
|
|
if (
|
|
event.type ===
|
|
"steal_success"
|
|
&&
|
|
event.targetUserId
|
|
&&
|
|
Array.isArray(
|
|
social.receivedFrom
|
|
)
|
|
&&
|
|
social.receivedFrom.includes(
|
|
event.targetUserId
|
|
)
|
|
) {
|
|
set[
|
|
"special.etTuCrumb"
|
|
] = 1;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* NO HARD FEELINGS
|
|
* ========================================================
|
|
*
|
|
* Give Cookies to somebody who has previously
|
|
* successfully stolen from you.
|
|
*/
|
|
|
|
if (
|
|
event.type ===
|
|
"cookies_given"
|
|
&&
|
|
event.targetUserId
|
|
&&
|
|
Array.isArray(
|
|
social.successfullyStolenBy
|
|
)
|
|
&&
|
|
social.successfullyStolenBy.includes(
|
|
event.targetUserId
|
|
)
|
|
) {
|
|
set[
|
|
"special.noHardFeelings"
|
|
] = 1;
|
|
}
|
|
|
|
|
|
/*
|
|
* ========================================================
|
|
* COOKIE LAUNDERING
|
|
* ========================================================
|
|
*
|
|
* Give Cookies to somebody you have previously
|
|
* successfully stolen from.
|
|
*/
|
|
|
|
if (
|
|
event.type ===
|
|
"cookies_given"
|
|
&&
|
|
event.targetUserId
|
|
&&
|
|
Array.isArray(
|
|
social.successfullyStolenFrom
|
|
)
|
|
&&
|
|
social.successfullyStolenFrom.includes(
|
|
event.targetUserId
|
|
)
|
|
) {
|
|
set[
|
|
"special.cookieLaundering"
|
|
] = 1;
|
|
}
|
|
|
|
|
|
if (
|
|
Object.keys(
|
|
set
|
|
).length ===
|
|
0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$set:
|
|
set
|
|
}
|
|
);
|
|
}
|
|
|
|
function getDefaultStats(userId, guildId) {
|
|
return {
|
|
userId,
|
|
guildId,
|
|
|
|
economy: {
|
|
cookiesObtainedTotal: 0,
|
|
cookiesLostTotal: 0,
|
|
cookiesGivenTotal: 0,
|
|
stolenTotal: 0,
|
|
stolenFromMeTotal: 0,
|
|
vaultStolenTotal: 0
|
|
},
|
|
|
|
unique: {
|
|
playersStolenFrom: [],
|
|
playersStolenBy: []
|
|
},
|
|
|
|
games: {
|
|
total: {
|
|
played: 0,
|
|
won: 0,
|
|
lost: 0,
|
|
pushed: 0
|
|
},
|
|
|
|
slots: {
|
|
played: 0,
|
|
won: 0,
|
|
lost: 0,
|
|
pushed: 0,
|
|
cookiesWon: 0,
|
|
cookiesLost: 0,
|
|
currentRunItBackStreak: 0,
|
|
bestRunItBackStreak: 0
|
|
},
|
|
|
|
blackjack: {
|
|
played: 0,
|
|
won: 0,
|
|
lost: 0,
|
|
pushed: 0,
|
|
cookiesWon: 0,
|
|
cookiesLost: 0,
|
|
currentRunItBackStreak: 0,
|
|
bestRunItBackStreak: 0
|
|
},
|
|
|
|
roulette: {
|
|
played: 0,
|
|
won: 0,
|
|
lost: 0,
|
|
pushed: 0,
|
|
cookiesWon: 0,
|
|
cookiesLost: 0,
|
|
currentRunItBackStreak: 0,
|
|
bestRunItBackStreak: 0
|
|
},
|
|
|
|
heist: {
|
|
played: 0,
|
|
won: 0,
|
|
lost: 0,
|
|
pushed: 0,
|
|
cookiesWon: 0,
|
|
cookiesLost: 0
|
|
}
|
|
},
|
|
|
|
streaks: {
|
|
currentWin: 0,
|
|
bestWin: 0,
|
|
currentLoss: 0,
|
|
bestLoss: 0
|
|
},
|
|
|
|
social: {
|
|
giftedTo: [],
|
|
receivedFrom: [],
|
|
successfullyStolenFrom: [],
|
|
successfullyStolenBy: []
|
|
},
|
|
|
|
special: {
|
|
allInRunItBack: 0,
|
|
bankruptcyCount: 0,
|
|
bankruptcyRecoveryBest: 0,
|
|
maxUniqueStealAttackers: 0,
|
|
etTuCrumb: 0,
|
|
noHardFeelings: 0,
|
|
cookieLaundering: 0
|
|
},
|
|
|
|
activity: {
|
|
currentDailyStreak: 0,
|
|
bestDailyStreak: 0,
|
|
|
|
lastActiveDate: null,
|
|
lastGameDate: null,
|
|
|
|
currentWeekKey: null,
|
|
currentWeekDays: [],
|
|
|
|
gamesByWeekday: {
|
|
monday: 0,
|
|
tuesday: 0,
|
|
wednesday: 0,
|
|
thursday: 0,
|
|
friday: 0,
|
|
saturday: 0,
|
|
sunday: 0
|
|
},
|
|
|
|
gamesByTimeBucket: {
|
|
overnight: 0,
|
|
morning: 0,
|
|
afternoon: 0,
|
|
evening: 0
|
|
}
|
|
},
|
|
daily: {
|
|
dateKey: null,
|
|
|
|
gamesPlayed: [],
|
|
gamesWon: [],
|
|
gamesLost: [],
|
|
|
|
collectedCookies: false,
|
|
gambledAfterCollect: false,
|
|
|
|
wonAnyGame: false,
|
|
lostAnyGame: false,
|
|
|
|
gaveCookies: false,
|
|
stoleCookies: false
|
|
},
|
|
|
|
profileSchemaVersion: 2
|
|
};
|
|
}
|
|
|
|
async function ensureStatsDocument(userId, guildId) {
|
|
const { stats } = getProfileCollections();
|
|
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$setOnInsert: getDefaultStats(
|
|
userId,
|
|
guildId
|
|
)
|
|
},
|
|
{ upsert: true }
|
|
);
|
|
}
|
|
|
|
async function ensureAchievementDocument(userId, guildId) {
|
|
const { achievements } = getProfileCollections();
|
|
|
|
await achievements.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$setOnInsert: {
|
|
userId,
|
|
guildId,
|
|
unlocked: {}
|
|
}
|
|
},
|
|
{ upsert: true }
|
|
);
|
|
}
|
|
|
|
async function getProfileStats(userId, guildId) {
|
|
const { stats } = getProfileCollections();
|
|
|
|
return await stats.findOne({
|
|
userId,
|
|
guildId
|
|
}) ?? getDefaultStats(
|
|
userId,
|
|
guildId
|
|
);
|
|
}
|
|
|
|
async function trackProfileEvent(
|
|
interaction,
|
|
userId,
|
|
event
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
if (
|
|
!guildId ||
|
|
!userId ||
|
|
!event?.type
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await ensureStatsDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
const { stats } = getProfileCollections();
|
|
|
|
switch (event.type) {
|
|
case "cookie_gain":
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.cookiesObtainedTotal":
|
|
event.amount
|
|
},
|
|
|
|
...(
|
|
event.source === "give" &&
|
|
event.senderUserId
|
|
? {
|
|
$addToSet: {
|
|
"social.receivedFrom":
|
|
event.senderUserId
|
|
},
|
|
|
|
$set: {
|
|
"special.lastGiftSenderId":
|
|
event.senderUserId
|
|
}
|
|
}
|
|
: {}
|
|
)
|
|
}
|
|
);
|
|
break;
|
|
case "bankruptcy":
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"special.bankruptcyCount":
|
|
1
|
|
},
|
|
|
|
$set: {
|
|
"special.bankruptcyRecoveryBest":
|
|
0
|
|
}
|
|
}
|
|
);
|
|
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
|
|
},
|
|
|
|
...(event.targetUserId
|
|
? {
|
|
$addToSet: {
|
|
"social.giftedTo":
|
|
event.targetUserId
|
|
},
|
|
|
|
$set: {
|
|
"special.lastGiftTargetId":
|
|
event.targetUserId
|
|
}
|
|
}
|
|
: {})
|
|
}
|
|
);
|
|
break;
|
|
case "steal_targeted":
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$max: {
|
|
"special.maxUniqueStealAttackers":
|
|
Number(
|
|
event.uniqueAttackers ?? 0
|
|
)
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
case "steal_success":
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.stolenTotal":
|
|
event.amount
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenFrom":
|
|
event.targetUserId,
|
|
|
|
"social.successfullyStolenFrom":
|
|
event.targetUserId
|
|
},
|
|
|
|
$set: {
|
|
"special.lastSuccessfulStealTargetId":
|
|
event.targetUserId
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
case "stolen_from_me":
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$inc: {
|
|
"economy.stolenFromMeTotal":
|
|
event.amount
|
|
},
|
|
|
|
$addToSet: {
|
|
"unique.playersStolenBy":
|
|
event.thiefUserId,
|
|
|
|
"social.successfullyStolenBy":
|
|
event.thiefUserId
|
|
},
|
|
|
|
$set: {
|
|
"special.lastSuccessfulThiefId":
|
|
event.thiefUserId
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
case "vault_steal":
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
"economy.vaultStolenTotal":
|
|
event.amount
|
|
}
|
|
}
|
|
);
|
|
break;
|
|
|
|
case "game_result":
|
|
await trackGameActivity(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
await trackGameResult(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
|
|
break;
|
|
|
|
case "game_start":
|
|
await trackGameStart(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
break;
|
|
|
|
case "profile_refresh":
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
|
|
if (
|
|
[
|
|
"steal_success",
|
|
"cookies_given"
|
|
].includes(
|
|
event.type
|
|
)
|
|
) {
|
|
await evaluateSocialRelationshipFlags(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
[
|
|
"cookie_gain",
|
|
"cookie_loss",
|
|
"game_result",
|
|
"bankruptcy"
|
|
].includes(
|
|
event.type
|
|
)
|
|
) {
|
|
await updateBankruptcyRecovery(
|
|
userId,
|
|
guildId
|
|
);
|
|
}
|
|
if (
|
|
event.type ===
|
|
"game_result"
|
|
) {
|
|
await trackDailyGameState(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
}
|
|
await trackDailyNarrativeState(
|
|
userId,
|
|
guildId,
|
|
event
|
|
);
|
|
const updatedStats =
|
|
await getProfileStats(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
await evaluateAchievements(
|
|
interaction,
|
|
userId,
|
|
updatedStats
|
|
);
|
|
|
|
|
|
await evaluateEventAchievements(
|
|
interaction,
|
|
userId,
|
|
event
|
|
);
|
|
|
|
|
|
await evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
updatedStats,
|
|
event
|
|
);
|
|
|
|
|
|
return updatedStats;
|
|
}
|
|
|
|
|
|
async function recordGameOutcome(
|
|
interaction,
|
|
userId,
|
|
{
|
|
game,
|
|
result,
|
|
profit = 0,
|
|
...metadata
|
|
}
|
|
) {
|
|
return trackProfileEvent(
|
|
interaction,
|
|
userId,
|
|
{
|
|
type: "game_result",
|
|
game,
|
|
result,
|
|
profit,
|
|
...metadata
|
|
}
|
|
);
|
|
}
|
|
|
|
async function trackGameStart(
|
|
userId,
|
|
guildId,
|
|
event
|
|
) {
|
|
const { stats } = getProfileCollections();
|
|
|
|
const game = String(
|
|
event.game ?? ""
|
|
).toLowerCase();
|
|
|
|
if (!["slots", "roulette", "blackjack"].includes(game)) {
|
|
return;
|
|
}
|
|
|
|
const source = String(
|
|
event.source ?? "normal"
|
|
).toLowerCase();
|
|
|
|
const currentPath =
|
|
`games.${game}.currentRunItBackStreak`;
|
|
|
|
const bestPath =
|
|
`games.${game}.bestRunItBackStreak`;
|
|
|
|
if (source !== "run_it_back") {
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$set: {
|
|
[currentPath]: 0
|
|
}
|
|
}
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const updated = await stats.findOneAndUpdate(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: {
|
|
[currentPath]: 1
|
|
}
|
|
},
|
|
{
|
|
returnDocument: "after"
|
|
}
|
|
);
|
|
|
|
const currentStreak =
|
|
updated?.games?.[game]?.currentRunItBackStreak ?? 0;
|
|
|
|
const set = {};
|
|
|
|
const previousWager = Number(
|
|
event.previousWager ??
|
|
event.previousBet ??
|
|
0
|
|
);
|
|
|
|
const currentWager = Number(
|
|
event.wager ??
|
|
event.bet ??
|
|
0
|
|
);
|
|
|
|
const chainedAllIn =
|
|
event.previousAllIn === true &&
|
|
previousWager > 0 &&
|
|
currentWager === previousWager;
|
|
|
|
if (chainedAllIn) {
|
|
set["special.allInRunItBack"] = 1;
|
|
}
|
|
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$max: {
|
|
[bestPath]: currentStreak
|
|
},
|
|
...(Object.keys(set).length
|
|
? { $set: set }
|
|
: {})
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
async function trackGameResult(
|
|
userId,
|
|
guildId,
|
|
event
|
|
) {
|
|
const { stats } = getProfileCollections();
|
|
|
|
const game = String(
|
|
event.game ?? ""
|
|
).toLowerCase();
|
|
|
|
if (
|
|
![
|
|
"slots",
|
|
"blackjack",
|
|
"roulette",
|
|
"heist"
|
|
].includes(game)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const result = String(
|
|
event.result ?? ""
|
|
).toLowerCase();
|
|
|
|
const profit = Number(
|
|
event.profit ?? 0
|
|
);
|
|
|
|
const increment = {
|
|
"games.total.played": 1,
|
|
[`games.${game}.played`]: 1
|
|
};
|
|
|
|
const set = {};
|
|
|
|
if (result === "win") {
|
|
increment["games.total.won"] = 1;
|
|
increment[`games.${game}.won`] = 1;
|
|
increment["streaks.currentWin"] = 1;
|
|
set["streaks.currentLoss"] = 0;
|
|
}
|
|
|
|
else if (result === "loss") {
|
|
increment["games.total.lost"] = 1;
|
|
increment[`games.${game}.lost`] = 1;
|
|
increment["streaks.currentLoss"] = 1;
|
|
set["streaks.currentWin"] = 0;
|
|
}
|
|
|
|
else if (result === "push") {
|
|
increment["games.total.pushed"] = 1;
|
|
increment[`games.${game}.pushed`] = 1;
|
|
}
|
|
|
|
else {
|
|
return;
|
|
}
|
|
|
|
if (Number.isFinite(profit)) {
|
|
if (profit > 0) {
|
|
increment[
|
|
`games.${game}.cookiesWon`
|
|
] = profit;
|
|
|
|
increment[
|
|
"economy.cookiesObtainedTotal"
|
|
] = profit;
|
|
}
|
|
|
|
else if (profit < 0) {
|
|
increment[
|
|
`games.${game}.cookiesLost`
|
|
] = Math.abs(profit);
|
|
|
|
increment[
|
|
"economy.cookiesLostTotal"
|
|
] = Math.abs(profit);
|
|
}
|
|
}
|
|
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$inc: increment,
|
|
...(Object.keys(set).length
|
|
? { $set: set }
|
|
: {})
|
|
}
|
|
);
|
|
|
|
const current = await stats.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
await stats.updateOne(
|
|
{ userId, guildId },
|
|
{
|
|
$max: {
|
|
"streaks.bestWin":
|
|
current?.streaks
|
|
?.currentWin ?? 0,
|
|
|
|
"streaks.bestLoss":
|
|
current?.streaks
|
|
?.currentLoss ?? 0
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
async function evaluateAchievements(
|
|
interaction,
|
|
userId,
|
|
statsDocument
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
await ensureAchievementDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
const definitions =
|
|
await achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray();
|
|
|
|
let unlockedDoc =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
let unlocked =
|
|
unlockedDoc?.unlocked ?? {};
|
|
|
|
const normalDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
![
|
|
"achievementPercentage",
|
|
"event"
|
|
].includes(
|
|
definition.measure
|
|
)
|
|
);
|
|
|
|
for (const definition of normalDefinitions) {
|
|
if (unlocked[definition.achievementId]) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!achievementRequirementsMet(
|
|
statsDocument,
|
|
definition
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const unlockedNow =
|
|
await unlockAchievement(
|
|
interaction,
|
|
userId,
|
|
guildId,
|
|
definition
|
|
);
|
|
|
|
if (unlockedNow) {
|
|
unlocked[
|
|
definition.achievementId
|
|
] = getUnixTime();
|
|
}
|
|
}
|
|
|
|
unlockedDoc =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
unlocked =
|
|
unlockedDoc?.unlocked ?? {};
|
|
|
|
const collectorDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
);
|
|
|
|
const collectibleDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
isStatBackfillableAchievement(
|
|
definition
|
|
)
|
|
);
|
|
|
|
const totalCollectible =
|
|
collectibleDefinitions.length;
|
|
|
|
const unlockedCollectible =
|
|
collectibleDefinitions.filter(
|
|
definition =>
|
|
Boolean(
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const achievementPercentage =
|
|
totalCollectible > 0
|
|
? unlockedCollectible /
|
|
totalCollectible
|
|
: 0;
|
|
|
|
for (const definition of collectorDefinitions) {
|
|
if (unlocked[definition.achievementId]) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
achievementPercentage <
|
|
Number(definition.target)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const unlockedNow =
|
|
await unlockAchievement(
|
|
interaction,
|
|
userId,
|
|
guildId,
|
|
definition
|
|
);
|
|
|
|
if (unlockedNow) {
|
|
unlocked[
|
|
definition.achievementId
|
|
] = getUnixTime();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function unlockAchievement(
|
|
interaction,
|
|
userId,
|
|
guildId,
|
|
definition
|
|
) {
|
|
const { achievements } =
|
|
getProfileCollections();
|
|
|
|
const field =
|
|
`unlocked.${definition.achievementId}`;
|
|
|
|
const result =
|
|
await achievements.updateOne(
|
|
{
|
|
userId,
|
|
guildId,
|
|
[field]: {
|
|
$exists: false
|
|
}
|
|
},
|
|
{
|
|
$set: {
|
|
[field]:
|
|
getUnixTime()
|
|
}
|
|
}
|
|
);
|
|
|
|
if (result.modifiedCount !== 1) {
|
|
return false;
|
|
}
|
|
|
|
await sendAchievementNotification(
|
|
interaction,
|
|
userId,
|
|
definition
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
function readStatValue(
|
|
document,
|
|
definition
|
|
) {
|
|
if (!definition.stat) {
|
|
return 0;
|
|
}
|
|
|
|
const value =
|
|
definition.stat
|
|
.split(".")
|
|
.reduce(
|
|
(current, key) =>
|
|
current?.[key],
|
|
document
|
|
);
|
|
|
|
if (
|
|
definition.measure ===
|
|
"arrayLength"
|
|
) {
|
|
return Array.isArray(value)
|
|
? value.length
|
|
: 0;
|
|
}
|
|
|
|
return Number(value ?? 0);
|
|
}
|
|
|
|
async function sendAchievementNotification(
|
|
interaction,
|
|
userId,
|
|
achievement
|
|
) {
|
|
const embed =
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
"🏆 Achievement Unlocked!"
|
|
)
|
|
.setDescription(
|
|
[
|
|
`<@${userId}> earned an achievement!`,
|
|
"",
|
|
`${achievement.emoji ?? "🏆"} **${achievement.name}**`,
|
|
"",
|
|
achievement.description ??
|
|
achievement.requirement ??
|
|
"Achievement unlocked."
|
|
].join("\n")
|
|
)
|
|
.setTimestamp();
|
|
|
|
try {
|
|
/*
|
|
* If the interaction has already been acknowledged,
|
|
* followUp is safe.
|
|
*/
|
|
if (
|
|
interaction.replied ||
|
|
interaction.deferred
|
|
) {
|
|
return await interaction.followUp({
|
|
embeds: [embed]
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Otherwise don't consume the interaction's primary
|
|
* reply. The game/button handler still needs it.
|
|
*
|
|
* Send the public achievement announcement directly
|
|
* to the channel instead.
|
|
*/
|
|
if (interaction.channel) {
|
|
return await interaction.channel.send({
|
|
embeds: [embed]
|
|
});
|
|
}
|
|
}
|
|
|
|
catch (error) {
|
|
console.error(
|
|
`[Achievement] Failed to announce ${achievement.achievementId} for ${userId}:`,
|
|
error
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function evaluateBadges(
|
|
interaction,
|
|
userId,
|
|
statsDocument,
|
|
event = {}
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
if (!guildId) {
|
|
return;
|
|
}
|
|
|
|
const balance =
|
|
await getCookieBalance(
|
|
userId
|
|
);
|
|
|
|
if (balance >= 100000) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_100k_cookies"
|
|
);
|
|
}
|
|
|
|
if (balance >= 1000000) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_1m_cookies"
|
|
);
|
|
}
|
|
|
|
if (
|
|
(
|
|
statsDocument
|
|
?.economy
|
|
?.vaultStolenTotal ?? 0
|
|
) >= 100000
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_100k_vault_stolen"
|
|
);
|
|
}
|
|
|
|
const uniquePlayersStolenFrom =
|
|
statsDocument
|
|
?.unique
|
|
?.playersStolenFrom ?? [];
|
|
|
|
const uniqueStealCount =
|
|
uniquePlayersStolenFrom.length;
|
|
|
|
if (uniqueStealCount >= 5) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_unique_steals_5"
|
|
);
|
|
}
|
|
|
|
if (uniqueStealCount >= 10) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_unique_steals_10"
|
|
);
|
|
}
|
|
|
|
if (uniqueStealCount >= 15) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_unique_steals_15"
|
|
);
|
|
}
|
|
|
|
if (
|
|
event.type === "game_result" &&
|
|
event.game === "slots"
|
|
) {
|
|
const tripletBadges = {
|
|
"🍋": "first_slots_triple_lemon",
|
|
"🍒": "first_slots_triple_cherry",
|
|
"🍪": "first_slots_triple_cookie",
|
|
"⭐": "first_slots_triple_star",
|
|
"💎": "first_slots_jackpot"
|
|
};
|
|
|
|
const badgeId =
|
|
tripletBadges[event.triplet];
|
|
|
|
if (badgeId) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
badgeId
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"game_result" &&
|
|
event.game ===
|
|
"blackjack" &&
|
|
event.natural ===
|
|
true &&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_blackjack_natural"
|
|
);
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"game_result" &&
|
|
event.game ===
|
|
"blackjack" &&
|
|
event.allIn ===
|
|
true &&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_blackjack_allin_win"
|
|
);
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"game_result" &&
|
|
event.game ===
|
|
"roulette" &&
|
|
event.zero ===
|
|
true &&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_roulette_zero"
|
|
);
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"game_result" &&
|
|
event.game ===
|
|
"roulette" &&
|
|
event.allIn ===
|
|
true &&
|
|
event.result ===
|
|
"win"
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_roulette_allin_win"
|
|
);
|
|
}
|
|
|
|
if (
|
|
event.type === "game_start" &&
|
|
event.source === "run_it_back" &&
|
|
event.previousAllIn === true &&
|
|
Number(
|
|
event.previousWager ??
|
|
event.previousBet ??
|
|
0
|
|
) > 0 &&
|
|
Number(
|
|
event.wager ??
|
|
event.bet ??
|
|
0
|
|
) ===
|
|
Number(
|
|
event.previousWager ??
|
|
event.previousBet ??
|
|
0
|
|
)
|
|
) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_all_in_run_it_back"
|
|
);
|
|
}
|
|
|
|
await evaluateCollectorBadges(
|
|
interaction,
|
|
userId
|
|
);
|
|
}
|
|
|
|
async function evaluateCollectorBadges(
|
|
interaction,
|
|
userId
|
|
) {
|
|
const guildId = interaction.guildId;
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const activeAchievements =
|
|
(
|
|
await achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.toArray()
|
|
).filter(
|
|
achievement =>
|
|
isStatBackfillableAchievement(
|
|
achievement
|
|
)
|
|
);
|
|
|
|
if (
|
|
activeAchievements.length ===
|
|
0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const achievementDocument =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
const unlocked =
|
|
achievementDocument?.unlocked ?? {};
|
|
|
|
const unlockedCount =
|
|
activeAchievements.filter(
|
|
achievement =>
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const percentage =
|
|
unlockedCount /
|
|
activeAchievements.length;
|
|
|
|
if (percentage >= 0.5) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_achievement_half",
|
|
{
|
|
unlockedAchievements:
|
|
unlockedCount,
|
|
availableAchievements:
|
|
activeAchievements.length
|
|
}
|
|
);
|
|
}
|
|
|
|
if (percentage >= 1) {
|
|
await tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
"first_achievement_all",
|
|
{
|
|
unlockedAchievements:
|
|
unlockedCount,
|
|
availableAchievements:
|
|
activeAchievements.length
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
async function tryClaimBadge(
|
|
interaction,
|
|
userId,
|
|
badgeId,
|
|
metadata = null
|
|
) {
|
|
const { badges } =
|
|
getProfileCollections();
|
|
|
|
const guildId = interaction.guildId;
|
|
|
|
if (!guildId) {
|
|
return false;
|
|
}
|
|
|
|
const update = {
|
|
ownerId: userId,
|
|
unlockedAt: getUnixTime()
|
|
};
|
|
|
|
if (
|
|
metadata &&
|
|
typeof metadata ===
|
|
"object"
|
|
) {
|
|
update.metadata = metadata;
|
|
}
|
|
|
|
const badge =
|
|
await badges.findOneAndUpdate(
|
|
{
|
|
guildId,
|
|
badgeId,
|
|
ownerId: null
|
|
},
|
|
{
|
|
$set: update
|
|
},
|
|
{
|
|
returnDocument:
|
|
"after"
|
|
}
|
|
);
|
|
|
|
if (
|
|
!badge ||
|
|
badge.ownerId !==
|
|
userId
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const description = [
|
|
`${badge.emoji ?? "🏅"} **${badge.name ?? badge.badgeId}**`,
|
|
"",
|
|
`<@${userId}> claimed a guild-unique badge!`,
|
|
"",
|
|
badge.description ??
|
|
"A unique badge has been claimed.",
|
|
"",
|
|
"*This badge can no longer be earned by another player.*"
|
|
];
|
|
|
|
if (
|
|
badge.metadata
|
|
?.unlockedAchievements !==
|
|
undefined &&
|
|
badge.metadata
|
|
?.availableAchievements !==
|
|
undefined
|
|
) {
|
|
description.splice(
|
|
description.length - 1,
|
|
0,
|
|
`Claimed at **${badge.metadata.unlockedAchievements}/${badge.metadata.availableAchievements} achievements**.`,
|
|
""
|
|
);
|
|
}
|
|
|
|
try {
|
|
await interaction.channel.send({
|
|
embeds: [
|
|
new EmbedBuilder()
|
|
.setTitle(
|
|
"🏅 Unique Badge Claimed!"
|
|
)
|
|
.setDescription(
|
|
description.join("\n")
|
|
)
|
|
.setFooter({
|
|
text:
|
|
`badge:${badge.badgeId}`
|
|
})
|
|
.setTimestamp()
|
|
]
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`[Badge] Failed to announce ${badgeId} for ${userId}:`,
|
|
error
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function buildProfileOverview(
|
|
guildId,
|
|
user,
|
|
sessionOwnerId = user.id
|
|
) {
|
|
const stats =
|
|
await getProfileStats(
|
|
user.id,
|
|
guildId
|
|
);
|
|
|
|
const balance =
|
|
await getCookieBalance(
|
|
user.id
|
|
);
|
|
|
|
const {
|
|
achievements,
|
|
badges,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const [
|
|
achievementDoc,
|
|
achievementCount,
|
|
ownedBadges
|
|
] = await Promise.all([
|
|
achievements.findOne({
|
|
userId: user.id,
|
|
guildId
|
|
}),
|
|
|
|
achievementConfig.countDocuments({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
}),
|
|
|
|
badges.find({
|
|
guildId,
|
|
ownerId: user.id
|
|
}).toArray()
|
|
]);
|
|
|
|
const unlockedCount =
|
|
Object.keys(
|
|
achievementDoc?.unlocked ?? {}
|
|
).length;
|
|
|
|
return new EmbedBuilder()
|
|
.setTitle(
|
|
`👤 ${user.globalName ?? user.username}'s Profile`
|
|
)
|
|
.setThumbnail(
|
|
user.displayAvatarURL({
|
|
size: 256
|
|
})
|
|
)
|
|
.addFields(
|
|
{
|
|
name: "🍪 Cookies",
|
|
value:
|
|
balance.toLocaleString(),
|
|
inline: true
|
|
},
|
|
{
|
|
name: "🏆 Achievements",
|
|
value:
|
|
`${unlockedCount} / ${achievementCount}`,
|
|
inline: true
|
|
},
|
|
{
|
|
name: "🏅 Unique Badges",
|
|
value:
|
|
`${ownedBadges.length}`,
|
|
inline: true
|
|
},
|
|
{
|
|
name: "🎮 Games",
|
|
value: [
|
|
`Played: **${stats.games.total.played}**`,
|
|
`Won: **${stats.games.total.won}**`,
|
|
`Lost: **${stats.games.total.lost}**`,
|
|
`Pushes: **${stats.games.total.pushed}**`
|
|
].join("\n"),
|
|
inline: true
|
|
},
|
|
{
|
|
name: "🔥 Best Streaks",
|
|
value: [
|
|
`Wins: **${stats.streaks.bestWin}**`,
|
|
`Losses: **${stats.streaks.bestLoss}**`
|
|
].join("\n"),
|
|
inline: true
|
|
}
|
|
)
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"overview",
|
|
0,
|
|
1
|
|
)
|
|
});
|
|
}
|
|
|
|
async function buildStatsEmbed(
|
|
guildId,
|
|
user,
|
|
sessionOwnerId = user.id
|
|
) {
|
|
const stats =
|
|
await getProfileStats(
|
|
user.id,
|
|
guildId
|
|
);
|
|
|
|
const gameLines = [
|
|
["🎰 Slots", stats.games?.slots],
|
|
["🃏 Blackjack", stats.games?.blackjack],
|
|
["🎡 Roulette", stats.games?.roulette],
|
|
["🏦 Heist", stats.games?.heist],
|
|
["♠️ Poker", stats.games?.poker]
|
|
].map(([name, game = {}]) => {
|
|
const played =
|
|
Number(game.played ?? 0);
|
|
|
|
const won =
|
|
Number(game.won ?? 0);
|
|
|
|
const lost =
|
|
Number(game.lost ?? 0);
|
|
|
|
const pushed =
|
|
Number(game.pushed ?? 0);
|
|
|
|
const cookiesWon =
|
|
Number(game.cookiesWon ?? 0);
|
|
|
|
const cookiesLost =
|
|
Number(game.cookiesLost ?? 0);
|
|
|
|
const decidedGames =
|
|
won + lost;
|
|
|
|
const winRate =
|
|
decidedGames > 0
|
|
? (won / decidedGames) * 100
|
|
: 0;
|
|
|
|
const net =
|
|
cookiesWon - cookiesLost;
|
|
|
|
return [
|
|
`**${name}**`,
|
|
`Played **${played}** • W **${won}** • L **${lost}** • Push **${pushed}**`,
|
|
`Win rate: **${winRate.toFixed(1)}%**`,
|
|
`Won **${cookiesWon.toLocaleString()} 🍪** • Lost **${cookiesLost.toLocaleString()} 🍪**`,
|
|
`Net: **${net >= 0 ? "+" : ""}${net.toLocaleString()} 🍪**`
|
|
].join("\n");
|
|
});
|
|
|
|
const economy =
|
|
stats.economy ?? {};
|
|
|
|
const unique =
|
|
stats.unique ?? {};
|
|
|
|
const streaks =
|
|
stats.streaks ?? {};
|
|
|
|
const activity =
|
|
stats.activity ?? {};
|
|
|
|
const gamesByWeekday =
|
|
activity.gamesByWeekday ?? {};
|
|
|
|
const gamesByTimeBucket =
|
|
activity.gamesByTimeBucket ?? {};
|
|
|
|
return new EmbedBuilder()
|
|
.setTitle(
|
|
`📊 ${user.globalName ?? user.username}'s Stats`
|
|
)
|
|
.setThumbnail(
|
|
user.displayAvatarURL({
|
|
size: 256
|
|
})
|
|
)
|
|
.addFields(
|
|
{
|
|
name: "🍪 Economy",
|
|
value: [
|
|
`Obtained: **${Number(economy.cookiesObtainedTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Lost: **${Number(economy.cookiesLostTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Given: **${Number(economy.cookiesGivenTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Stolen: **${Number(economy.stolenTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Stolen from you: **${Number(economy.stolenFromMeTotal ?? 0).toLocaleString()} 🍪**`,
|
|
`Vault stolen: **${Number(economy.vaultStolenTotal ?? 0).toLocaleString()} 🍪**`
|
|
].join("\n")
|
|
},
|
|
{
|
|
name: "🥷 Unique Players",
|
|
value: [
|
|
`Stolen from: **${unique.playersStolenFrom?.length ?? 0}**`,
|
|
`Stolen by: **${unique.playersStolenBy?.length ?? 0}**`
|
|
].join("\n"),
|
|
inline: true
|
|
},
|
|
{
|
|
name: "🔥 Streaks",
|
|
value: [
|
|
`Current wins: **${streaks.currentWin ?? 0}**`,
|
|
`Best wins: **${streaks.bestWin ?? 0}**`,
|
|
`Current losses: **${streaks.currentLoss ?? 0}**`,
|
|
`Best losses: **${streaks.bestLoss ?? 0}**`
|
|
].join("\n"),
|
|
inline: true
|
|
},
|
|
{
|
|
name: "📅 Activity",
|
|
|
|
value: [
|
|
`Daily streak: **${activity.currentDailyStreak ?? 0} days**`,
|
|
`Best streak: **${activity.bestDailyStreak ?? 0} days**`,
|
|
"",
|
|
`Mon **${gamesByWeekday.monday ?? 0}** • Tue **${gamesByWeekday.tuesday ?? 0}** • Wed **${gamesByWeekday.wednesday ?? 0}**`,
|
|
`Thu **${gamesByWeekday.thursday ?? 0}** • Fri **${gamesByWeekday.friday ?? 0}**`,
|
|
`Sat **${gamesByWeekday.saturday ?? 0}** • Sun **${gamesByWeekday.sunday ?? 0}**`,
|
|
"",
|
|
`🌙 Overnight **${gamesByTimeBucket.overnight ?? 0}**`,
|
|
`🌅 Morning **${gamesByTimeBucket.morning ?? 0}**`,
|
|
`☀️ Afternoon **${gamesByTimeBucket.afternoon ?? 0}**`,
|
|
`🌆 Evening **${gamesByTimeBucket.evening ?? 0}**`
|
|
].join("\n")
|
|
},
|
|
{
|
|
name: "🎮 Per Game",
|
|
value:
|
|
gameLines.join("\n\n")
|
|
}
|
|
)
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"stats",
|
|
0,
|
|
1
|
|
)
|
|
});
|
|
}
|
|
|
|
async function buildBadgesEmbed(
|
|
guildId,
|
|
user,
|
|
sessionOwnerId = user.id,
|
|
page = 0
|
|
) {
|
|
const { badges } =
|
|
getProfileCollections();
|
|
|
|
const documents =
|
|
await badges.find({
|
|
guildId
|
|
}).sort({
|
|
category: 1,
|
|
name: 1
|
|
}).toArray();
|
|
|
|
const owned =
|
|
documents.filter(
|
|
badge =>
|
|
badge.ownerId ===
|
|
user.id
|
|
);
|
|
|
|
const pages = [];
|
|
|
|
pages.push({
|
|
title: "Owned",
|
|
content:
|
|
owned.length
|
|
? owned.map(
|
|
badge =>
|
|
formatBadgeLine(
|
|
badge,
|
|
user.id,
|
|
true
|
|
)
|
|
).join("\n\n")
|
|
: "*No unique badges yet.*"
|
|
});
|
|
|
|
const categoryOrder = [
|
|
"economy",
|
|
"stealing",
|
|
"slots",
|
|
"blackjack",
|
|
"roulette",
|
|
"heist",
|
|
"collector"
|
|
];
|
|
|
|
const categories =
|
|
[...new Set(
|
|
documents.map(
|
|
badge =>
|
|
badge.category ??
|
|
"other"
|
|
)
|
|
)].sort(
|
|
(a, b) => {
|
|
const ai =
|
|
categoryOrder.indexOf(a);
|
|
const bi =
|
|
categoryOrder.indexOf(b);
|
|
|
|
if (
|
|
ai === -1 &&
|
|
bi === -1
|
|
) {
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
if (ai === -1) {
|
|
return 1;
|
|
}
|
|
|
|
if (bi === -1) {
|
|
return -1;
|
|
}
|
|
|
|
return ai - bi;
|
|
}
|
|
);
|
|
|
|
for (const category of categories) {
|
|
const categoryBadges =
|
|
documents.filter(
|
|
badge =>
|
|
(badge.category ?? "other") ===
|
|
category
|
|
);
|
|
|
|
if (!categoryBadges.length) {
|
|
continue;
|
|
}
|
|
|
|
const chunks =
|
|
paginateLines(
|
|
categoryBadges.map(
|
|
badge =>
|
|
formatBadgeLine(
|
|
badge,
|
|
user.id,
|
|
false
|
|
)
|
|
),
|
|
3200,
|
|
"\n\n"
|
|
);
|
|
|
|
for (
|
|
let index = 0;
|
|
index < chunks.length;
|
|
index++
|
|
) {
|
|
pages.push({
|
|
title:
|
|
`${formatCategoryName(category)}${chunks.length > 1 ? ` ${index + 1}/${chunks.length}` : ""}`,
|
|
content:
|
|
chunks[index]
|
|
});
|
|
}
|
|
}
|
|
|
|
const safePage =
|
|
clampPage(
|
|
page,
|
|
pages.length
|
|
);
|
|
|
|
const selected =
|
|
pages[safePage] ?? {
|
|
title: "Badges",
|
|
content:
|
|
"No badges configured."
|
|
};
|
|
|
|
return new EmbedBuilder()
|
|
.setTitle(
|
|
`🏅 ${user.globalName ?? user.username}'s Badges`
|
|
)
|
|
.setDescription(
|
|
[
|
|
`### ${selected.title}`,
|
|
selected.content
|
|
].join("\n")
|
|
)
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"badges",
|
|
safePage,
|
|
pages.length
|
|
)
|
|
});
|
|
}
|
|
|
|
async function buildAchievementsEmbed(
|
|
guildId,
|
|
user,
|
|
sessionOwnerId = user.id,
|
|
page = 0
|
|
) {
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} = getProfileCollections();
|
|
|
|
const [
|
|
unlockedDoc,
|
|
definitions,
|
|
statsDocument
|
|
] = await Promise.all([
|
|
achievements.findOne({
|
|
userId: user.id,
|
|
guildId
|
|
}),
|
|
|
|
achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
}
|
|
})
|
|
.sort({
|
|
category: 1,
|
|
target: 1,
|
|
name: 1
|
|
})
|
|
.toArray(),
|
|
|
|
getProfileStats(
|
|
user.id,
|
|
guildId
|
|
)
|
|
]);
|
|
|
|
const unlocked =
|
|
unlockedDoc?.unlocked ?? {};
|
|
|
|
const collectibleDefinitions =
|
|
definitions.filter(
|
|
definition =>
|
|
isStatBackfillableAchievement(
|
|
definition
|
|
)
|
|
);
|
|
|
|
const unlockedCollectible =
|
|
collectibleDefinitions.filter(
|
|
definition =>
|
|
Boolean(
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const collectorPercentage =
|
|
collectibleDefinitions.length > 0
|
|
? unlockedCollectible /
|
|
collectibleDefinitions.length
|
|
: 0;
|
|
|
|
const lines = [];
|
|
let lastCategory = null;
|
|
|
|
for (const achievement of definitions) {
|
|
const complete =
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
);
|
|
|
|
const category =
|
|
achievement.category ??
|
|
"other";
|
|
|
|
if (category !== lastCategory) {
|
|
if (lines.length) {
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push(
|
|
`### ${formatCategoryName(category)}`
|
|
);
|
|
|
|
lastCategory = category;
|
|
}
|
|
|
|
if (
|
|
achievement.secret === true &&
|
|
!complete
|
|
) {
|
|
lines.push(
|
|
"⬜ ❓ **???** — Hidden achievement"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let currentValue;
|
|
let targetValue =
|
|
Number(achievement.target ?? 0);
|
|
let progressText = "";
|
|
|
|
if (
|
|
achievement.measure ===
|
|
"achievementPercentage"
|
|
) {
|
|
currentValue =
|
|
collectorPercentage;
|
|
|
|
progressText =
|
|
` • **${Math.min(100, currentValue * 100).toFixed(0)}% / ${Math.min(100, targetValue * 100).toFixed(0)}%**`;
|
|
}
|
|
else if (
|
|
achievement.measure === "all" ||
|
|
achievement.measure === "any"
|
|
) {
|
|
const {
|
|
completed,
|
|
total
|
|
} =
|
|
getCompositeProgress(
|
|
statsDocument,
|
|
achievement
|
|
);
|
|
|
|
|
|
progressText =
|
|
` • **${completed} / ${total}**`;
|
|
}
|
|
else if (achievement.stat) {
|
|
currentValue =
|
|
readStatValue(
|
|
statsDocument,
|
|
achievement
|
|
);
|
|
|
|
progressText =
|
|
` • **${Number(currentValue).toLocaleString()} / ${targetValue.toLocaleString()}**`;
|
|
}
|
|
|
|
|
|
|
|
const requirement =
|
|
achievement.requirement ??
|
|
achievement.description ??
|
|
"Requirement unavailable.";
|
|
|
|
lines.push(
|
|
[
|
|
complete ? "✅" : "⬜",
|
|
" ",
|
|
achievement.emoji ?? "🏆",
|
|
" **",
|
|
achievement.name,
|
|
"** — ",
|
|
requirement,
|
|
complete
|
|
? ""
|
|
: progressText
|
|
].join("")
|
|
);
|
|
}
|
|
|
|
const pages =
|
|
paginateLines(
|
|
lines,
|
|
3400,
|
|
"\n"
|
|
);
|
|
|
|
if (!pages.length) {
|
|
pages.push(
|
|
"No achievements configured."
|
|
);
|
|
}
|
|
|
|
const safePage =
|
|
clampPage(
|
|
page,
|
|
pages.length
|
|
);
|
|
|
|
const unlockedCount =
|
|
definitions.filter(
|
|
achievement =>
|
|
Boolean(
|
|
unlocked[
|
|
achievement.achievementId
|
|
]
|
|
)
|
|
).length;
|
|
|
|
const completion =
|
|
definitions.length > 0
|
|
? (unlockedCount / definitions.length) * 100
|
|
: 0;
|
|
|
|
return new EmbedBuilder()
|
|
.setTitle(
|
|
`🏆 ${user.globalName ?? user.username}'s Achievements`
|
|
)
|
|
.setDescription(
|
|
pages[safePage]
|
|
)
|
|
.addFields({
|
|
name: "Progress",
|
|
value:
|
|
`**${unlockedCount} / ${definitions.length}** unlocked • **${completion.toFixed(1)}%** complete`
|
|
})
|
|
.setFooter({
|
|
text:
|
|
buildProfileFooter(
|
|
sessionOwnerId,
|
|
user.id,
|
|
"achievements",
|
|
safePage,
|
|
pages.length
|
|
)
|
|
});
|
|
}
|
|
|
|
function buildProfileButtons(
|
|
section = "overview",
|
|
page = 0,
|
|
pageCount = 1
|
|
) {
|
|
const rows = [
|
|
new ActionRowBuilder()
|
|
.addComponents(
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileOverview"
|
|
)
|
|
.setLabel(
|
|
"Overview"
|
|
)
|
|
.setEmoji(
|
|
"👤"
|
|
)
|
|
.setStyle(
|
|
section === "overview"
|
|
? ButtonStyle.Primary
|
|
: ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileStats"
|
|
)
|
|
.setLabel(
|
|
"Stats"
|
|
)
|
|
.setEmoji(
|
|
"📊"
|
|
)
|
|
.setStyle(
|
|
section === "stats"
|
|
? ButtonStyle.Primary
|
|
: ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileBadges"
|
|
)
|
|
.setLabel(
|
|
"Badges"
|
|
)
|
|
.setEmoji(
|
|
"🏅"
|
|
)
|
|
.setStyle(
|
|
section === "badges"
|
|
? ButtonStyle.Primary
|
|
: ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileAchievements"
|
|
)
|
|
.setLabel(
|
|
"Achievements"
|
|
)
|
|
.setEmoji(
|
|
"🏆"
|
|
)
|
|
.setStyle(
|
|
section === "achievements"
|
|
? ButtonStyle.Primary
|
|
: ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
"profileClose"
|
|
)
|
|
.setLabel(
|
|
"Close"
|
|
)
|
|
.setEmoji(
|
|
"✖️"
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Danger
|
|
)
|
|
)
|
|
];
|
|
|
|
if (
|
|
[
|
|
"badges",
|
|
"achievements"
|
|
].includes(section) &&
|
|
pageCount > 1
|
|
) {
|
|
const prefix =
|
|
section === "badges"
|
|
? "profileBadges"
|
|
: "profileAchievements";
|
|
|
|
rows.push(
|
|
new ActionRowBuilder()
|
|
.addComponents(
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
`${prefix}Left`
|
|
)
|
|
.setLabel(
|
|
"Previous"
|
|
)
|
|
.setEmoji(
|
|
"◀️"
|
|
)
|
|
.setDisabled(
|
|
page <= 0
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
`${prefix}Page`
|
|
)
|
|
.setLabel(
|
|
`${page + 1} / ${pageCount}`
|
|
)
|
|
.setDisabled(
|
|
true
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
),
|
|
|
|
new ButtonBuilder()
|
|
.setCustomId(
|
|
`${prefix}Right`
|
|
)
|
|
.setLabel(
|
|
"Next"
|
|
)
|
|
.setEmoji(
|
|
"▶️"
|
|
)
|
|
.setDisabled(
|
|
page >=
|
|
pageCount - 1
|
|
)
|
|
.setStyle(
|
|
ButtonStyle.Secondary
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function buildProfileFooter(
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section,
|
|
page,
|
|
pageCount
|
|
) {
|
|
return [
|
|
"profile",
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section,
|
|
page,
|
|
pageCount
|
|
].join(":");
|
|
}
|
|
|
|
function getProfileContext(
|
|
interaction
|
|
) {
|
|
const footer =
|
|
interaction.message
|
|
?.embeds?.[0]
|
|
?.footer
|
|
?.text;
|
|
|
|
return getProfileContextFromFooter(
|
|
footer
|
|
);
|
|
}
|
|
|
|
function getProfileContextFromFooter(
|
|
footer
|
|
) {
|
|
const [
|
|
type,
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section = "overview",
|
|
rawPage = "0",
|
|
rawPageCount = "1"
|
|
] = footer?.split(":") ?? [];
|
|
|
|
if (
|
|
type !== "profile" ||
|
|
!sessionOwnerId ||
|
|
!profileUserId
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sessionOwnerId,
|
|
profileUserId,
|
|
section,
|
|
page:
|
|
Number(rawPage) || 0,
|
|
pageCount:
|
|
Math.max(
|
|
1,
|
|
Number(rawPageCount) || 1
|
|
)
|
|
};
|
|
}
|
|
|
|
function getProfileTargetId(
|
|
interaction
|
|
) {
|
|
return getProfileContext(
|
|
interaction
|
|
)?.profileUserId ?? null;
|
|
}
|
|
|
|
async function requireProfileOwner(
|
|
interaction
|
|
) {
|
|
const context =
|
|
getProfileContext(
|
|
interaction
|
|
);
|
|
|
|
if (!context) {
|
|
await replyProfileError(
|
|
interaction,
|
|
"This profile session is no longer valid."
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
context.sessionOwnerId !==
|
|
interaction.user.id
|
|
) {
|
|
await replyProfileError(
|
|
interaction,
|
|
"This isn't your profile session."
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
async function replyProfileError(
|
|
interaction,
|
|
content
|
|
) {
|
|
const payload = {
|
|
content,
|
|
flags:
|
|
MessageFlags.Ephemeral
|
|
};
|
|
|
|
if (
|
|
interaction.replied ||
|
|
interaction.deferred
|
|
) {
|
|
return interaction.followUp(
|
|
payload
|
|
);
|
|
}
|
|
|
|
return interaction.reply(
|
|
payload
|
|
);
|
|
}
|
|
|
|
function paginateLines(
|
|
lines,
|
|
maxLength = 3400,
|
|
separator = "\n"
|
|
) {
|
|
const pages = [];
|
|
let current = [];
|
|
|
|
for (const line of lines) {
|
|
const candidate =
|
|
[...current, line].join(
|
|
separator
|
|
);
|
|
|
|
if (
|
|
candidate.length >
|
|
maxLength &&
|
|
current.length
|
|
) {
|
|
pages.push(
|
|
current.join(
|
|
separator
|
|
)
|
|
);
|
|
|
|
current = [line];
|
|
}
|
|
|
|
else {
|
|
current.push(line);
|
|
}
|
|
}
|
|
|
|
if (current.length) {
|
|
pages.push(
|
|
current.join(
|
|
separator
|
|
)
|
|
);
|
|
}
|
|
|
|
return pages;
|
|
}
|
|
|
|
function clampPage(
|
|
page,
|
|
pageCount
|
|
) {
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
Number(page) || 0,
|
|
Math.max(
|
|
0,
|
|
pageCount - 1
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
function formatCategoryName(
|
|
category
|
|
) {
|
|
const labels = {
|
|
economy:
|
|
"🍪 Economy",
|
|
stealing:
|
|
"🥷 Stealing",
|
|
slots:
|
|
"🎰 Slots",
|
|
blackjack:
|
|
"🃏 Blackjack",
|
|
roulette:
|
|
"🎡 Roulette",
|
|
heist:
|
|
"🏦 Heist",
|
|
poker:
|
|
"♠️ Poker",
|
|
collector:
|
|
"🏆 Collector",
|
|
other:
|
|
"🏅 Other",
|
|
activity:
|
|
"📅 Activity",
|
|
vault:
|
|
"🏦 Vault",
|
|
victim:
|
|
"🎯 Victim",
|
|
};
|
|
|
|
return labels[category] ??
|
|
`🏅 ${category.charAt(0).toUpperCase()}${category.slice(1)}`;
|
|
}
|
|
|
|
function formatBadgeLine(
|
|
badge,
|
|
profileUserId,
|
|
ownedPage
|
|
) {
|
|
const emoji =
|
|
badge.emoji ?? "🏅";
|
|
|
|
const description =
|
|
badge.description ??
|
|
"No description available.";
|
|
|
|
if (ownedPage) {
|
|
return `${emoji} **${badge.name}**\n${description}`;
|
|
}
|
|
|
|
let status;
|
|
|
|
if (
|
|
badge.ownerId ===
|
|
profileUserId
|
|
) {
|
|
status = "✅ **Owned by this player**";
|
|
}
|
|
|
|
else if (badge.ownerId) {
|
|
status = `🔒 Claimed by <@${badge.ownerId}>`;
|
|
}
|
|
|
|
else {
|
|
status = "🟢 **Available**";
|
|
}
|
|
|
|
return [
|
|
`${emoji} **${badge.name}**`,
|
|
description,
|
|
status
|
|
].join("\n");
|
|
}
|
|
|
|
function readStatPath(
|
|
document,
|
|
stat
|
|
) {
|
|
if (!stat) {
|
|
return undefined;
|
|
}
|
|
|
|
return String(stat)
|
|
.split(".")
|
|
.reduce(
|
|
(current, key) =>
|
|
current?.[key],
|
|
document
|
|
);
|
|
}
|
|
|
|
|
|
function evaluateCondition(
|
|
document,
|
|
condition
|
|
) {
|
|
if (
|
|
!condition ||
|
|
!condition.stat
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const actual =
|
|
readStatPath(
|
|
document,
|
|
condition.stat
|
|
);
|
|
|
|
const expected =
|
|
condition.value;
|
|
|
|
const operator =
|
|
String(
|
|
condition.operator ??
|
|
"gte"
|
|
).toLowerCase();
|
|
|
|
|
|
switch (operator) {
|
|
case "eq":
|
|
return (
|
|
actual ===
|
|
expected
|
|
);
|
|
|
|
|
|
case "neq":
|
|
return (
|
|
actual !==
|
|
expected
|
|
);
|
|
|
|
|
|
case "gt":
|
|
return (
|
|
Number(actual ?? 0) >
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "gte":
|
|
return (
|
|
Number(actual ?? 0) >=
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "lt":
|
|
return (
|
|
Number(actual ?? 0) <
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "lte":
|
|
return (
|
|
Number(actual ?? 0) <=
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "includes":
|
|
return (
|
|
Array.isArray(actual) &&
|
|
actual.includes(
|
|
expected
|
|
)
|
|
);
|
|
|
|
|
|
case "not_includes":
|
|
return (
|
|
Array.isArray(actual) &&
|
|
!actual.includes(
|
|
expected
|
|
)
|
|
);
|
|
|
|
|
|
case "truthy":
|
|
return Boolean(
|
|
actual
|
|
);
|
|
|
|
|
|
case "falsy":
|
|
return !Boolean(
|
|
actual
|
|
);
|
|
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
function evaluateCompositeAchievement(
|
|
document,
|
|
definition
|
|
) {
|
|
const conditions =
|
|
Array.isArray(
|
|
definition.conditions
|
|
)
|
|
? definition.conditions
|
|
: [];
|
|
|
|
|
|
if (!conditions.length) {
|
|
return false;
|
|
}
|
|
|
|
|
|
if (
|
|
definition.measure ===
|
|
"all"
|
|
) {
|
|
return conditions.every(
|
|
condition =>
|
|
evaluateCondition(
|
|
document,
|
|
condition
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
if (
|
|
definition.measure ===
|
|
"any"
|
|
) {
|
|
return conditions.some(
|
|
condition =>
|
|
evaluateCondition(
|
|
document,
|
|
condition
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
function isStatBackfillableAchievement(
|
|
definition
|
|
) {
|
|
if (!definition) {
|
|
return false;
|
|
}
|
|
|
|
|
|
if (
|
|
definition.measure ===
|
|
"achievementPercentage"
|
|
||
|
|
definition.measure ===
|
|
"event"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
|
|
if (
|
|
definition.measure === "all" ||
|
|
definition.measure === "any"
|
|
) {
|
|
return (
|
|
Array.isArray(
|
|
definition.conditions
|
|
)
|
|
&&
|
|
definition.conditions.length > 0
|
|
);
|
|
}
|
|
|
|
|
|
return Boolean(
|
|
definition.stat
|
|
);
|
|
}
|
|
|
|
|
|
function achievementRequirementsMet(
|
|
document,
|
|
definition
|
|
) {
|
|
if (!definition) {
|
|
return false;
|
|
}
|
|
|
|
|
|
if (
|
|
definition.measure === "all" ||
|
|
definition.measure === "any"
|
|
) {
|
|
return evaluateCompositeAchievement(
|
|
document,
|
|
definition
|
|
);
|
|
}
|
|
|
|
|
|
if (!definition.stat) {
|
|
return false;
|
|
}
|
|
|
|
|
|
const currentValue =
|
|
readStatValue(
|
|
document,
|
|
definition
|
|
);
|
|
|
|
|
|
return (
|
|
currentValue >=
|
|
Number(
|
|
definition.target ?? 0
|
|
)
|
|
);
|
|
}
|
|
|
|
function getCompositeProgress(
|
|
statsDocument,
|
|
definition
|
|
) {
|
|
const conditions =
|
|
Array.isArray(
|
|
definition.conditions
|
|
)
|
|
? definition.conditions
|
|
: [];
|
|
|
|
|
|
if (!conditions.length) {
|
|
return {
|
|
completed: 0,
|
|
total: 0
|
|
};
|
|
}
|
|
|
|
|
|
const completed =
|
|
conditions.filter(
|
|
condition =>
|
|
evaluateCondition(
|
|
statsDocument,
|
|
condition
|
|
)
|
|
).length;
|
|
|
|
|
|
return {
|
|
completed,
|
|
total:
|
|
conditions.length
|
|
};
|
|
}
|
|
function buildEventContext(
|
|
interaction,
|
|
event
|
|
) {
|
|
const activity =
|
|
getActivityDateContext();
|
|
|
|
return {
|
|
...event,
|
|
|
|
localDate:
|
|
activity.dateKey,
|
|
|
|
localWeekday:
|
|
activity.weekday,
|
|
|
|
localHour:
|
|
activity.hour,
|
|
|
|
localWeekKey:
|
|
activity.weekKey,
|
|
|
|
localTimeBucket:
|
|
activity.timeBucket,
|
|
|
|
isWeekend:
|
|
activity.weekday ===
|
|
"saturday"
|
|
||
|
|
activity.weekday ===
|
|
"sunday",
|
|
|
|
isWeekday:
|
|
![
|
|
"saturday",
|
|
"sunday"
|
|
].includes(
|
|
activity.weekday
|
|
)
|
|
};
|
|
}
|
|
function readEventPath(
|
|
event,
|
|
path
|
|
) {
|
|
if (!path) {
|
|
return undefined;
|
|
}
|
|
|
|
return String(path)
|
|
.split(".")
|
|
.reduce(
|
|
(current, key) =>
|
|
current?.[key],
|
|
event
|
|
);
|
|
}
|
|
|
|
function evaluateEventCondition(
|
|
event,
|
|
condition
|
|
) {
|
|
if (
|
|
!condition ||
|
|
!condition.field
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const actual =
|
|
readEventPath(
|
|
event,
|
|
condition.field
|
|
);
|
|
|
|
const expected =
|
|
condition.value;
|
|
|
|
const operator =
|
|
String(
|
|
condition.operator ??
|
|
"eq"
|
|
).toLowerCase();
|
|
|
|
|
|
switch (operator) {
|
|
case "eq":
|
|
return (
|
|
actual ===
|
|
expected
|
|
);
|
|
|
|
|
|
case "neq":
|
|
return (
|
|
actual !==
|
|
expected
|
|
);
|
|
|
|
|
|
case "gt":
|
|
return (
|
|
Number(actual ?? 0) >
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "gte":
|
|
return (
|
|
Number(actual ?? 0) >=
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "lt":
|
|
return (
|
|
Number(actual ?? 0) <
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "lte":
|
|
return (
|
|
Number(actual ?? 0) <=
|
|
Number(expected ?? 0)
|
|
);
|
|
|
|
|
|
case "in":
|
|
return (
|
|
Array.isArray(
|
|
expected
|
|
)
|
|
&&
|
|
expected.includes(
|
|
actual
|
|
)
|
|
);
|
|
|
|
|
|
case "not_in":
|
|
return (
|
|
Array.isArray(
|
|
expected
|
|
)
|
|
&&
|
|
!expected.includes(
|
|
actual
|
|
)
|
|
);
|
|
|
|
|
|
case "truthy":
|
|
return Boolean(
|
|
actual
|
|
);
|
|
|
|
|
|
case "falsy":
|
|
return !Boolean(
|
|
actual
|
|
);
|
|
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
function evaluateEventAchievement(
|
|
event,
|
|
definition
|
|
) {
|
|
const conditions =
|
|
Array.isArray(
|
|
definition.eventConditions
|
|
)
|
|
? definition.eventConditions
|
|
: [];
|
|
|
|
|
|
if (!conditions.length) {
|
|
return false;
|
|
}
|
|
|
|
|
|
const mode =
|
|
String(
|
|
definition.eventMode ??
|
|
"all"
|
|
).toLowerCase();
|
|
|
|
|
|
if (mode === "any") {
|
|
return conditions.some(
|
|
condition =>
|
|
evaluateEventCondition(
|
|
event,
|
|
condition
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
return conditions.every(
|
|
condition =>
|
|
evaluateEventCondition(
|
|
event,
|
|
condition
|
|
)
|
|
);
|
|
}
|
|
async function evaluateEventAchievements(
|
|
interaction,
|
|
userId,
|
|
event
|
|
) {
|
|
const guildId =
|
|
interaction.guildId;
|
|
|
|
|
|
if (
|
|
!guildId ||
|
|
!event?.type
|
|
) {
|
|
return;
|
|
}
|
|
|
|
|
|
const {
|
|
achievements,
|
|
achievementConfig
|
|
} =
|
|
getProfileCollections();
|
|
|
|
|
|
await ensureAchievementDocument(
|
|
userId,
|
|
guildId
|
|
);
|
|
|
|
|
|
const definitions =
|
|
await achievementConfig
|
|
.find({
|
|
active: {
|
|
$ne: false
|
|
},
|
|
|
|
measure:
|
|
"event"
|
|
})
|
|
.toArray();
|
|
|
|
|
|
if (!definitions.length) {
|
|
return;
|
|
}
|
|
|
|
|
|
const achievementDocument =
|
|
await achievements.findOne({
|
|
userId,
|
|
guildId
|
|
});
|
|
|
|
|
|
const unlocked =
|
|
achievementDocument
|
|
?.unlocked ?? {};
|
|
|
|
|
|
const eventContext =
|
|
buildEventContext(
|
|
interaction,
|
|
event
|
|
);
|
|
|
|
|
|
for (
|
|
const definition
|
|
of definitions
|
|
) {
|
|
if (
|
|
unlocked[
|
|
definition.achievementId
|
|
]
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
/*
|
|
* Optional fast filter.
|
|
*
|
|
* This avoids evaluating Roulette conditions
|
|
* against a Cookie Bakery event, for example.
|
|
*/
|
|
|
|
if (
|
|
definition.eventType &&
|
|
definition.eventType !==
|
|
eventContext.type
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
if (
|
|
!evaluateEventAchievement(
|
|
eventContext,
|
|
definition
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
|
|
await unlockAchievement(
|
|
interaction,
|
|
userId,
|
|
guildId,
|
|
definition
|
|
);
|
|
}
|
|
}
|
|
async function updateBankruptcyRecovery(
|
|
userId,
|
|
guildId
|
|
) {
|
|
const {
|
|
stats
|
|
} =
|
|
getProfileCollections();
|
|
|
|
const document =
|
|
await stats.findOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
projection: {
|
|
"special.bankruptcyCount":
|
|
1
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
if (
|
|
Number(
|
|
document
|
|
?.special
|
|
?.bankruptcyCount ?? 0
|
|
) <= 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
|
|
const balance =
|
|
await getCookieBalance(
|
|
userId
|
|
);
|
|
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$max: {
|
|
"special.bankruptcyRecoveryBest":
|
|
balance
|
|
}
|
|
}
|
|
);
|
|
}
|
|
async function trackDailyGameState(
|
|
userId,
|
|
guildId,
|
|
event
|
|
) {
|
|
if (
|
|
event.type !==
|
|
"game_result"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const game =
|
|
String(
|
|
event.game ?? ""
|
|
).toLowerCase();
|
|
|
|
const result =
|
|
String(
|
|
event.result ?? ""
|
|
).toLowerCase();
|
|
|
|
if (
|
|
![
|
|
"slots",
|
|
"blackjack",
|
|
"roulette",
|
|
"heist"
|
|
].includes(
|
|
game
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
stats
|
|
} =
|
|
getProfileCollections();
|
|
|
|
const {
|
|
dateKey
|
|
} =
|
|
getActivityDateContext();
|
|
|
|
const document =
|
|
await stats.findOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
projection: {
|
|
daily: 1
|
|
}
|
|
}
|
|
);
|
|
|
|
const currentDateKey =
|
|
document
|
|
?.daily
|
|
?.dateKey ?? null;
|
|
|
|
if (
|
|
currentDateKey !==
|
|
dateKey
|
|
) {
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$set: {
|
|
"daily.dateKey":
|
|
dateKey,
|
|
|
|
"daily.gamesPlayed":
|
|
[game],
|
|
|
|
"daily.gamesWon":
|
|
result === "win"
|
|
? [game]
|
|
: [],
|
|
|
|
"daily.gamesLost":
|
|
result === "loss"
|
|
? [game]
|
|
: [],
|
|
|
|
"daily.collectedCookies":
|
|
false,
|
|
|
|
"daily.gambledAfterCollect":
|
|
false,
|
|
|
|
"daily.wonAnyGame":
|
|
result === "win",
|
|
|
|
"daily.lostAnyGame":
|
|
result === "loss",
|
|
|
|
"daily.gaveCookies":
|
|
false,
|
|
|
|
"daily.stoleCookies":
|
|
false
|
|
}
|
|
}
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const addToSet = {
|
|
"daily.gamesPlayed":
|
|
game
|
|
};
|
|
|
|
const set = {};
|
|
|
|
if (
|
|
result ===
|
|
"win"
|
|
) {
|
|
addToSet[
|
|
"daily.gamesWon"
|
|
] = game;
|
|
|
|
set[
|
|
"daily.wonAnyGame"
|
|
] = true;
|
|
}
|
|
|
|
if (
|
|
result ===
|
|
"loss"
|
|
) {
|
|
addToSet[
|
|
"daily.gamesLost"
|
|
] = game;
|
|
|
|
set[
|
|
"daily.lostAnyGame"
|
|
] = true;
|
|
}
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$addToSet:
|
|
addToSet,
|
|
|
|
...(
|
|
Object.keys(
|
|
set
|
|
).length
|
|
? {
|
|
$set:
|
|
set
|
|
}
|
|
: {}
|
|
)
|
|
}
|
|
);
|
|
}
|
|
async function trackDailyNarrativeState(
|
|
userId,
|
|
guildId,
|
|
event
|
|
) {
|
|
const {
|
|
stats
|
|
} =
|
|
getProfileCollections();
|
|
|
|
const {
|
|
dateKey
|
|
} =
|
|
getActivityDateContext();
|
|
|
|
const document =
|
|
await stats.findOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
projection: {
|
|
daily: 1
|
|
}
|
|
}
|
|
);
|
|
|
|
const currentDateKey =
|
|
document
|
|
?.daily
|
|
?.dateKey ?? null;
|
|
|
|
if (
|
|
currentDateKey !==
|
|
dateKey
|
|
) {
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$set: {
|
|
"daily.dateKey":
|
|
dateKey,
|
|
|
|
"daily.gamesPlayed":
|
|
[],
|
|
|
|
"daily.gamesWon":
|
|
[],
|
|
|
|
"daily.gamesLost":
|
|
[],
|
|
|
|
"daily.collectedCookies":
|
|
false,
|
|
|
|
"daily.gambledAfterCollect":
|
|
false,
|
|
|
|
"daily.wonAnyGame":
|
|
false,
|
|
|
|
"daily.lostAnyGame":
|
|
false,
|
|
|
|
"daily.gaveCookies":
|
|
false,
|
|
|
|
"daily.stoleCookies":
|
|
false
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const set = {};
|
|
|
|
if (
|
|
event.type ===
|
|
"cookie_gain"
|
|
&&
|
|
event.source ===
|
|
"collect"
|
|
) {
|
|
set[
|
|
"daily.collectedCookies"
|
|
] = true;
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"cookies_given"
|
|
) {
|
|
set[
|
|
"daily.gaveCookies"
|
|
] = true;
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"steal_success"
|
|
) {
|
|
set[
|
|
"daily.stoleCookies"
|
|
] = true;
|
|
}
|
|
|
|
if (
|
|
event.type ===
|
|
"game_start"
|
|
&&
|
|
document
|
|
?.daily
|
|
?.collectedCookies === true
|
|
) {
|
|
set[
|
|
"daily.gambledAfterCollect"
|
|
] = true;
|
|
}
|
|
|
|
if (
|
|
Object.keys(
|
|
set
|
|
).length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await stats.updateOne(
|
|
{
|
|
userId,
|
|
guildId
|
|
},
|
|
{
|
|
$set:
|
|
set
|
|
}
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
ensureProfileSystem,
|
|
trackProfileEvent,
|
|
recordGameOutcome,
|
|
getProfileStats,
|
|
getProfileCollections,
|
|
getDefaultStats,
|
|
getCompositeProgress,
|
|
updateBankruptcyRecovery,
|
|
|
|
readStatValue,
|
|
readStatPath,
|
|
|
|
evaluateCondition,
|
|
evaluateCompositeAchievement,
|
|
achievementRequirementsMet,
|
|
isStatBackfillableAchievement,
|
|
|
|
getActivityDateContext,
|
|
trackGameActivity,
|
|
|
|
buildProfileOverview,
|
|
buildStatsEmbed,
|
|
buildBadgesEmbed,
|
|
buildAchievementsEmbed,
|
|
buildProfileButtons,
|
|
|
|
getProfileContext,
|
|
getProfileContextFromFooter,
|
|
getProfileTargetId,
|
|
requireProfileOwner,
|
|
|
|
buildEventContext,
|
|
readEventPath,
|
|
evaluateEventCondition,
|
|
evaluateEventAchievement,
|
|
evaluateEventAchievements,
|
|
evaluateSocialRelationshipFlags,
|
|
trackDailyGameState,
|
|
trackDailyNarrativeState,
|
|
}; |