diff --git a/docs/COMMAND-WIKI.md b/docs/COMMAND-WIKI.md index c2cc7eca..4a737b79 100644 --- a/docs/COMMAND-WIKI.md +++ b/docs/COMMAND-WIKI.md @@ -151,6 +151,14 @@ - ``bet``: How much to bet - default is 10. - **Subcommands:** None +## worldle +- **Aliases:** `wl`, `country-guess` +- **Description:** Play Worldle - Guess the country +- **Examples:**
`.worldle`
`.worldle 100`
`.wl 50` +- **Options:** + - ``bet``: A valid bet amount +- **Subcommands:** None + # INTERVIEWER ## interviewers - **Aliases:** `int`, `interviewer` diff --git a/src/commandDetails/games/blackjack.ts b/src/commandDetails/games/blackjack.ts index 2776598d..9a2fdde3 100644 --- a/src/commandDetails/games/blackjack.ts +++ b/src/commandDetails/games/blackjack.ts @@ -265,17 +265,26 @@ const blackjackExecuteCommand: SapphireMessageExecuteType = async ( // Return next game state await msg.edit({ embeds: [getEmbedFromGame(game!)] }); await reactCollector.update({ components: [optionRow] }); - } catch { - // If player has not acted within time limit, consider it as quitting the game - game = performGameAction(author, BlackjackAction.QUIT); - msg.edit( - "You didn't act within the time limit. Unfortunately, this counts as a quit. Please start another game!", - ); - if (game) { - game.stage = BlackjackStage.DONE; + } catch (error) { + if (error instanceof Error && error.message.includes('time')) { + // If player has not acted within time limit, consider it as quitting the game + game = performGameAction(author, BlackjackAction.QUIT); + await msg.edit( + "You didn't act within the time limit. Unfortunately, this counts as a quit. Please start another game!", + ); + if (game) { + game.stage = BlackjackStage.DONE; + } + } else { + // Handling Unexpected Errors + await msg.edit('An unexpected error occured. The game has been aborted.'); + + closeGame(author, 0); // No change to balance + return 'An unexpected error occured. The game has been aborted.'; } } } + if (game) { // Update game embed await msg.edit({ embeds: [getEmbedFromGame(game)], components: [] }); diff --git a/src/commandDetails/games/worldle.ts b/src/commandDetails/games/worldle.ts new file mode 100644 index 00000000..2335b599 --- /dev/null +++ b/src/commandDetails/games/worldle.ts @@ -0,0 +1,363 @@ +import { container } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + Colors, + EmbedBuilder, + Interaction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, + getUserFromMessage, +} from '../../codeyCommand'; +import { + UserCoinEvent, + adjustCoinBalanceByUserId, + getCoinBalanceByUserId, + transferTracker, +} from '../../components/coin'; +import { getCoinEmoji, getEmojiByName } from '../../components/emojis'; +import { + WorldleAction, + WorldleGame, + endWorldleGame, + fetchCountries, + getProgressBar, + performWorldleAction, + startWorldleGame, + worldleGamesByPlayerId, +} from '../../components/games/worldle'; + +// CodeyCoin constants +const DEFAULT_BET = 20; +const MIN_BET = 10; +const MAX_BET = 1000000; +const REWARD_PER_GUESS = 10; // Additional reward for each unused guess + +// ----------------------------------- START OF UTILITY FUNCTIONS ---------------------------- // + +// ensure bet is within bounds +const validateBetAmount = (amount: number): string => { + if (amount < MIN_BET) return `Too few coins! Minimum bet is ${MIN_BET} Codey coins.`; + if (amount > MAX_BET) return `Too many coins! Maximum bet is ${MAX_BET} Codey coins.`; + return ''; +}; + +const createGameButtons = () => { + const guessButton = new ButtonBuilder() + .setCustomId('guess') + .setLabel('Make a Guess') + .setEmoji('๐ŸŒ') + .setStyle(ButtonStyle.Success); + + const hintButton = new ButtonBuilder() + .setCustomId('hint') + .setLabel('Hint') + .setEmoji('๐Ÿ’ก') + .setStyle(ButtonStyle.Primary); + + const quitButton = new ButtonBuilder() + .setCustomId('quit') + .setLabel('Quit') + .setEmoji('๐Ÿšช') + .setStyle(ButtonStyle.Danger); + + return new ActionRowBuilder().addComponents(guessButton, hintButton, quitButton); +}; + +const createGuessModal = (): { + modal: ModalBuilder; + actionRow: ActionRowBuilder; +} => { + const modal = new ModalBuilder().setCustomId('worldle-guess').setTitle('Guess the Country'); + + const countryInput = new TextInputBuilder() + .setCustomId('country-input') + .setLabel('Enter country name') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g. France, Japan, Brazil') + .setRequired(true); + + const actionRow = new ActionRowBuilder().addComponents(countryInput); + modal.addComponents(actionRow); + + return { modal, actionRow }; +}; + +// create an embed for the game +const createGameEmbed = (game: WorldleGame, bet: number): EmbedBuilder => { + const embed = new EmbedBuilder() + .setTitle('Worldle - Guess the Country') + .setColor(game.gameOver ? (game.won ? Colors.Green : Colors.Red) : Colors.Yellow); + + // add game description + if (game.gameOver) { + if (game.won) { + const unusedGuesses = game.maxAttempts - game.guessedCountries.length; + const extraReward = unusedGuesses * REWARD_PER_GUESS; + embed.setDescription( + `๐ŸŽ‰ You won! The country was **${game.targetCountry.name}**.\n` + + `You guessed it in ${game.guessedCountries.length}/${game.maxAttempts} attempts.\n` + + `Reward: ${bet + extraReward} ${getCoinEmoji()} (+${extraReward} bonus for quick solve)`, + ); + } else { + embed.setDescription( + `Game over! The country was **${game.targetCountry.name}**.\n` + + `You lost ${bet} ${getCoinEmoji()}. Better luck next time!`, + ); + } + } else { + embed.setDescription( + `Guess the country silhouette! You have ${ + game.maxAttempts - game.guessedCountries.length + } guesses left.\n` + + `Bet: ${bet} ${getCoinEmoji()}\n` + + `Use the buttons below to make a guess or get a hint.`, + ); + } + + // add guesses + if (game.guessedCountries.length > 0) { + const guessesField = game.guessedCountries + .map((guess, index) => { + return `${index + 1}. **${guess.country.name}** - ${guess.distance} km ${ + guess.direction + }\n${getProgressBar(guess.percentage)} ${guess.percentage}%`; + }) + .join('\n\n'); + + embed.addFields({ name: 'Your Guesses', value: guessesField }); + } + + return embed; +}; + +// game end handler +const handleGameEnd = async (game: WorldleGame, playerId: string, bet: number): Promise => { + let reward = 0; + + if (game.won) { + // calc reward : base bet + unused guesses + const unusedGuesses = game.maxAttempts - game.guessedCountries.length; + reward = bet + unusedGuesses * REWARD_PER_GUESS; + } else { + // loses bet + reward = -bet; + } + + await adjustCoinBalanceByUserId(playerId, reward, UserCoinEvent.Worldle); + + // end game + endWorldleGame(playerId); + + return reward; +}; + +// ----------------------------------- END OF UTILITY FUNCTIONS ---------------------------- // + +const worldleExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const message = messageFromUser; + + const bet = args['bet'] === undefined ? DEFAULT_BET : args['bet']; + + const author = getUserFromMessage(message).id; + const channel = message.channelId; + + // validate bet + const validateRes = validateBetAmount(bet); + if (validateRes !== '') { + return validateRes; + } + + // check if user has enough coins to bet + const playerBalance = await getCoinBalanceByUserId(author); + if (playerBalance! < bet) { + return `You don't have enough coins to place that bet. ${getEmojiByName('codey_sad')}`; + } + + // check if user is transferring coins + if (transferTracker.transferringUsers.has(author)) { + return `Please finish your current coin transfer before starting a game.`; + } + + // check if user has active game + if (worldleGamesByPlayerId.has(author)) { + // check if game is still running + const currentGame = worldleGamesByPlayerId.get(author)!; + const now = new Date().getTime(); + + if (!currentGame.gameOver && now - currentGame.startedAt.getTime() < 60000) { + return `Please finish your current game before starting another one!`; + } + } + + await fetchCountries(); + + // initialize game + const game = await startWorldleGame(author, channel); + if (!game) { + return 'Failed to start the game. Please try again later.'; + } + + const gameButtons = createGameButtons(); + + // initial game state + const msg = await message.reply({ + embeds: [createGameEmbed(game, bet)], + components: [gameButtons], + fetchReply: true, + }); + + const collector = msg.createMessageComponentCollector({ + filter: (i: Interaction) => { + if (!i.isButton() && !i.isModalSubmit()) return false; + return i.user.id === author; + }, + time: 300000, // 5 min timeout + }); + + const modalHandler = async (interaction: Interaction) => { + if (!interaction.isModalSubmit()) return; + if (interaction.customId !== 'worldle-guess') return; + if (interaction.user.id !== author) return; + + try { + const countryName = interaction.fields.getTextInputValue('country-input'); + + // Process the guess + const result = performWorldleAction(author, WorldleAction.GUESS, countryName); + + if (result.error) { + await interaction.reply({ + content: result.error, + ephemeral: true, + }); + } else { + await interaction.deferUpdate().catch(() => { + // if refails, try reply + return interaction.reply({ + content: `Guessed: ${countryName}`, + ephemeral: true, + }); + }); + + // update original msg + await msg.edit({ + embeds: [createGameEmbed(game, bet)], + components: game.gameOver ? [] : [gameButtons], + }); + + // Handle game end if necessary + if (game.gameOver) { + await handleGameEnd(game, author, bet); + collector.stop(); + } + } + } catch (error) { + // Try to respond to the interaction in multiple ways to ensure at least one works + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: 'An error occurred while processing your guess.', + ephemeral: true, + }); + } + } catch (replyError) { + await msg.edit(`Error in processing your request.`); + } + } + }; + + _client.on('interactionCreate', modalHandler); + + // remove listener when done + collector.on('end', () => { + _client.off('interactionCreate', modalHandler); + }); + + collector.on('collect', async (interaction: ButtonInteraction) => { + if (!interaction.isButton()) return; + + try { + if (interaction.customId === 'guess') { + const { modal } = createGuessModal(); + await interaction.showModal(modal); + } else if (interaction.customId === 'hint') { + // retrieve hint + const hintResult = performWorldleAction(author, WorldleAction.HINT); + if (hintResult) { + await interaction.reply({ + content: `**Hint ${hintResult.hintNumber}/${game.maxAttempts}**: ${hintResult.hint}`, + ephemeral: true, + }); + } else { + await interaction.reply({ + content: 'No hints available.', + ephemeral: true, + }); + } + } else if (interaction.customId === 'quit') { + performWorldleAction(author, WorldleAction.QUIT); + game.gameOver = true; + + await handleGameEnd(game, author, bet); + + await interaction.update({ + embeds: [createGameEmbed(game, bet)], + components: [], + }); + + collector.stop(); + } + } catch (error) { + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: 'An error occurred while processing your action.', + ephemeral: true, + }); + } + } catch (replyError) { + await msg.edit('Error in processing your request'); + } + } + }); + + return undefined; // message already sent +}; + +export const worldleCommandDetails: CodeyCommandDetails = { + name: 'worldle', + aliases: ['wl', 'country-guess'], + description: 'Play Worldle - Guess the country', + detailedDescription: `**Examples:** +\`${container.botPrefix}worldle\` +\`${container.botPrefix}worldle 100\` +\`${container.botPrefix}wl 50\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Starting Worldle game...', + executeCommand: worldleExecuteCommand, + messageIfFailure: 'Could not start the Worldle game', + options: [ + { + name: 'bet', + description: 'A valid bet amount', + type: CodeyCommandOptionType.INTEGER, + required: false, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commands/games/worldle.ts b/src/commands/games/worldle.ts new file mode 100644 index 00000000..c89c5cc0 --- /dev/null +++ b/src/commands/games/worldle.ts @@ -0,0 +1,16 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommand } from '../../codeyCommand'; +import { worldleCommandDetails } from '../../commandDetails/games/worldle'; + +export class GamesWorldleCommand extends CodeyCommand { + details = worldleCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: worldleCommandDetails.aliases, + description: worldleCommandDetails.description, + detailedDescription: worldleCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/coin.ts b/src/components/coin.ts index 328443b4..b6998e42 100644 --- a/src/components/coin.ts +++ b/src/components/coin.ts @@ -33,6 +33,7 @@ export enum UserCoinEvent { RpsWin, CoinTransferReceiver, CoinTransferSender, + Worldle, } export type Bonus = { diff --git a/src/components/games/worldle.ts b/src/components/games/worldle.ts new file mode 100644 index 00000000..a29199a0 --- /dev/null +++ b/src/components/games/worldle.ts @@ -0,0 +1,350 @@ +import axios from 'axios'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import _ from 'lodash'; + +export type WorldleGame = { + channelId: string; + targetCountry: Country; + guessedCountries: Guess[]; + startedAt: Date; + maxAttempts: number; + gameOver: boolean; + won: boolean; +}; + +export type Country = { + name: string; + code: string; + capital: string; + continent: string; + latlng: [number, number]; +}; + +export type Guess = { + country: Country; + distance: number; + direction: string; + percentage: number; +}; + +export enum WorldleAction { + GUESS = 'GUESS', + HINT = 'HINT', + QUIT = 'QUIT', +} + +export enum WorldleStage { + IN_PROGRESS = 'IN_PROGRESS', + DONE = 'DONE', +} + +export interface CountryAPI { + name: { + common: string; + }; + cca2: string; + capital?: string[]; + region: string; + latlng?: number[]; +} + +const MAX_ATTEMPTS = 4; +const COUNTRIES_API_URL = 'https://restcountries.com/v3.1/all'; +const EARTH_RADIUS = 6371; // km + +// keep track of games by discord ids +export const worldleGamesByPlayerId = new Map(); + +let countriesCache: Country[] = []; + +export const hintButton = new ButtonBuilder() + .setCustomId('hint') + .setLabel('Hint') + .setEmoji('๐Ÿ’ก') + .setStyle(ButtonStyle.Primary); + +export const quitButton = new ButtonBuilder() + .setCustomId('quit') + .setLabel('Quit') + .setEmoji('๐Ÿšช') + .setStyle(ButtonStyle.Danger); + +export const gameActionRow = new ActionRowBuilder().addComponents( + hintButton, + quitButton, +); + +// calculates distance between two coordinates +export const calculateDistance = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number => { + const toRad = (value: number) => (value * Math.PI) / 180; + + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return Math.round(EARTH_RADIUS * c); +}; + +// calculate direction between 2 points +export const calculateDirection = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): string => { + const dLat = lat2 - lat1; + const dLon = lon2 - lon1; + + let angle = Math.atan2(dLat, dLon) * (180 / Math.PI); + + // Convert to 0-360 range + if (angle < 0) { + angle += 360; + } + + // Convert angle to cardinal direction + const directions = ['๐Ÿกบ E', '๐Ÿกฝ NE', '๐Ÿกน N', '๐Ÿกผ NW', '๐Ÿกธ W', '๐Ÿกฟ SW', '๐Ÿกป S', '๐Ÿกพ SE']; + return directions[Math.round(angle / 45) % 8]; +}; + +// calculates proximity percentage +// * 0% = far, 100% = correct * +export const calculateProximity = (distance: number): number => { + // Max considered distance (half Earth circumference ~20,000km) + const MAX_DISTANCE = 10000; + const percentage = Math.max(0, 100 - Math.round((distance / MAX_DISTANCE) * 100)); + return percentage; +}; + +// fetch countries data +export const fetchCountries = async (): Promise => { + if (countriesCache.length > 0) { + return countriesCache; + } + + try { + const response = await axios.get(COUNTRIES_API_URL); + const data = response.data; + + // eslint-disable-next-line + countriesCache = data.map((country: any) => ({ + name: country.name.common, + code: country.cca2, + capital: country.capital?.[0] || 'Unknown', + continent: country.region || 'Unknown', + latlng: country.latlng || [0, 0], + })); + + return countriesCache; + } catch (error) { + return []; + } +}; + +// try to find country by name +export const findCountryByName = (name: string): Country | null => { + if (countriesCache.length === 0) { + return null; + } + + // try exact match + const exactMatch = countriesCache.find((c) => c.name.toLowerCase() === name.toLowerCase()); + + if (exactMatch) { + return exactMatch; + } + + // if no exact match, try partial match + const partialMatch = countriesCache.find( + (c) => + c.name.toLowerCase().includes(name.toLowerCase()) || + name.toLowerCase().includes(c.name.toLowerCase()), + ); + + return partialMatch || null; +}; + +// start a new Worldle game +export const startWorldleGame = async ( + playerId: string, + channelId: string, +): Promise => { + // check if player already has an active game + if (worldleGamesByPlayerId.has(playerId)) { + const currentGame = worldleGamesByPlayerId.get(playerId)!; + const now = new Date().getTime(); + + // if game is in progress and started less than a minute ago, don't start a new one + if (!currentGame.gameOver && now - currentGame.startedAt.getTime() < 60000) { + return null; + } + } + + // ensures countries data is loaded + await fetchCountries(); + if (countriesCache.length === 0) { + return null; + } + + // select a random country + const targetCountry = _.sample(countriesCache)!; + + // create new game + const game: WorldleGame = { + channelId, + targetCountry, + guessedCountries: [], + startedAt: new Date(), + maxAttempts: MAX_ATTEMPTS, + gameOver: false, + won: false, + }; + + worldleGamesByPlayerId.set(playerId, game); + return game; +}; + +// process a guess +export const makeGuess = ( + playerId: string, + countryName: string, +): { game: WorldleGame | null; guess: Guess | null; error?: string } => { + const game = worldleGamesByPlayerId.get(playerId); + if (!game) { + return { game: null, guess: null, error: 'No active game found.' }; + } + + if (game.gameOver) { + return { game, guess: null, error: 'Game is already over.' }; + } + + if (game.guessedCountries.length >= game.maxAttempts) { + game.gameOver = true; + return { game, guess: null, error: 'Maximum attempts reached.' }; + } + + // find country by name + const guessedCountry = findCountryByName(countryName); + if (!guessedCountry) { + return { game, guess: null, error: 'Country not found. Try another name.' }; + } + + // check if country was already guessed + if (game.guessedCountries.some((g) => g.country.code === guessedCountry.code)) { + return { game, guess: null, error: 'You already guessed this country.' }; + } + + // calculate distance and direction + const distance = calculateDistance( + game.targetCountry.latlng[0], + game.targetCountry.latlng[1], + guessedCountry.latlng[0], + guessedCountry.latlng[1], + ); + + const direction = calculateDirection( + guessedCountry.latlng[0], + guessedCountry.latlng[1], + game.targetCountry.latlng[0], + game.targetCountry.latlng[1], + ); + + const percentage = calculateProximity(distance); + + // create guess object + const guess: Guess = { + country: guessedCountry, + distance, + direction, + percentage, + }; + + // add guess to game + game.guessedCountries.push(guess); + + // check if guess is correct + if (guessedCountry.code === game.targetCountry.code) { + game.gameOver = true; + game.won = true; + } else if (game.guessedCountries.length >= game.maxAttempts) { + game.gameOver = true; + } + + return { game, guess }; +}; + +// get hint for current game +export const getHint = (playerId: string): { hint: string; hintNumber: number } | null => { + const game = worldleGamesByPlayerId.get(playerId); + if (!game) { + return null; + } + + const hintNumber = game.guessedCountries.length; + let hint = ''; + + switch (hintNumber) { + case 0: + hint = `Continent: ${game.targetCountry.continent}`; + break; + case 1: + hint = `First letter: ${game.targetCountry.name[0]}`; + break; + case 2: + hint = `Capital: ${game.targetCountry.capital}`; + break; + case 3: + hint = `Number of letters: ${game.targetCountry.name.length}`; + break; + default: + // unreachable + hint = `The country is ${game.targetCountry.name}`; + } + + return { hint, hintNumber: hintNumber + 1 }; +}; + +// terminate game +export const endWorldleGame = (playerId: string): void => { + worldleGamesByPlayerId.delete(playerId); +}; + +// perform game action +export const performWorldleAction = ( + playerId: string, + actionName: WorldleAction, + data?: string, + // eslint-disable-next-line +): any => { + switch (actionName) { + case WorldleAction.GUESS: + return makeGuess(playerId, data || ''); + case WorldleAction.HINT: + return getHint(playerId); + case WorldleAction.QUIT: + const game = worldleGamesByPlayerId.get(playerId); + if (game) { + game.gameOver = true; + } + return { game }; + default: + return null; + } +}; + +// Get progress bars based on percentage +export const getProgressBar = (percentage: number): string => { + const filledCount = Math.round(percentage / 10); + const emptyCount = 10 - filledCount; + + return '๐ŸŸฉ'.repeat(filledCount) + 'โฌœ'.repeat(emptyCount); +};