diff --git a/GAMEPLAY.md b/GAMEPLAY.md index 437234e1d..9fd54a9f8 100644 --- a/GAMEPLAY.md +++ b/GAMEPLAY.md @@ -8,9 +8,21 @@ You can see the current scoreboard by typing `,score`. It is also shown after th See the latest updates to KMQ with `,news`. -## Game Modes +# Game Modes See who can survive the longest into a KMQ game with elimination mode. Using `,play elimination x`, everyone starts with `x` lives and the last one alive wins. Guessing correctly will save your life, while everyone else loses one. Use elimination mode in conjunction with `,timer` to raise the pressure! `,goal` cannot be used in elimination mode. +# EXP System +Think you have what it takes to be a KMQ master? Rise through the ranks, gaining EXP and leveling up by playing KMQ. Every correct guess will net you some EXP, increasing based on your game options. The higher the number of songs selected by your game options, the more EXP you will get! + +You start off as a `Novice`, and work your way up through the works of `Trainee` (Level 10), `Pre-debut` (Level 20), `Nugu` (Level 30), and many more to discover. Check out `,profile` to see where you stand. + +You will only gain EXP if: +- There is a minimum of 10 songs selected +- You are using `,mode song` (full EXP) +- You are using `,mode artist` or `,mode both` and you are not using `,groups` (30% EXP) + + + # Game Options KMQ offers different game options to narrow down the selection of random songs based on your preferences. The current game options can be viewed by using `,options` or simply tagging KMQ Bot. diff --git a/migrations/20210112130147_player-exp-system.js b/migrations/20210112130147_player-exp-system.js new file mode 100644 index 000000000..8d24f7bbb --- /dev/null +++ b/migrations/20210112130147_player-exp-system.js @@ -0,0 +1,14 @@ + +exports.up = function (knex) { + return knex.schema.table("player_stats", function (table) { + table.integer("exp").defaultTo(0); + table.integer("level").defaultTo(1); + }); +}; + +exports.down = function (knex) { + return knex.schema.table("player_stats", function (table) { + table.dropColumn("exp"); + table.dropColumn("level"); + }); +}; diff --git a/src/commands/game_commands/profile.ts b/src/commands/game_commands/profile.ts index 297dbee0e..f39c0e2a0 100644 --- a/src/commands/game_commands/profile.ts +++ b/src/commands/game_commands/profile.ts @@ -5,9 +5,32 @@ import { getDebugLogHeader, getMessageContext, getUserTag, sendEmbed, sendInfoMe import BaseCommand, { CommandArgs } from "../base_command"; import _logger from "../../logger"; import { bold, friendlyFormattedDate } from "../../helpers/utils"; +import { CUM_EXP_TABLE } from "../../structures/game_session"; const logger = _logger("profile"); +const RANK_TITLES = [ + { title: "Novice", req: 0 }, + { title: "Trainee", req: 10 }, + { title: "Pre-debut", req: 20 }, + { title: "Nugu", req: 30 }, + { title: "New Artist Of The Year", req: 40 }, + { title: "Artist Of The Year", req: 50 }, + { title: "Bonsang Award Winner", req: 60 }, + { title: "Daesang Award Winner", req: 70 }, + { title: "CEO of KMQ Entertainment", req: 80 }, + { title: "President of South Korea", req: 90 }, + { title: "Reuniter of the Two Koreas", req: 100 }, +]; + +export function getRankNameByLevel(level: number): string { + for (let i = RANK_TITLES.length - 1; i >= 0; i--) { + const rankTitle = RANK_TITLES[i]; + if (level >= rankTitle.req) return rankTitle.title; + } + return RANK_TITLES[0].title; +} + export default class ProfileCommand implements BaseCommand { help = { name: "profile", @@ -60,22 +83,37 @@ export default class ProfileCommand implements BaseCommand { .where("games_played", ">", gamesPlayed) .first())["count"] as number) + 1; - const fields: Array = [{ - name: "Songs Guessed", - value: `${songsGuessed} | #${relativeSongRank}/${totalPlayers} `, - }, - { - name: "Games Played", - value: `${gamesPlayed} | #${relativeGamesPlayedRank}/${totalPlayers} `, - }, - { - name: "First Played", - value: firstPlayDateString, - }, - { - name: "Last Active", - value: lastActiveDateString, - }]; + const { exp, level } = (await dbContext.kmq("player_stats") + .select(["exp", "level"]) + .where("player_id", "=", requestedPlayer.id) + .first()); + const fields: Array = [ + { + name: "Level", + value: `${level} (${getRankNameByLevel(level)})`, + inline: true, + }, + { + name: "Experience", + value: `${exp}/${CUM_EXP_TABLE[level + 1]}`, + inline: true, + }, + { + name: "Songs Guessed", + value: `${songsGuessed} | #${relativeSongRank}/${totalPlayers} `, + }, + { + name: "Games Played", + value: `${gamesPlayed} | #${relativeGamesPlayedRank}/${totalPlayers} `, + }, + { + name: "First Played", + value: firstPlayDateString, + }, + { + name: "Last Active", + value: lastActiveDateString, + }]; sendEmbed(message.channel, { title: bold(`${getUserTag(requestedPlayer)}`), diff --git a/src/helpers/discord_utils.ts b/src/helpers/discord_utils.ts index a54bb6d5c..6804e8b6c 100644 --- a/src/helpers/discord_utils.ts +++ b/src/helpers/discord_utils.ts @@ -114,13 +114,15 @@ export async function sendEndOfRoundMessage(messageContext: MessageContext, scor * @param description - The description of the embed */ export async function sendErrorMessage(messageContext: MessageContext, title: string, description: string) { + const author = messageContext.user ? { + name: messageContext.user.username, + icon_url: messageContext.user.avatarURL, + } : null; + await sendMessage(messageContext.channel, { embed: { color: EMBED_ERROR_COLOR, - author: { - name: messageContext.user.username, - icon_url: messageContext.user.avatarURL, - }, + author, title: bold(title), description, }, @@ -145,12 +147,15 @@ export async function sendInfoMessage(messageContext: MessageContext, title: str text: footerText, }; } + + const author = messageContext.user ? { + name: messageContext.user.username, + icon_url: messageContext.user.avatarURL, + } : null; + const embed = { color: EMBED_INFO_COLOR, - author: { - name: messageContext.user.username, - icon_url: messageContext.user.avatarURL, - }, + author, title: bold(title), description, footer, diff --git a/src/structures/elimination_scoreboard.ts b/src/structures/elimination_scoreboard.ts index a64a8c54c..c00969b93 100644 --- a/src/structures/elimination_scoreboard.ts +++ b/src/structures/elimination_scoreboard.ts @@ -47,11 +47,13 @@ export default class EliminationScoreboard extends Scoreboard { * @param _avatarURL - Unused * @param _pointsEarned - Unused */ - updateScoreboard(_winnerTag: string, winnerID: string, _avatarURL: string, _pointsEarned: number) { + updateScoreboard(_winnerTag: string, winnerID: string, _avatarURL: string, _pointsEarned: number, expGain: number) { let maxLives = -1; for (const player of Object.values(this.players)) { if (player.getId() !== winnerID) { player.decrementLives(); + } else { + player.incrementExp(expGain); } if (player.getLives() === maxLives) { this.firstPlace.push(player); diff --git a/src/structures/game_session.ts b/src/structures/game_session.ts index 1e4752541..24f414e12 100644 --- a/src/structures/game_session.ts +++ b/src/structures/game_session.ts @@ -5,7 +5,7 @@ import { ShuffleType } from "../commands/game_options/shuffle"; import dbContext from "../database_context"; import { isDebugMode, skipSongPlay } from "../helpers/debug_utils"; import { - getDebugLogHeader, getSqlDateString, getUserTag, getVoiceChannel, sendErrorMessage, sendEndOfRoundMessage, getMessageContext, + getDebugLogHeader, getSqlDateString, getUserTag, getVoiceChannel, sendErrorMessage, sendEndOfRoundMessage, getMessageContext, sendInfoMessage, } from "../helpers/discord_utils"; import { ensureVoiceConnection, getGuildPreference, selectRandomSong, getSongCount, endSession } from "../helpers/game_utils"; import { delay, getAudioDurationInSeconds } from "../helpers/utils"; @@ -19,10 +19,25 @@ import EliminationScoreboard from "./elimination_scoreboard"; import { deleteGameSession } from "../helpers/management_utils"; import { GameType } from "../commands/game_commands/play"; import { ModeType } from "../commands/game_options/mode"; +import { getRankNameByLevel } from "../commands/game_commands/profile"; const logger = _logger("game_session"); const LAST_PLAYED_SONG_QUEUE_SIZE = 10; +const EXP_TABLE = [...Array(200).keys()].map((level) => { + if (level === 0 || level === 1) return 0; + return 10 * (level ** 2) + 200 * level - 200; +}); + +// eslint-disable-next-line no-return-assign +export const CUM_EXP_TABLE = EXP_TABLE.map(((sum) => (value) => sum += value)(0)); + +interface LevelUpResult { + userId: string; + startLevel: number; + endLevel: number; +} + export default class GameSession { /** The GameType that the GameSession started in */ public readonly gameType: GameType; @@ -136,6 +151,7 @@ export default class GameSession { } } + const leveledUpPlayers: Array = []; // commit player stats for (const participant of this.participants) { await this.ensurePlayerStat(participant); @@ -144,6 +160,23 @@ export default class GameSession { if (playerScore > 0) { await this.incrementPlayerSongsGuessed(participant, playerScore); } + const playerExpGain = this.scoreboard.getPlayerExpGain(participant); + if (playerExpGain > 0) { + const levelUpResult = await this.incrementPlayerExp(participant, playerExpGain); + if (levelUpResult) { + leveledUpPlayers.push(levelUpResult); + } + } + } + + // send level up message + if (leveledUpPlayers.length > 0) { + let levelUpMessages = leveledUpPlayers.map((leveledUpPlayer) => `\`${this.scoreboard.getPlayerName(leveledUpPlayer.userId)}\` has leveled from \`${leveledUpPlayer.startLevel}\` to \`${leveledUpPlayer.endLevel} (${getRankNameByLevel(leveledUpPlayer.endLevel)})\``); + if (levelUpMessages.length > 10) { + levelUpMessages = levelUpMessages.slice(0, 10); + levelUpMessages.push("and many others..."); + } + sendInfoMessage({ channel: this.textChannel }, "🚀 Power up!", levelUpMessages.join("\n")); } // commit guild stats @@ -191,15 +224,15 @@ export default class GameSession { const pointsEarned = this.checkGuess(message, guildPreference.getModeType()); if (pointsEarned > 0) { - logger.info(`${getDebugLogHeader(message)} | Song correctly guessed. song = ${this.gameRound.songName}`); - // update game session's lastActive const gameSession = state.gameSessions[this.guildID]; gameSession.lastActiveNow(); // update scoreboard const userTag = getUserTag(message.author); - this.scoreboard.updateScoreboard(userTag, message.author.id, message.author.avatarURL, pointsEarned); + const expGain = await this.calculateExpGain(guildPreference); + logger.info(`${getDebugLogHeader(message)} | Song correctly guessed. song = ${this.gameRound.songName}. Gained ${expGain} EXP`); + this.scoreboard.updateScoreboard(userTag, message.author.id, message.author.avatarURL, pointsEarned, expGain); // misc. game round cleanup this.stopGuessTimeout(); @@ -472,6 +505,40 @@ export default class GameSession { .increment("games_played", 1); } + /** + * @param userId - The Discord ID of the user to exp gain + * @param expGain - The amount of EXP gained + */ + private async incrementPlayerExp(userId: string, expGain: number): Promise { + const { exp: currentExp, level } = (await dbContext.kmq("player_stats") + .select(["exp", "level"]) + .where("player_id", "=", userId) + .first()); + const newExp = currentExp + expGain; + let newLevel = level; + + // check for level up + while (newExp > CUM_EXP_TABLE[newLevel + 1]) { + newLevel++; + } + + // persist exp and level to data store + await dbContext.kmq("player_stats") + .update({ exp: newExp, level: newLevel }) + .where("player_id", "=", userId); + + if (level !== newLevel) { + logger.info(`${userId} has leveled from ${level} to ${newLevel}`); + return { + userId, + startLevel: level, + endLevel: newLevel, + }; + } + + return null; + } + /** * @returns Debug string containing basic information about the GameRound */ @@ -479,4 +546,28 @@ export default class GameSession { if (!this.gameRound) return "No active game round"; return `${this.gameRound.songName}:${this.gameRound.artist}:${this.gameRound.videoID}`; } + + /** + * https://www.desmos.com/calculator/zxvbuq0bch + * @param guildPreference - The guild preference + * @returns The amount of EXP gained based on the current game options + */ + private async calculateExpGain(guildPreference: GuildPreference): Promise { + let expModifier = 1; + const songCount = Math.min(await getSongCount(guildPreference), guildPreference.getLimit()); + + // minimum amount of songs for exp gain + if (songCount < 10) return 0; + + // penalize for using artist guess modes + if (guildPreference.getModeType() === ModeType.ARTIST || guildPreference.getModeType() === ModeType.BOTH) { + if (guildPreference.isGroupsMode()) return 0; + expModifier = 0.3; + } + + const expBase = 1000 / (1 + (Math.exp(1 - (0.00125 * songCount)))); + let expJitter = expBase * (0.05 * Math.random()); + expJitter *= Math.round(Math.random()) ? 1 : -1; + return Math.floor(expModifier * (expBase + expJitter)); + } } diff --git a/src/structures/player.ts b/src/structures/player.ts index 5530a6bac..4ded36cf0 100644 --- a/src/structures/player.ts +++ b/src/structures/player.ts @@ -11,11 +11,15 @@ export default class Player { /** The player's avatar URL */ private avatarURL: string; + /** The player's EXP gain */ + private expGain: number; + constructor(tag: string, id: string, avatarURL: string, points: number) { this.tag = tag; this.id = id; this.score = points; this.avatarURL = avatarURL; + this.expGain = 0; } /** @returns the player's Discord tag */ @@ -28,6 +32,11 @@ export default class Player { return this.score; } + /** @returns the player's EXP gain */ + getExpGain(): number { + return this.expGain; + } + /** @returns the player's Discord ID */ getId(): string { return this.id; @@ -45,4 +54,13 @@ export default class Player { incrementScore(pointsEarned: number) { this.score += pointsEarned; } + + /** + * Increment the player's EXP gain by the specified amount + * @param expGain - The amount of EXP that was gained + */ + + incrementExp(expGain: number) { + this.expGain += expGain; + } } diff --git a/src/structures/scoreboard.ts b/src/structures/scoreboard.ts index 7c8578772..cbe302629 100644 --- a/src/structures/scoreboard.ts +++ b/src/structures/scoreboard.ts @@ -77,13 +77,15 @@ export default class Scoreboard { * @param winnerID - The Discord ID of the correct guesser * @param avatarURL - The avatar URL of the correct guesser * @param pointsEarned - The amount of points awarded + * @param expGain - The amount of EXP gained */ - updateScoreboard(winnerTag: string, winnerID: string, avatarURL: string, pointsEarned: number) { + updateScoreboard(winnerTag: string, winnerID: string, avatarURL: string, pointsEarned: number, expGain: number) { if (!this.players[winnerID]) { this.players[winnerID] = new Player(winnerTag, winnerID, avatarURL, pointsEarned); } else { this.players[winnerID].incrementScore(pointsEarned); } + this.players[winnerID].incrementExp(expGain); if (this.players[winnerID].getScore() === this.highestScore) { // If user is tied for first, add them to the first place array @@ -116,6 +118,17 @@ export default class Scoreboard { return 0; } + /** + * @param userId - The Discord user ID of the player to check + * @returns the exp gained by the player + */ + getPlayerExpGain(userId: string): number { + if (userId in this.players) { + return this.players[userId].getExpGain(); + } + return 0; + } + /** @returns whether the game has completed */ async gameFinished(): Promise { const guildPreference = await getGuildPreference(this.guildID); @@ -126,4 +139,12 @@ export default class Scoreboard { getPlayerNames(): Array { return Object.values(this.players).map((player) => player.getTag()); } + + /** + * @param userId - The Discord user ID of the Player + * @returns a Player object for the corresponding user ID + * */ + getPlayerName(userId: string): string { + return this.players[userId].getTag(); + } }