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);
+};