UNPKG

cronos-xp

Version:

A flexible xp-based level system framework that uses mongoDB

876 lines (875 loc) 34.6 kB
"use strict"; const mongoose = require("mongoose"); const guildSchema = new mongoose.Schema({ _id: String, users: {} }, { versionKey: false }); /** * A custom error for a missing argument */ class MissingArgumentException extends Error { constructor(message) { super(); this.name = "MissingArgumentException"; this.message = message; } } /** * A LevelSystem class that works with a mongoDB and allows for quite some flexibility. * * See the {@link https://github.com/cronos-team/cronos-xp#readme readme} */ class LevelSystem { /** * @param {string} mongoUrl - The URL to the mongoDB * @param {ConstructorOptions} [options] - A parameter for options */ constructor(mongoUrl, options) { if (typeof options?.linear !== "undefined") { if (typeof options?.linear === "boolean") { this._linear = options.linear; } else { console.info("Invalid linear input. Setting linear to default (false)"); this._linear = false; } } else { this._linear = false; } if (typeof options?.xpGap !== "undefined") { if (typeof options?.xpGap === "number") { this._xpGap = Math.abs(options.xpGap); } else { console.info("Invalid xpGap input. Setting xpGap to default (300)"); this._xpGap = 300; } } else { this._xpGap = 300; } if (typeof options?.growthMultiplier !== "undefined") { if (typeof options?.growthMultiplier === "number") { this._growthMultiplier = Math.abs(options.growthMultiplier); } else { console.info("Invalid growthMultiplier input. Setting growthMultiplier to default (30)"); this._growthMultiplier = 30; } } else { this._growthMultiplier = 30; } if (typeof options?.startWithZero !== "undefined") { if (typeof options?.startWithZero === "boolean") { this._startWithZero = options.startWithZero; } else { console.info("Invalid startWithZero input. Setting startWithZero to default (true)"); this._startWithZero = true; } } else { this._startWithZero = true; } if (typeof options?.returnDetails !== "undefined") { if (typeof options?.returnDetails === "boolean") { this._returnDetails = options.returnDetails; } else { console.info("Invalid returnDetails input. Setting returnDetails to default (false)"); this._returnDetails = false; } } else { this._returnDetails = false; } mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }).catch((e) => { console.error(e); }); this._model = mongoose.model("GuildXP", guildSchema, "GuildXP"); } ; /** * This method closes the connection to the database and deletes the current model */ destroy() { return new Promise((resolve => { mongoose.connection.close(); this._model = null; resolve(true); })); } ; /** * Method that returns the amount of xp needed for a certain level * @param {number} targetLevel - The desired level * @returns {number} - Amount of xp needed for targetLevel */ xpForLevel(targetLevel) { targetLevel = Math.round(targetLevel); if (this._linear) { return this._startWithZero ? targetLevel * this._xpGap : (targetLevel - 1) * this._xpGap; } else { if (this._growthMultiplier === 0) { // level³ = xp let functionValue = Math.pow(targetLevel, 3); if (!this._startWithZero) { // level³ - level = xp functionValue = functionValue - targetLevel; } return Math.round(functionValue); } else { // growthMultiplier * level² = xp let functionValue = Math.abs(this._growthMultiplier) * Math.pow(targetLevel, 2); if (!this._startWithZero) { // growthMultiplier * level² - growthMultiplier = xp functionValue = functionValue - this._growthMultiplier; } return Math.round(functionValue); } } } ; /** * Method that returns the level for a specific amount of xp * @param {number} targetXp - The desired xp * @returns {number} - The level at this amount of xp */ levelForXp(targetXp) { targetXp = Math.round(targetXp); if (this._linear) { return this._startWithZero ? Math.floor(targetXp / this._xpGap) : Math.floor(targetXp / this._xpGap) + 1; } else { if (this._growthMultiplier === 0) { // level = xp^1/3 let functionValue; this._startWithZero ? functionValue = targetXp : functionValue = targetXp + this._growthMultiplier; return Math.floor(Math.pow(functionValue, 1 / 3)); } else { // level = (xp / growthMultiplier)^1/2 let functionValue; this._startWithZero ? functionValue = targetXp : functionValue = targetXp + this._growthMultiplier; return Math.floor(Math.pow(functionValue / Math.abs(this._growthMultiplier), 1 / 2)); } } } ; /** * Method that returns the amount of xp needed to reach the next level * @param {number} currentXp - The current xp on which the calculations for the next level are based on * @returns {(number | XpForNextReturnObject)} - The amount of xp needed or the current and next level as well as their min required XP */ xpForNext(currentXp) { currentXp = Math.round(currentXp); let currentLevel = this.levelForXp(currentXp); let currentLevelXp = this.xpForLevel(currentLevel); let nextLevel = currentLevel + 1; let nextLevelXp = this.xpForLevel(nextLevel); if (!this._returnDetails) { return nextLevelXp - currentXp; } else { return { xpNeeded: nextLevelXp - currentXp, currentLevel: currentLevel, nextLevel: nextLevel, currentLevelXp: currentLevelXp, nextLevelXp: nextLevelXp }; } } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The amount of xp which the level and xp are set to * @returns {Promise<boolean>} - Returns true if the operation was successful * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the guild or user doesn't exist or if there was a problem with the update operation */ async setXp(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } let isUser = await this.isUser(guildId, userId); if (!isUser) throw new Error("This guildId or userId does not exist"); value = Math.round(value); return new Promise(((resolve, reject) => { this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: this.levelForXp(value), [`users.${userId}.xp`]: value } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The level which the level and xp are set to * @returns {Promise<boolean>} - Returns true if the operation was successful * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the guild or user doesn't exist or if there was a problem with the update operation */ async setLevel(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } let isUser = await this.isUser(guildId, userId); if (!isUser) throw new Error("This guildId or userId does not exist"); value = Math.round(value); return new Promise(((resolve, reject) => { this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: value, [`users.${userId}.xp`]: this.xpForLevel(value) } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The amount of xp to add to that user * @returns {Promise<AddSubtractReturnObject>} - Returns an object of type "AddSubtractReturnObject" * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the user couldn't be found or if there was a problem with the update operation */ async addXp(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } let user = await this.getUser(guildId, userId); return new Promise(((resolve, reject) => { if (typeof user !== "boolean") { value = Math.floor(Math.abs(value)); let newXp = user.xp + value; let newLevel = this.levelForXp(newXp); let levelDif = newLevel - user.level; let hasLevelUp = levelDif > 0; this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: newLevel, [`users.${userId}.xp`]: newXp } }, (e) => { if (e) reject(e); let result = { newLevel: newLevel, newXp: newXp, hasLevelUp: hasLevelUp, }; if (this._returnDetails) { result.details = { xpDifference: value, levelDifference: levelDif, nextLevelXp: this.xpForLevel(newLevel + 1), currentLevelXp: this.xpForLevel(newLevel) }; } resolve(result); }); } else { throw Error("Target user couldn't be found in the database"); } })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The amount of levels to add to that user * @returns {Promise<AddSubtractReturnObject>} - Returns an object of type "AddSubtractReturnObject" * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the user couldn't be found or if there was a problem with the update operation */ async addLevel(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } let user = await this.getUser(guildId, userId); return new Promise(((resolve, reject) => { if (typeof user !== "boolean") { value = Math.floor(Math.abs(value)); let newLevel = user.level + value; let xpDif = this.xpForLevel(newLevel) - this.xpForLevel(user.level); let newXp = user.xp + xpDif; let hasLevelUp = value > 0; this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: newLevel, [`users.${userId}.xp`]: newXp } }, (e) => { if (e) reject(e); let result = { newLevel: newLevel, newXp: newXp, hasLevelUp: hasLevelUp, }; if (this._returnDetails) { result.details = { xpDifference: xpDif, levelDifference: value, nextLevelXp: this.xpForLevel(newLevel + 1), currentLevelXp: this.xpForLevel(newLevel) }; } resolve(result); }); } else { throw Error("Target user couldn't be found in the database"); } })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The amount of xp to remove from that user * @returns {Promise<AddSubtractReturnObject>} - Returns an object of type "AddSubtractReturnObject" * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the user couldn't be found or if there was a problem with the update operation */ async subtractXp(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } value = Math.round(value); let user = await this.getUser(guildId, userId); return new Promise(((resolve, reject) => { if (typeof user !== "boolean") { let newXp = user.xp - value; if (newXp < 0) newXp = 0; let newLevel = this.levelForXp(newXp); let levelDif = newLevel - user.level; let hasLevelDown = levelDif < 0; this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: newLevel, [`users.${userId}.xp`]: newXp } }, (e) => { if (e) reject(e); let result = { newLevel: newLevel, newXp: newXp, hasLevelDown: hasLevelDown, }; if (this._returnDetails) { result.details = { xpDifference: -value, levelDifference: levelDif, nextLevelXp: this.xpForLevel(newLevel + 1), currentLevelXp: this.xpForLevel(newLevel) }; } resolve(result); }); } else { throw Error("Target user couldn't be found in the database"); } })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @param {number} value - The amount of levels to remove from that user * @returns {Promise<AddSubtractReturnObject>} - Returns an object of type "AddSubtractReturnObject" * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If the user couldn't be found or if there was a problem with the update operation */ async subtractLevel(guildId, userId, value) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } value = Math.round(value); let user = await this.getUser(guildId, userId); return new Promise(((resolve, reject) => { if (typeof user !== "boolean") { let newLevel = user.level - value; if (newLevel < 0) newLevel = 0; let userLevel = user.level; let xpDif = (this.xpForLevel(user.level) - this.xpForLevel(newLevel)); let newXp = user.xp - xpDif; let hasLevelDown = value > 0; this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}.level`]: newLevel, [`users.${userId}.xp`]: newXp } }, (e) => { if (e) reject(e); let result = { newLevel: newLevel, newXp: newXp, hasLevelDown: hasLevelDown, }; if (this._returnDetails) { result.details = { xpDifference: xpDif === 0 ? xpDif : -xpDif, levelDifference: newLevel <= 0 ? -value - (userLevel - value) : -value, nextLevelXp: this.xpForLevel(newLevel + 1), currentLevelXp: this.xpForLevel(newLevel) }; } resolve(result); }); } else { throw Error("Target user couldn't be found in the database"); } })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {number} [limit = 10] - The amount of leaderboard entries to return * @param {number} [startingAt = 0] - At which place to start (0 = start from first, 2 = start from third, ...) * @returns {Promise<[string, User][]> | boolean} - Returns an array of arrays that consist of the id as a string and "User" object * @example * //Returns: * [["id1", {xp: 0, level: 0}], ["id2", {xp: 0, level: 0}], ...] * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async getLeaderboard(guildId, limit, startingAt) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } const l = limit ? Math.abs(limit) : 10; const sa = startingAt ? Math.abs(startingAt) : 0; return new Promise(((resolve, reject) => { startingAt ? startingAt = Math.abs(startingAt) : startingAt = 0; this._model.findOne({ "_id": guildId }).then((result) => { if (result === null) resolve(false); let a = Object.entries(result.users); let b = a.sort((a, b) => b[1].xp - a[1].xp); let c = b.slice(sa, sa + l); resolve(c); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if user exists and false if he doesn't * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async isUser(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.findOne({ "_id": guildId, [`users.${userId}`]: { $exists: true } }).then((result) => { if (result === null) resolve(false); resolve(true); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<User | boolean>} - Returns the users data if he exists or false if he doesn't * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async getUser(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } let isUser = await this.isUser(guildId, userId); return new Promise(((resolve, reject) => { if (!isUser) resolve(false); this._model.findOne({ "_id": guildId }, //This can be commented if needed to take off the selecting load from MongoDB { "users": { [userId]: 1 } } // ).then((result) => { result.users[userId] ? resolve(result.users[userId]) : resolve(false); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<number>} - Returns the users rank in the guild or 0 if the user doesn't exist * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async getUserRank(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.findOne({ "_id": guildId }).then((result) => { if (result === null) resolve(0); let a = Object.entries(result.users); let b = a.sort((a, b) => b[1].xp - a[1].xp).map(x => x[0]); if (typeof userId === "string") { resolve(b.indexOf(userId) + 1); } else { resolve(0); } }).catch((e) => { reject(e); }); })); } /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if the user was created * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there already is a user with this id in this guild or if there was a problem with the update operation */ async createUser(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } const isUser = await this.isUser(guildId, userId); if (isUser) throw new Error("This guild already has a user with this id"); return new Promise(((resolve, reject) => { this._model.updateOne({ "_id": guildId }, { $set: { [`users.${userId}`]: { "xp": 0, "level": 0 } } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if the user was deleted * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async deleteUser(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.updateOne({ "_id": guildId }, { $unset: { [`users.${userId}`]: undefined } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if the user was deleted globally * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async deleteUserGlobal(userId) { try { userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.updateMany({ [`users.${userId}`]: { $exists: true } }, { $unset: { [`users.${userId}`]: undefined } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if the user was reset * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async resetUser(guildId, userId) { try { guildId = await LevelSystem._validateGuildId(guildId); userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return this.setXp(guildId, userId, 0); } ; /** * @param {(string | number)} userId - The id of the user * @returns {Promise<boolean>} - Returns true if the user was reset globally * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async resetUserGlobal(userId) { try { userId = await LevelSystem._validateUserId(userId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.updateMany({ [`users.${userId}`]: { $exists: true } }, { $set: { [`users.${userId}.level`]: 0, [`users.${userId}.xp`]: 0 } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {Promise<boolean>} - Returns true if guild exists and false if it doesn't * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async isGuild(guildId) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.findOne({ "_id": guildId }).then((result) => { if (result === null) resolve(false); resolve(true); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {Promise<boolean | object>} - Returns the guilds data if it exists or false if he doesn't * @example * //Returns: * { * "123": {xp: 0, level: 0}, * "456": {xp: 0, level: 0}, * "789": {xp: 0, level: 0} * } * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async getGuild(guildId) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.findOne({ "_id": guildId }).then((result) => { if (result === null) resolve(false); resolve(result.users); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {Promise<boolean>} - Returns true if the guild was created and false if it already exists * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async createGuild(guildId) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } const isGuild = await this.isGuild(guildId); if (isGuild) throw new Error("This guild already exists"); let guild = new this._model({ "_id": guildId, "users": {} }); return new Promise((resolve, reject) => { guild.save((e) => { if (e) reject(e); resolve(true); }); }); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {Promise<boolean>} - Returns true if the guild was deleted * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async deleteGuild(guildId) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.deleteOne({ "_id": guildId }).then(() => { // If you want to know if it actually got delete or if it already was deleted change the first line and add the second line // this._model.deleteOne({"_id": guildId}).then((result: any) => { // result.deletedCount === 0 ? resolve(false) : resolve(true) resolve(true); }).catch((e) => { reject(e); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {Promise<boolean>} - Returns true if the guild was reset * @throws {MissingArgumentException} - If there is a missing argument * @throws {Error} - If there was a problem with the update operation */ async resetGuild(guildId) { try { guildId = await LevelSystem._validateGuildId(guildId); } catch (e) { throw e; } return new Promise(((resolve, reject) => { this._model.updateOne({ "_id": guildId }, { $unset: { users: {} } }, (e) => { if (e) reject(e); resolve(true); }); })); } ; /** * @param {(string | number)} guildId - The id of the guild * @returns {string} - A valid guildId as a string * @throws {MissingArgumentException} - If there is a missing argument */ static _validateGuildId(guildId) { if (!guildId && guildId !== 0) throw new MissingArgumentException("Missing parameter \"guildId\""); if (typeof guildId === "string") return guildId; return guildId.toString(); } ; /** * @param {(string | number)} userId - The id of the user * @returns {string} - A valid userId as a string * @throws {MissingArgumentException} - If there is a missing argument */ static _validateUserId(userId) { if (!userId && userId !== 0) throw new MissingArgumentException("Missing parameter \"userId\""); if (typeof userId === "string") return userId; return userId.toString(); } ; } module.exports = LevelSystem;