From 979ec2a46854fcb3fac6a0ab3d3f7f12fcf8484e Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 29 Sep 2024 17:17:55 -0400 Subject: [PATCH 1/4] Fixed blackjack errors deducting coins --- src/commandDetails/games/blackjack.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/commandDetails/games/blackjack.ts b/src/commandDetails/games/blackjack.ts index 2776598d..771a7bcb 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: [] }); From b9c71a5a9a0bd2268a24ebe6b4b746dc8ed3bd46 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 29 Sep 2024 17:23:58 -0400 Subject: [PATCH 2/4] Fixed small typo --- src/commandDetails/games/blackjack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commandDetails/games/blackjack.ts b/src/commandDetails/games/blackjack.ts index 771a7bcb..9a2fdde3 100644 --- a/src/commandDetails/games/blackjack.ts +++ b/src/commandDetails/games/blackjack.ts @@ -277,7 +277,7 @@ const blackjackExecuteCommand: SapphireMessageExecuteType = async ( } } else { // Handling Unexpected Errors - await msg.edit("An unexpected error occured. The game has been aborted."); + 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.'; From 62c09f4cb3e5ef6f51d6ebd7dbf75cafea830c2b Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 26 Mar 2025 10:30:48 -0400 Subject: [PATCH 3/4] Init worldle impl --- docs/COMMAND-WIKI.md | 8 + src/commandDetails/games/worldle.ts | 351 ++++++++++++++++++++++++++++ src/commands/games/worldle.ts | 16 ++ src/components/coin.ts | 1 + src/components/games/worldle.ts | 338 +++++++++++++++++++++++++++ 5 files changed, 714 insertions(+) create mode 100644 src/commandDetails/games/worldle.ts create mode 100644 src/commands/games/worldle.ts create mode 100644 src/components/games/worldle.ts 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/worldle.ts b/src/commandDetails/games/worldle.ts new file mode 100644 index 00000000..39ab6a74 --- /dev/null +++ b/src/commandDetails/games/worldle.ts @@ -0,0 +1,351 @@ +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) { + console.error('Error in Worldle modal submission:', 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) { + console.error('Error replying to modal interaction:', replyError); + } + } + }; + + _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) { + console.error('Error in Worldle button interaction:', error); + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: 'An error occurred while processing your action.', + ephemeral: true + }); + } + } catch (replyError) { + console.error('Error replying to button interaction:', replyError); + } + } + }); + + 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: {}, +}; \ No newline at end of file diff --git a/src/commands/games/worldle.ts b/src/commands/games/worldle.ts new file mode 100644 index 00000000..02f6dbdf --- /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, + }); + } +} \ No newline at end of file diff --git a/src/components/coin.ts b/src/components/coin.ts index 328443b4..cafddafd 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..13316bf6 --- /dev/null +++ b/src/components/games/worldle.ts @@ -0,0 +1,338 @@ +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', +} + +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; + + 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) { + console.error('Error fetching countries:', 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 +): 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); +}; \ No newline at end of file From 9e30fa22b8be852ebcc5ac3508171ba27f701519 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 26 Mar 2025 13:23:40 -0400 Subject: [PATCH 4/4] Adhered to eslint formatting --- src/commandDetails/games/worldle.ts | 152 +++++++++++++++------------- src/commands/games/worldle.ts | 2 +- src/components/coin.ts | 2 +- src/components/games/worldle.ts | 132 +++++++++++++----------- 4 files changed, 156 insertions(+), 132 deletions(-) diff --git a/src/commandDetails/games/worldle.ts b/src/commandDetails/games/worldle.ts index 39ab6a74..2335b599 100644 --- a/src/commandDetails/games/worldle.ts +++ b/src/commandDetails/games/worldle.ts @@ -9,7 +9,7 @@ import { Interaction, ModalBuilder, TextInputBuilder, - TextInputStyle + TextInputStyle, } from 'discord.js'; import { CodeyCommandDetails, @@ -73,21 +73,22 @@ const createGameButtons = () => { 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 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 }; }; @@ -96,54 +97,67 @@ 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)`); + 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!`); + 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.`); + 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'); - + 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 + unusedGuesses * REWARD_PER_GUESS; + } else { + // loses bet reward = -bet; } - + await adjustCoinBalanceByUserId(playerId, reward, UserCoinEvent.Worldle); - + // end game endWorldleGame(playerId); - + return reward; }; @@ -155,57 +169,57 @@ const worldleExecuteCommand: SapphireMessageExecuteType = async ( 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; @@ -213,38 +227,38 @@ const worldleExecuteCommand: SapphireMessageExecuteType = async ( }, time: 300000, // 5 min timeout }); - -const modalHandler = async (interaction: Interaction) => { + + 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 + ephemeral: true, }); } else { await interaction.deferUpdate().catch(() => { // if refails, try reply - return interaction.reply({ - content: `Guessed: ${countryName}`, - ephemeral: true + return interaction.reply({ + content: `Guessed: ${countryName}`, + ephemeral: true, }); }); - + // update original msg await msg.edit({ embeds: [createGameEmbed(game, bet)], - components: game.gameOver ? [] : [gameButtons] + components: game.gameOver ? [] : [gameButtons], }); - + // Handle game end if necessary if (game.gameOver) { await handleGameEnd(game, author, bet); @@ -252,31 +266,30 @@ const modalHandler = async (interaction: Interaction) => { } } } catch (error) { - console.error('Error in Worldle modal submission:', 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 + ephemeral: true, }); } } catch (replyError) { - console.error('Error replying to modal interaction:', 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(); @@ -287,42 +300,41 @@ const modalHandler = async (interaction: Interaction) => { if (hintResult) { await interaction.reply({ content: `**Hint ${hintResult.hintNumber}/${game.maxAttempts}**: ${hintResult.hint}`, - ephemeral: true + ephemeral: true, }); } else { await interaction.reply({ content: 'No hints available.', - ephemeral: true + 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: [] + components: [], }); - + collector.stop(); } } catch (error) { - console.error('Error in Worldle button interaction:', error); try { if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: 'An error occurred while processing your action.', - ephemeral: true + ephemeral: true, }); } } catch (replyError) { - console.error('Error replying to button interaction:', replyError); + await msg.edit('Error in processing your request'); } } }); - + return undefined; // message already sent }; @@ -348,4 +360,4 @@ export const worldleCommandDetails: CodeyCommandDetails = { }, ], subcommandDetails: {}, -}; \ No newline at end of file +}; diff --git a/src/commands/games/worldle.ts b/src/commands/games/worldle.ts index 02f6dbdf..c89c5cc0 100644 --- a/src/commands/games/worldle.ts +++ b/src/commands/games/worldle.ts @@ -13,4 +13,4 @@ export class GamesWorldleCommand extends CodeyCommand { detailedDescription: worldleCommandDetails.detailedDescription, }); } -} \ No newline at end of file +} diff --git a/src/components/coin.ts b/src/components/coin.ts index cafddafd..b6998e42 100644 --- a/src/components/coin.ts +++ b/src/components/coin.ts @@ -33,7 +33,7 @@ export enum UserCoinEvent { RpsWin, CoinTransferReceiver, CoinTransferSender, - Worldle + Worldle, } export type Bonus = { diff --git a/src/components/games/worldle.ts b/src/components/games/worldle.ts index 13316bf6..a29199a0 100644 --- a/src/components/games/worldle.ts +++ b/src/components/games/worldle.ts @@ -2,8 +2,8 @@ import axios from 'axios'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import _ from 'lodash'; -export type WorldleGame = { - channelId: string; +export type WorldleGame = { + channelId: string; targetCountry: Country; guessedCountries: Guess[]; startedAt: Date; @@ -38,6 +38,16 @@ export enum WorldleStage { 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 @@ -59,26 +69,28 @@ export const quitButton = new ButtonBuilder() .setEmoji('๐Ÿšช') .setStyle(ButtonStyle.Danger); -export const gameActionRow = new ActionRowBuilder().addComponents(hintButton, quitButton); +export const gameActionRow = new ActionRowBuilder().addComponents( + hintButton, + quitButton, +); // calculates distance between two coordinates export const calculateDistance = ( lat1: number, lon1: number, lat2: number, - lon2: 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)); + + 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); }; @@ -87,18 +99,18 @@ export const calculateDirection = ( lat1: number, lon1: number, lat2: number, - lon2: 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]; @@ -118,22 +130,22 @@ 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] + latlng: country.latlng || [0, 0], })); - + return countriesCache; } catch (error) { - console.error('Error fetching countries:', error); return []; } }; @@ -143,50 +155,49 @@ 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() - ); - + 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()) + 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 + 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, @@ -195,9 +206,9 @@ export const startWorldleGame = async ( startedAt: new Date(), maxAttempts: MAX_ATTEMPTS, gameOver: false, - won: false + won: false, }; - + worldleGamesByPlayerId.set(playerId, game); return game; }; @@ -205,61 +216,61 @@ export const startWorldleGame = async ( // process a guess export const makeGuess = ( playerId: string, - countryName: 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)) { + 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] + guessedCountry.latlng[1], ); - + const direction = calculateDirection( guessedCountry.latlng[0], guessedCountry.latlng[1], game.targetCountry.latlng[0], - game.targetCountry.latlng[1] + game.targetCountry.latlng[1], ); - + const percentage = calculateProximity(distance); - + // create guess object const guess: Guess = { country: guessedCountry, distance, direction, - percentage + percentage, }; - + // add guess to game game.guessedCountries.push(guess); - + // check if guess is correct if (guessedCountry.code === game.targetCountry.code) { game.gameOver = true; @@ -267,7 +278,7 @@ export const makeGuess = ( } else if (game.guessedCountries.length >= game.maxAttempts) { game.gameOver = true; } - + return { game, guess }; }; @@ -277,10 +288,10 @@ export const getHint = (playerId: string): { hint: string; hintNumber: number } if (!game) { return null; } - + const hintNumber = game.guessedCountries.length; let hint = ''; - + switch (hintNumber) { case 0: hint = `Continent: ${game.targetCountry.continent}`; @@ -298,7 +309,7 @@ export const getHint = (playerId: string): { hint: string; hintNumber: number } // unreachable hint = `The country is ${game.targetCountry.name}`; } - + return { hint, hintNumber: hintNumber + 1 }; }; @@ -311,7 +322,8 @@ export const endWorldleGame = (playerId: string): void => { export const performWorldleAction = ( playerId: string, actionName: WorldleAction, - data?: string + data?: string, + // eslint-disable-next-line ): any => { switch (actionName) { case WorldleAction.GUESS: @@ -333,6 +345,6 @@ export const performWorldleAction = ( export const getProgressBar = (percentage: number): string => { const filledCount = Math.round(percentage / 10); const emptyCount = 10 - filledCount; - + return '๐ŸŸฉ'.repeat(filledCount) + 'โฌœ'.repeat(emptyCount); -}; \ No newline at end of file +};