Skip to content

Commit

Permalink
Merge pull request #448 from Brainicism/feature/exp-system
Browse files Browse the repository at this point in the history
EXP/level system
  • Loading branch information
Brainicism authored Jan 13, 2021
2 parents 65ec6a0 + 4a869db commit 67a5bb0
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 31 deletions.
14 changes: 13 additions & 1 deletion GAMEPLAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions migrations/20210112130147_player-exp-system.js
Original file line number Diff line number Diff line change
@@ -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");
});
};
70 changes: 54 additions & 16 deletions src/commands/game_commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -60,22 +83,37 @@ export default class ProfileCommand implements BaseCommand {
.where("games_played", ">", gamesPlayed)
.first())["count"] as number) + 1;

const fields: Array<Eris.EmbedField> = [{
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<Eris.EmbedField> = [
{
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)}`),
Expand Down
21 changes: 13 additions & 8 deletions src/helpers/discord_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/structures/elimination_scoreboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
99 changes: 95 additions & 4 deletions src/structures/game_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -136,6 +151,7 @@ export default class GameSession {
}
}

const leveledUpPlayers: Array<LevelUpResult> = [];
// commit player stats
for (const participant of this.participants) {
await this.ensurePlayerStat(participant);
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -472,11 +505,69 @@ 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<LevelUpResult> {
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
*/
private getDebugSongDetails(): string {
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<number> {
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));
}
}
18 changes: 18 additions & 0 deletions src/structures/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
Expand All @@ -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;
}
}
Loading

0 comments on commit 67a5bb0

Please sign in to comment.