diff --git a/src/commandDetails/coin/leaderboard.ts b/src/commandDetails/coin/leaderboard.ts index 25c24f31..9458b266 100644 --- a/src/commandDetails/coin/leaderboard.ts +++ b/src/commandDetails/coin/leaderboard.ts @@ -1,79 +1,13 @@ -import { container, SapphireClient } from '@sapphire/framework'; -import { EmbedBuilder } from 'discord.js'; +import { container } from '@sapphire/framework'; import { CodeyCommandDetails, - getUserFromMessage, SapphireMessageExecuteType, SapphireMessageResponse, + getUserFromMessage, } from '../../codeyCommand'; import { getCoinBalanceByUserId, getCoinLeaderboard } from '../../components/coin'; import { getCoinEmoji } from '../../components/emojis'; -import { DEFAULT_EMBED_COLOUR } from '../../utils/embeds'; - -// Number of users to display on leaderboard -const LEADERBOARD_LIMIT_DISPLAY = 10; -// Number of users to fetch for leaderboard -const LEADERBOARD_LIMIT_FETCH = LEADERBOARD_LIMIT_DISPLAY * 2; - -const getCoinLeaderboardEmbed = async ( - client: SapphireClient, - userId: string, -): Promise => { - // Get extra users to filter bots later - let leaderboard = await getCoinLeaderboard(LEADERBOARD_LIMIT_FETCH); - const leaderboardArray: string[] = []; - // Initialize user's coin balance if they have not already - const userBalance = await getCoinBalanceByUserId(userId); - let previousBalance = -1; - let position = 0; - let rank = 0; - let offset = 0; - let i = 0; - let absoluteCount = 0; - while (leaderboardArray.length < LEADERBOARD_LIMIT_DISPLAY || position === 0) { - if (i === LEADERBOARD_LIMIT_FETCH) { - offset += LEADERBOARD_LIMIT_FETCH; - leaderboard = await getCoinLeaderboard(LEADERBOARD_LIMIT_FETCH, offset); - i = 0; - } - if (i >= leaderboard.length) { - break; - } - const userCoinEntry = leaderboard[i++]; - const user = await client.users.fetch(userCoinEntry.user_id).catch(() => null); - if (user?.bot) continue; - if (previousBalance === userCoinEntry.balance) { - previousBalance = userCoinEntry.balance; - // rank does not change - } else { - previousBalance = userCoinEntry.balance; - rank = absoluteCount + 1; - } - // count how many total users have been processed: - absoluteCount++; - if (userCoinEntry.user_id === userId) { - position = rank; - } - if (leaderboardArray.length < LEADERBOARD_LIMIT_DISPLAY) { - const userCoinEntryText = `${rank}\\. <@${userCoinEntry.user_id}> - ${ - userCoinEntry.balance - } ${getCoinEmoji()}`; - leaderboardArray.push(userCoinEntryText); - } - } - const leaderboardText = leaderboardArray.join('\n'); - const leaderboardEmbed = new EmbedBuilder() - .setColor(DEFAULT_EMBED_COLOUR) - .setTitle('Codey Coin Leaderboard') - .setDescription(leaderboardText); - leaderboardEmbed.addFields([ - { - name: 'Your Position', - value: `You are currently **#${position}** in the leaderboard with ${userBalance} ${getCoinEmoji()}.`, - }, - ]); - return leaderboardEmbed; -}; +import { getLeaderboardEmbed } from '../../utils/leaderboards'; const coinLeaderboardExecuteCommand: SapphireMessageExecuteType = async ( client, @@ -81,7 +15,16 @@ const coinLeaderboardExecuteCommand: SapphireMessageExecuteType = async ( _args, ): Promise => { const userId = getUserFromMessage(messageFromUser).id; - return { embeds: [await getCoinLeaderboardEmbed(client, userId)] }; + const leaderboardEmbed = await getLeaderboardEmbed( + client, + userId, + getCoinLeaderboard, + (entry, rank) => `${rank}\\. <@${entry.user_id}> - ${entry.balance} coins`, + getCoinBalanceByUserId, + 'Coin Leaderboard', + getCoinEmoji(), + ); + return { embeds: [leaderboardEmbed] }; }; export const coinLeaderboardCommandDetails: CodeyCommandDetails = { diff --git a/src/commandDetails/games/blackjack.ts b/src/commandDetails/games/blackjack.ts index 2776598d..6efa91cb 100644 --- a/src/commandDetails/games/blackjack.ts +++ b/src/commandDetails/games/blackjack.ts @@ -34,6 +34,7 @@ import { startGame, } from '../../components/games/blackjack'; import { pluralize } from '../../utils/pluralize'; +import { adjustBlackjackGameResult } from '../../components/games/blackjackLeaderboards'; // CodeyCoin constants const DEFAULT_BET = 10; @@ -121,6 +122,7 @@ const getBalanceChange = (game: GameState): number => { const getEmbedColourFromGame = (game: GameState): keyof typeof Colors => { if (game.stage === BlackjackStage.DONE) { const balance = getBalanceChange(game); + // Player lost coins if (balance < 0) { return 'Red'; @@ -138,7 +140,7 @@ const getEmbedColourFromGame = (game: GameState): keyof typeof Colors => { }; // Retrieve game status at different states -const getDescriptionFromGame = (game: GameState): string => { +const getDescriptionFromGame = async (game: GameState): Promise => { const amountDiff = Math.abs(getBalanceChange(game)); if (game.stage === BlackjackStage.DONE) { // Player surrendered @@ -170,14 +172,15 @@ const getDescriptionFromGame = (game: GameState): string => { }; // Display embed to play game -const getEmbedFromGame = (game: GameState): EmbedBuilder => { +const getEmbedFromGame = async (game: GameState): Promise => { const embed = new EmbedBuilder().setTitle('Blackjack'); embed.setColor(getEmbedColourFromGame(game)); + const description = await getDescriptionFromGame(game); embed.addFields([ // Show bet amount and game description - { name: `Bet: ${game.bet} ${getCoinEmoji()}`, value: getDescriptionFromGame(game) }, + { name: `Bet: ${game.bet} ${getCoinEmoji()}`, value: description }, // Show player and dealer value and hands { name: `Player: ${game.playerValue.join(' or ')}`, @@ -193,7 +196,9 @@ const getEmbedFromGame = (game: GameState): EmbedBuilder => { }; // End the game -const closeGame = (playerId: string, balanceChange = 0) => { +const closeGame = async (playerId: string, game: GameState) => { + const balanceChange = getBalanceChange(game); + adjustBlackjackGameResult(playerId, balanceChange); endGame(playerId); adjustCoinBalanceByUserId(playerId, balanceChange, UserCoinEvent.Blackjack); }; @@ -239,9 +244,10 @@ const blackjackExecuteCommand: SapphireMessageExecuteType = async ( return 'Please finish your current game before starting another one!'; } + const embed = await getEmbedFromGame(game); // Show game initial state and setup reactions const msg = await message.reply({ - embeds: [getEmbedFromGame(game)], + embeds: [embed], components: game?.stage != BlackjackStage.DONE ? [optionRow] : [], fetchReply: true, }); @@ -261,9 +267,10 @@ const blackjackExecuteCommand: SapphireMessageExecuteType = async ( // Wait for user action game = await performActionFromReaction(reactCollector, author); + const updatedEmbed = await getEmbedFromGame(game!); // Return next game state - await msg.edit({ embeds: [getEmbedFromGame(game!)] }); + await msg.edit({ embeds: [updatedEmbed] }); await reactCollector.update({ components: [optionRow] }); } catch { // If player has not acted within time limit, consider it as quitting the game @@ -278,9 +285,10 @@ const blackjackExecuteCommand: SapphireMessageExecuteType = async ( } if (game) { // Update game embed - await msg.edit({ embeds: [getEmbedFromGame(game)], components: [] }); + const finalEmbed = await getEmbedFromGame(game); + await msg.edit({ embeds: [finalEmbed], components: [] }); // End the game - closeGame(author, getBalanceChange(game)); + closeGame(author, game); } }; diff --git a/src/commandDetails/games/blackjackLeaderboards/total.ts b/src/commandDetails/games/blackjackLeaderboards/total.ts new file mode 100644 index 00000000..3ea0da0b --- /dev/null +++ b/src/commandDetails/games/blackjackLeaderboards/total.ts @@ -0,0 +1,53 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, + getUserFromMessage, +} from '../../../codeyCommand'; +import { getCoinEmoji } from '../../../components/emojis'; +import { getLeaderboardEmbed } from '../../../utils/leaderboards'; +import { + getNetTotalBlackjackBalanceByUserId, + getBlackjackNetTotalLeaderboard, +} from '../../../components/games/blackjackLeaderboards'; + +const blackjackNetTotalLeaderboardExecuteCommand: SapphireMessageExecuteType = async ( + client, + messageFromUser, + _args, +): Promise => { + const userId = getUserFromMessage(messageFromUser).id; + const leaderboardEmbed = await getLeaderboardEmbed( + client, + userId, + getBlackjackNetTotalLeaderboard, + (entry, rank) => { + const netGainLoss = entry.net_gain_loss ?? 0; + const formattedNetGainLoss = netGainLoss < 0 ? `(${netGainLoss})` : netGainLoss.toString(); + return `${rank}\\. <@${entry.user_id}> - ${formattedNetGainLoss} coins`; + }, + async (id) => { + const netGainLoss = await getNetTotalBlackjackBalanceByUserId(id); + return netGainLoss < 0 ? `(${netGainLoss})` : netGainLoss.toString(); + }, + 'Blackjack Net Total Leaderboard', + getCoinEmoji(), + ); + return { embeds: [leaderboardEmbed] }; +}; + +export const blackjackTotalLeaderboardCommandDetails: CodeyCommandDetails = { + name: 'total', + aliases: ['t'], + description: 'Get the current blackjack net gain/loss leaderboard.', + detailedDescription: `**Examples:** +\`${container.botPrefix}blackjackleaderboards total\` +\`${container.botPrefix}blackjackleaderboards t\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Getting the current blackjack net gain/loss leaderboard...', + executeCommand: blackjackNetTotalLeaderboardExecuteCommand, + options: [], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/games/blackjackLeaderboards/winrate.ts b/src/commandDetails/games/blackjackLeaderboards/winrate.ts new file mode 100644 index 00000000..e796a652 --- /dev/null +++ b/src/commandDetails/games/blackjackLeaderboards/winrate.ts @@ -0,0 +1,51 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, + getUserFromMessage, +} from '../../../codeyCommand'; +import { getLeaderboardEmbed } from '../../../utils/leaderboards'; +import { + getWinrateBlackjackByUserId, + getBlackjackWinrateLeaderboard, +} from '../../../components/games/blackjackLeaderboards'; + +const blackjackWinrateLeaderboardExecuteCommand: SapphireMessageExecuteType = async ( + client, + messageFromUser, + _args, +): Promise => { + const userId = getUserFromMessage(messageFromUser).id; + const leaderboardEmbed = await getLeaderboardEmbed( + client, + userId, + getBlackjackWinrateLeaderboard, + (entry, rank) => { + const formattedWinrate = entry.winrate ? (entry.winrate * 100).toFixed(2) + ' %' : 'N/A'; + return `${rank}\\. <@${entry.user_id}> - ${formattedWinrate}`; + }, + async (id) => { + const winrate = await getWinrateBlackjackByUserId(id); + return winrate ? (winrate * 100).toFixed(2) + ' %' : 'N/A'; + }, + 'Blackjack Winrate Leaderboard', + '', + ); + return { embeds: [leaderboardEmbed] }; +}; + +export const blackjackWinrateLeaderboardCommandDetails: CodeyCommandDetails = { + name: 'winrate', + aliases: ['wr'], + description: 'Get the current blackjack winrate leaderboard.', + detailedDescription: `**Examples:** +\`${container.botPrefix}blackjackleaderboards winrate\` +\`${container.botPrefix}blackjackleaderboards wr\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Getting the current blackjack winrate leaderboard...', + executeCommand: blackjackWinrateLeaderboardExecuteCommand, + options: [], + subcommandDetails: {}, +}; diff --git a/src/commands/games/blackjackLeaderboards.ts b/src/commands/games/blackjackLeaderboards.ts new file mode 100644 index 00000000..f61c8899 --- /dev/null +++ b/src/commands/games/blackjackLeaderboards.ts @@ -0,0 +1,32 @@ +import { Command, container } from '@sapphire/framework'; +import { CodeyCommand, CodeyCommandDetails } from '../../codeyCommand'; +import { blackjackWinrateLeaderboardCommandDetails } from '../../commandDetails/games/blackjackLeaderboards/winrate'; +import { blackjackTotalLeaderboardCommandDetails } from '../../commandDetails/games/blackjackLeaderboards/total'; + +const blackjackLeaderboardsCommandDetails: CodeyCommandDetails = { + name: 'blackjackleaderboards', + aliases: ['blackjacklb', 'bjlb'], + description: 'Handle blackjack leaderboard functions.', + detailedDescription: `**Examples:** +\`${container.botPrefix}blackjackleaderboards winrate @Codey\` +\`${container.botPrefix}blackjackleaderboards total @Codey\``, + options: [], + subcommandDetails: { + winrate: blackjackWinrateLeaderboardCommandDetails, + total: blackjackTotalLeaderboardCommandDetails, + }, + defaultSubcommandDetails: blackjackWinrateLeaderboardCommandDetails, +}; + +export class GamesBlackjackLeaderboardsCommand extends CodeyCommand { + details = blackjackLeaderboardsCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: blackjackLeaderboardsCommandDetails.aliases, + description: blackjackLeaderboardsCommandDetails.description, + detailedDescription: blackjackLeaderboardsCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/db.ts b/src/components/db.ts index 19dad417..b4b30219 100644 --- a/src/components/db.ts +++ b/src/components/db.ts @@ -100,6 +100,25 @@ const initUserCoinTable = async (db: Database): Promise => { ); }; +const initBlackjackPlayerStats = async (db: Database): Promise => { + await db.run( + ` + CREATE TABLE IF NOT EXISTS blackjack_player_stats ( + user_id VARCHAR(255) PRIMARY KEY NOT NULL, + games_played INTEGER NOT NULL DEFAULT 0, + games_won INTEGER NOT NULL DEFAULT 0, + games_lost INTEGER NOT NULL DEFAULT 0, + net_gain_loss INTEGER NOT NULL DEFAULT 0, + winrate REAL NOT NULL DEFAULT 0.0 + ) + `, + ); + await db.run( + `CREATE INDEX IF NOT EXISTS idx_net_gain_loss ON blackjack_player_stats (net_gain_loss)`, + ); + await db.run(`CREATE INDEX IF NOT EXISTS idx_winrate ON blackjack_player_stats (winrate)`); +}; + const initUserProfileTable = async (db: Database): Promise => { await db.run( ` @@ -230,6 +249,7 @@ const initTables = async (db: Database): Promise => { await initUserCoinBonusTable(db); await initUserCoinLedgerTable(db); await initUserCoinTable(db); + await initBlackjackPlayerStats(db); await initUserProfileTable(db); await initRpsGameInfo(db); await initConnectFourGameInfo(db); diff --git a/src/components/games/blackjackLeaderboards.ts b/src/components/games/blackjackLeaderboards.ts new file mode 100644 index 00000000..f8602943 --- /dev/null +++ b/src/components/games/blackjackLeaderboards.ts @@ -0,0 +1,109 @@ +import _ from 'lodash'; +import { openDB } from '../db'; + +export const getNetTotalBlackjackBalanceByUserId = async (userId: string): Promise => { + const db = await openDB(); + const res = await db.get( + 'SELECT net_gain_loss FROM blackjack_player_stats WHERE user_id = ?', + userId, + ); + return _.get(res, 'net_gain_loss', 0); +}; + +export const getWinrateBlackjackByUserId = async (userId: string): Promise => { + const db = await openDB(); + const res = await db.get('SELECT winrate FROM blackjack_player_stats WHERE user_id = ?', userId); + return _.get(res, 'winrate', 0); +}; + +interface WinRate { + user_id: string; + winrate: number; +} + +export const getBlackjackWinrateLeaderboard = async ( + limit: number, + offset = 0, +): Promise => { + const db = await openDB(); + const res = await db.all( + ` + SELECT user_id, winrate + FROM blackjack_player_stats + ORDER BY winrate DESC + LIMIT ? OFFSET ? + `, + limit, + offset, + ); + return res; +}; + +interface NetGainLoss { + user_id: string; + net_gain_loss: number; +} + +export const getBlackjackNetTotalLeaderboard = async ( + limit: number, + offset = 0, +): Promise => { + const db = await openDB(); + const res = await db.all( + ` + SELECT user_id, net_gain_loss + FROM blackjack_player_stats + ORDER BY net_gain_loss DESC + LIMIT ? OFFSET ? + `, + limit, + offset, + ); + return res; +}; + +export const adjustBlackjackGameResult = async ( + userId: string, + balanceChange: number, +): Promise => { + const db = await openDB(); + const playerStats = await db.get( + 'SELECT * FROM blackjack_player_stats WHERE user_id = ?', + userId, + ); + + let gamesPlayed = 1; + let gamesWon = balanceChange > 0 ? 1 : 0; + let gamesLost = balanceChange < 0 ? 1 : 0; + let netGainLoss = balanceChange; + + if (playerStats) { + gamesPlayed += playerStats.games_played; + gamesWon += playerStats.games_won; + gamesLost += playerStats.games_lost; + netGainLoss += playerStats.net_gain_loss; + + const winrate = gamesWon / gamesPlayed; + + await db.run( + 'UPDATE blackjack_player_stats SET games_played = ?, games_won = ?, games_lost = ?, net_gain_loss = ?, winrate = ? WHERE user_id = ?', + gamesPlayed, + gamesWon, + gamesLost, + netGainLoss, + winrate, + userId, + ); + } else { + const winrate = gamesWon / gamesPlayed; + await db.run( + 'INSERT INTO blackjack_player_stats (user_id, games_played, games_won, games_lost, net_gain_loss, winrate) VALUES (?, ?, ?, ?, ?, ?)', + userId, + gamesPlayed, + gamesWon, + gamesLost, + netGainLoss, + winrate, + ); + } +}; diff --git a/src/utils/leaderboards.ts b/src/utils/leaderboards.ts new file mode 100644 index 00000000..61377212 --- /dev/null +++ b/src/utils/leaderboards.ts @@ -0,0 +1,82 @@ +import { EmbedBuilder, Emoji } from 'discord.js'; +import { SapphireClient } from '@sapphire/framework'; +import { DEFAULT_EMBED_COLOUR } from '../utils/embeds'; + +const LEADERBOARD_LIMIT_DISPLAY = 10; +const LEADERBOARD_LIMIT_FETCH = LEADERBOARD_LIMIT_DISPLAY * 2; + +interface LeaderboardEntry { + user_id: string; + balance?: number; + winrate?: number; + net_gain_loss?: number; +} + +type GetLeaderboardData = (limit: number, offset?: number) => Promise; +type FormatLeaderboardEntry = (entry: LeaderboardEntry, rank: number) => string; +type GetUserStatistic = (userId: string) => Promise; + +const getLeaderboardEmbed = async ( + client: SapphireClient, + userId: string, + getLeaderboardData: GetLeaderboardData, + formatLeaderboardEntry: FormatLeaderboardEntry, + getUserStatistic: GetUserStatistic, + leaderboardTitle: string, + leaderboardEmoji: string | Emoji, +): Promise => { + let leaderboard = await getLeaderboardData(LEADERBOARD_LIMIT_FETCH); + const leaderboardArray: string[] = []; + const userStatistic = await getUserStatistic(userId); + let previousValue: number | undefined = undefined; + let position = 0; + let rank = 0; + let offset = 0; + let i = 0; + let absoluteCount = 0; + + while (leaderboardArray.length < LEADERBOARD_LIMIT_DISPLAY || position === 0) { + if (i === LEADERBOARD_LIMIT_FETCH) { + offset += LEADERBOARD_LIMIT_FETCH; + leaderboard = await getLeaderboardData(LEADERBOARD_LIMIT_FETCH, offset); + i = 0; + } + if (i >= leaderboard.length) { + break; + } + const entry = leaderboard[i++]; + const user = await client.users.fetch(entry.user_id).catch(() => null); + if (user?.bot) continue; + + const currentValue = entry.balance ?? entry.winrate ?? entry.net_gain_loss; + if (previousValue === currentValue) { + previousValue = currentValue; + } else { + previousValue = currentValue; + rank = absoluteCount + 1; + } + absoluteCount++; + if (entry.user_id === userId) { + position = rank; + } + if (leaderboardArray.length < LEADERBOARD_LIMIT_DISPLAY) { + leaderboardArray.push(formatLeaderboardEntry(entry, rank)); + } + } + + const leaderboardText = leaderboardArray.join('\n') || 'No entries available.'; + const leaderboardEmbed = new EmbedBuilder() + .setColor(DEFAULT_EMBED_COLOUR) + .setTitle(leaderboardTitle) + .setDescription(leaderboardText); + leaderboardEmbed.addFields([ + { + name: 'Your Position', + value: `You are currently **#${position}** in the leaderboard with ${userStatistic} ${leaderboardEmoji}.`, + }, + ]); + + return leaderboardEmbed; +}; + +export { getLeaderboardEmbed }; diff --git a/src/utils/updateWiki.ts b/src/utils/updateWiki.ts index 2277e712..81669b49 100644 --- a/src/utils/updateWiki.ts +++ b/src/utils/updateWiki.ts @@ -188,6 +188,10 @@ export const updateWiki = async (): Promise => { const subDir = `${commandDetailsDir}/${dir}`; const files = await readdir(subDir); for (const file of files) { + if (file === 'blackjackLeaderboards') { + // Skip blackjackLeaderboards until it can be fixed in this wiki! + continue; + } const filePath = `${subDir}/${file}`; const content = await readFile(filePath, 'utf-8'); const match = content.match(commandDetailsPattern);