'use strict'
const _ = require('lodash')
const Discord = require('discord.js')
const { Guild } = require('../Structures/Guild')
const { UserCard } = require('../Structures/UserCard')
const { Options } = require('./Options')
const { connect } = require('mongoose')
const { GuildsData } = require('../Model')
const { ValRnkAchv } = require('../Utils/ValRnkAchv')
const { ErrorCodes } = require('../Errors/ErrorCodes')
const { readdirSync } = require('node:fs')
const { RankBuilder } = require('../Structures/RankBuilder')
const { EventEmitter } = require('node:events')
const { GuildManager } = require('../Managers/GuildManager')
const { LevelManagerEvents } = require('../Interfaces')
const { AchievementBuilder } = require('../Structures/AchievementBuilder')
const { isNotPositiveInteger } = require('../Utils/ValidateInteger')
const { MissingValue, InvalidValue, EventMissingProperty } = require('../Errors/LME')
/** @typedef {import('../../typings').Rank} Rank */
/** @typedef {import('../../typings').Achievement} Achievement */
/** @typedef {import('../../typings').LevelManagerOptions} LevelManagerOptions */
/** @typedef {import('../../typings').UserEntry} UserEntry */
/** @typedef {import('../../typings').DatabaseData} DatabaseData */
/** @typedef {import('../../typings').LeaderboardOptions} LeaderboardOptions */
/**
* The hub of the library, this class is responsible for managing the ranks, achievements, and users from database.
*
* If you want to override the function that calculate the xp to level up, then visit {@tutorial overrideXpFunction}.
*
* @extends {EventEmitter}
*/
class LevelManager extends EventEmitter {
/** @type {Discord.Client} - Discord client */
client
/** @type {String} - MongoDB Uri */
mongoUri
/** @type {Function} - The function that calculate the xp to level up */
xpFunction
/** @type {Rank[]} - The default ranks when create a new guild entry */
ranks
/** @type {Achievement[]} - The default achievements when create a new guild entry */
achievement
/** @type {Number} - The max xp to level up */
maxXpToLevelUp
/** @type {Number} - The interval to save the data in the database */
saveInterval
/** @type {Boolean} - If the data will be saved automatically */
autoSave
/** @type {String} - The path to the events folder */
eventsPath
/** @type {GuildManager} - The guild manager */
guilds
/**
* @param {Discord.Client} client - Discord client.
* @param {LevelManagerOptions} options - Options for the level hub.
*
* @fires LevelManager#managerReady - When the manager is ready.
*
* @throws {MissingValue} - If some property of options is missing, or if options is undefined.
* @throws {InvalidValue} - If some property of options is not the correct type.
*/
constructor (client, options) {
super()
// Check if client exists and if is a discord client instance
if (!client) throw new MissingValue(ErrorCodes.MissingArgument, 'client')
if (!(client instanceof Discord.Client)) throw new InvalidValue(ErrorCodes.InvalidValue, 'Client', 'a Discord Client instance')
this.client = client
this.#checkOptions(options)
// Set all default options
this.ranks = options.ranks ?? Options.setDefaultRanks()
this.xpFunction = options.xpFunction ?? function (level, xp) {
return level * this.maxXpToLevelUp
}
this.achievements = options.achievements ?? Options.setDefaultAchievements()
this.maxXpToLevelUp = options.maxXpToLevelUp ?? 2500
this.saveInterval = options.saveInterval ?? 1000 * 60 * 60 * 3 // 3 hours
this.autoSave = options.autoSave ?? true
this.eventsPath = options.eventsPath ?? undefined
this.mongoUri = options.mongoUri
// Declare a guild manager
this.guilds = new GuildManager(client, [])
this.#init().then(() => {
/**
* Emitted when the manager is ready.
* @event LevelManager#managerReady
* @property {Discord.Client} client - Discord client.
*/
this.emit(LevelManagerEvents.ManagerReady, this.client)
})
}
/**
* Check if options have the correct type.
* @param {LevelManagerOptions} options - Options for the level hub.
*
* @throws {MissingValue} - If some property of options is missing, or if the parameter options is undefined.
* @throws {InvalidValue} - If some property of options is not the correct type.
* @private
*/
#checkOptions (options) {
if (!options) throw new MissingValue(ErrorCodes.MissingArgument, 'options')
if (typeof options !== 'object') throw new InvalidValue(ErrorCodes.InvalidValue, 'options', 'an object')
if (!options.mongoUri) throw new MissingValue(ErrorCodes.MissingProperty, 'mongoUri')
if (typeof options.mongoUri !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'mongoUri', 'a string')
if (options.xpFunction && typeof options.xpFunction !== 'function') throw new InvalidValue(ErrorCodes.InvalidValue, 'xpFunction', 'a function')
if (options.ranks && !Array.isArray(options.ranks)) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array')
if (options.ranks && !options.ranks.length) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array with at least one element')
if (options.achievements && !Array.isArray(options.achievements)) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array')
if (options.achievements && !options.achievements.length) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array with at least one element')
if (options.maxXpToLevelUp && typeof options.maxXpToLevelUp !== 'number') throw new InvalidValue(ErrorCodes.InvalidValue, 'maxXpToLevelUp', 'a number')
if (options.saveInterval && typeof options.saveInterval !== 'number') throw new InvalidValue(ErrorCodes.InvalidValue, 'saveInterval', 'a number')
if (options.autoSave && typeof options.autoSave !== 'boolean') throw new InvalidValue(ErrorCodes.InvalidValue, 'autoSave', 'a boolean')
if (options.eventsPath && typeof options.eventsPath !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'eventsPath', 'a string')
if (options.ranks) this.#checkRanks(options.ranks)
if (options.achievements) this.#checkAchievements(options.achievements)
}
/**
* Check if ranks have the correct type and if have all properties.
* @param {Rank[] | RankBuilder[]} ranks - Rank that will be checked.
* @private
*/
#checkRanks (ranks) {
for (const rank of ranks) {
if (typeof rank !== 'object' && !(rank instanceof RankBuilder)) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array of object or instance of Rank.')
ValRnkAchv.validateRank(rank)
}
}
/**
* Check if achievements have the correct type and if have all properties.
* @param {Achievement[] | AchievementBuilder[]} achievements - Achievement that will be checked.
* @private
*/
#checkAchievements (achievements) {
for (const achievement of achievements) {
if (typeof achievement !== 'object' && !(achievement instanceof AchievementBuilder)) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array of object or instance of Achievement.')
ValRnkAchv.validateAchievement(achievement)
}
}
/**
* Initialize the manager, first load all events and start the auto save interval,
* then load all data from the database.
* @returns {Promise<void>}
* @private
*/
#init () {
return new Promise((resolve, reject) => {
if (this.eventsPath) this.#loadEvents()
if (this.autoSave) this.#startAutoSave()
this.#loadData().then(resolve).catch(reject)
})
}
/**
* Load all events from the events path.
* @returns {Promise<void>}
* @private
*/
async #loadEvents () {
const events = await readdirSync(this.eventsPath)
const totalEvents = []
// Loop through all events and load them
for (const eventFile of events) {
const event = require(`${this.eventsPath}/${eventFile}`)
if (!event.eventType) throw new EventMissingProperty(ErrorCodes.EventMissingProperty, 'a eventType property')
if (!event.run) throw new EventMissingProperty(ErrorCodes.EventMissingProperty, 'a run method')
if (typeof event.run !== 'function') throw new InvalidValue(ErrorCodes.InvalidValue, 'run', 'a function')
if (!Object.values(LevelManagerEvents).includes(event.eventType)) throw new InvalidValue(ErrorCodes.InvalidValue, 'eventType', 'a valid event type')
totalEvents.push(event.eventType)
this.on(event.eventType, event.run.bind(null, this))
}
console.table(totalEvents)
}
/**
* Start the auto save interval.
* @returns {void}
* @private
*/
#startAutoSave () {
setInterval(async () => {
await this.saveData()
}, this.saveInterval)
}
/**
* Load all guilds data from the database.
* @returns {Promise<void>}
* @private
*/
async #loadData () {
await connect(this.mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true
}).then(async () => {
/** @type {DatabaseData[]} */
const bunch = await GuildsData.find({})
const guilds = []
// For each guild data create a new guild instance and push it to the guilds array.
for (const chunk of bunch) {
const guild = new Guild(this.client, chunk)
guilds.push(guild)
}
this.guilds = new GuildManager(this.client, guilds)
})
.catch(console.log)
}
/**
* Add a new guild entry to the cache and database.
* @param {Discord.Guild} guild
* @returns {Promise<Guild>}
* @private
*/
async #addGuild (guild) {
const guildData = new Guild(this.client, {
guildId: guild.id,
guildName: guild.name ?? 'Unknown',
achievements: this.achievements,
ranks: this.ranks,
users: []
})
this.guilds.cache.set(guildData.guildId, guild)
/* const document = await new GuildsData(guildData.toJSON())
await document.save().catch(console.log) */
return guildData
}
/**
* Add a new user entry to the cache and database.
* @param {Discord.GuildMember} member - The member that will be added.
* @param {Guild} guild - The guild where the member will be added.
* @returns {Promise<UserCard>}
* @private
*/
async #addUser (member, guild) {
/** @type {UserEntry} */
const data = {
id: member.id,
username: member.user.username,
level: 0,
xp: 0,
maxXpToLevelUp: this.maxXpToLevelUp,
messages: [],
achievements: this.achievements,
rank: this.ranks[0]
}
const user = new UserCard(this.client, data)
guild.users.cache.set(member.id, user)
// await GuildsData.findOneAndUpdate({ guildId: guild.guildId }, { $push: { users: UserCard } }).catch(console.log)
return user
}
/**
* Check if member and guildId are valid.
* @param {Discord.GuildMember} member - The member that will checked.
* @param {Discord.Snowflake} guildId - The guild that will checked.
*
* @throws {MissingValue} - If member is missing.
* @throws {InvalidValue} - If member or guildId is invalid.
*/
#filter (member, guildId) {
if (!member) throw new MissingValue(ErrorCodes.MissingArgument, 'member')
if (!(member instanceof Discord.GuildMember)) throw new InvalidValue(ErrorCodes.InvalidValue, 'member', 'a GuildMember')
if (typeof guildId !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a string')
if (guildId !== 'global' && !/^[0-9]+$/.test(guildId)) throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a SnowFlake')
}
/**
* Update the rank of a user.
* @param {UserCard} user - The user that will have a update in the rank.
* @param {Guild} guild - The guild where the user is.
* @param {Number} offset - The offset of the rank (+1 if the user level up, -1 if the user level down).
* @return {Rank}
* @private
*/
#updateRankUser (user, guild, offset) {
const rankIndex = guild.ranks.cache.findKey(rank => rank.priority === user.rank.priority + offset)
// If the rankIndex is undefined it means that the user is already in the highest or lowest rank.
if (!rankIndex) return
const rank = guild.ranks.cache.get(rankIndex)
user.rank = rank
return rank
}
/**
* Save all of guilds data to your database.
* @returns {Promise<void>}
*/
async saveData () {
for (const guild of this.guilds.cache.values()) {
// await GuildsData.findOneAndUpdate({ guildId: guild.guildId }, guild.toJSON())
}
}
/**
* Add a certain amount of xp to a member of a guild, there is no need to create a user entry, it will be created automatically if it doesn't exist.
* @param {Discord.GuildMember} member - The member that will receive the xp.
* @param {Number} xp - The amount of xp that will be added.
* @param {Discord.Guild} [guild] - The guild where the member is.
* @returns {Promise<boolean>} - Return true if the member has leveled up.
*
* @fires LevelManager#levelUp - When a member level up.
* @fires LevelManager#xpAdded - When a member receives xp.
*
* @throws {MissingValue} - If member or xp is missing.
* @throws {InvalidValue} - If member or xp is invalid.
*/
async addXp (member, xp, guild = { id: 'global', name: 'global' }) {
const { id: guildId } = guild
this.#filter(member, guildId)
if (!xp) throw new MissingValue(ErrorCodes.MissingArgument, 'xp')
if (isNotPositiveInteger(xp)) throw new InvalidValue(ErrorCodes.InvalidValue, 'xp', 'a positive integer')
let guildObject = this.guilds.cache.get(guildId)
if (!guildObject) guildObject = await this.#addGuild(guild)
let user = guildObject.users.cache.get(member.id)
if (!user) user = await this.#addUser(member, guildObject)
// This package uses integer numbers, so we need to truncate the xp to a integer.
user.xp += Math.floor(xp)
/**
* Emits when a member receives xp.
* @event LevelManager#xpAdded
* @property {UserCard} user - The user that received the xp.
* @property {Guild} guildObject - The guildObject where the user is.
* @property {Number} xp - The amount of xp that the user received.
*/
this.emit(LevelManagerEvents.XpAdded, user, guildObject, xp)
if (user.xp >= user.maxXpToLevelUp) {
user.level++
user.xp = Math.floor(user.xp - user.maxXpToLevelUp)
this.#updateRankUser(user, guildObject, 1)
user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)
/**
* Emits when a member has leveled up.
* @event LevelManager#levelUp
* @property {UserCard} user - The user that leveled up.
* @property {Guild} guildObject - The guildObject where the user is.
*/
this.emit(LevelManagerEvents.LevelUp, user, guildObject)
return true
}
return false
}
/**
* Manually level up a member.
* @param {Discord.GuildMember} member - The member that will be leveled up.
* @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
* @returns {Promise<UserCard>} - Return the user data of the member.
*
* @fires LevelManager#levelUp - When a member level up.
*
* @throws {MissingValue} - If member is missing.
* @throws {InvalidValue} - If member or guildId is invalid.
*/
async levelUp (member, guildId = 'global') {
this.#filter(member, guildId)
let guild = this.guilds.cache.get(guildId)
if (!guild) guild = await this.#addGuild(guildId)
let user = guild.users.cache.get(member.id)
if (!user) user = await this.#addUser(member, guild)
user.level++
user.xp = 0
this.#updateRankUser(user, guild, 1)
user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)
this.emit(LevelManagerEvents.LevelUp, user, guild)
return user
}
/**
* Decrease the level of a member.
* @param {Discord.GuildMember} member - The member that will be leveled down.
* @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
* @returns {Promise<UserCard>} - Return the user data of the member.
*
* @fires LevelManager#levelDown - When a member level down.
*
* @throws {MissingValue} - If member is missing.
* @throws {InvalidValue} - If member or guildId is invalid.
*/
async removeLevel (member, guildId = 'global') {
this.#filter(member, guildId)
let guild = this.guilds.cache.get(guildId)
if (!guild) guild = await this.#addGuild(guildId)
let user = guild.users.cache.get(member.id)
if (!user) user = await this.#addUser(member, guild)
// If user level is 0, we don't need to do anything.
if (user.level <= 0) return user
user.level--
this.#updateRankUser(user, guild, -1)
user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)
/**
* Emits when a member has leveled down.
* @event LevelManager#levelDown
* @property {UserCard} user - The user that leveled down.
* @property {Guild} guild - The guild where the user is.
*/
this.emit(LevelManagerEvents.LevelDown, user, guild)
return user
}
/**
* Manually rank up a member.
* @param {Discord.GuildMember} member - The member that will be ranked up.
* @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
* @returns {Promise<UserCard>} - Return the user data of the member.
*
* @fires LevelManager#rankUp - When a member rank up.
*
* @throws {MissingValue} - If member is missing.
* @throws {InvalidValue} - If member or guildId is invalid.
*/
async rankUp (member, guildId = 'global') {
this.#filter(member, guildId)
let guild = this.guilds.cache.get(guildId)
if (!guild) guild = await this.#addGuild(guildId)
let user = guild.users.cache.get(member.id)
if (!user) user = await this.#addUser(member, guild)
const rank = this.#updateRankUser(user, guild, 1)
/**
* Emits when a member has ranked up.
* @event LevelManager#rankUp
* @property {UserCard} user - The user that ranked up.
* @property {Guild} guild - The guild where the user is.
* @property {Rank} rank - The new rank of the user.
*/
this.emit(LevelManagerEvents.RankUp, user, guild, rank)
return user
}
/**
* Manually rank down a member.
* @param {Discord.GuildMember} member - The member that will be ranked down.
* @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
* @returns {Promise<UserCard>} - Return the user data of the member.
*
* @fires LevelManager#rankDown - When a member rank down.
*
* @throws {MissingValue} - If member is missing.
* @throws {InvalidValue} - If member or guildId is invalid.
*/
async degradeRank (member, guildId = 'global') {
this.#filter(member, guildId)
let guild = this.guilds.cache.get(guildId)
if (!guild) guild = await this.#addGuild(guildId)
let user = guild.users.cache.get(member.id)
if (!user) user = await this.#addUser(member, guild)
const rank = this.#updateRankUser(user, guild, -1)
/**
* Emits when a member has ranked down.
* @event LevelManager#rankDown
* @property {UserCard} user - The user that ranked down.
* @property {Guild} guild - The guild where the user is.
* @property {Rank} rank - The new rank of the user.
*/
this.emit(LevelManagerEvents.RankDown, user, guild, rank)
return user
}
/**
* Create an array of the top members of the guild (you can put a limit).
* @param {LeaderboardOptions} [guildId={ guildId: 'global', limit: 10 }] - The guild where the leaderboard will be.
* @returns {UserCard[]} - Return an array of the top members.
*
* @throws {MissingValue} - If guildId is missing.
* @throws {InvalidValue} - If guildId or limit is invalid.
*/
leaderboard ({ guildId = 'global', limit = 10 } = { guildId: 'global', limit: 10 }) {
if (typeof guildId !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a string')
if (guildId !== 'global' && !/^[0-9]+$/.test(guildId)) throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a SnowFlake')
if (isNotPositiveInteger(limit)) throw new InvalidValue(ErrorCodes.InvalidValue, 'limit', 'a positive integer')
const guild = this.guilds.cache.get(guildId)
if (!guild) return []
const array = guild.users.cache.map(u => u).sort((a, b) => b.level - a.level)
const users = array.slice(0, array.length < limit ? array.length : limit)
return _.cloneDeep(users)
}
}
module.exports = { LevelManager }