commit 3f9871276605e2cfd7953b1e8521dc14dcb88ace Author: Irinel Sarbu Date: Fri Nov 21 13:03:06 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d19616 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Deno +.deno/ +deno.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# User data +.morse-game/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..696d927 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Morse Code Practice Game šŸŽ® + +A terminal-based morse code practice game built with Deno. Improve your morse code translation skills through interactive gameplay with multiple difficulty levels and comprehensive statistics tracking. + +## Features + +- **Multiple Difficulty Modes** + - 🟢 Easy: Single letters (A-Z) + - 🟔 Medium: Single numbers (0-9) + - 🟣 Hard: Common words + - šŸ”“ Expert: Short phrases + +- **Interactive TUI Interface** + - Beautiful terminal UI with colors and tables + - Real-time timer for each round + - Immediate feedback on answers + - Progress tracking with streak counter + +- **Statistics Tracking** + - Overall accuracy and performance metrics + - Mode-specific statistics + - Best streak tracking + - Historical game data + - Average time per round + +- **Flexible CLI** + - Interactive menu mode (default) + - Quick play mode with command-line arguments + - Stats viewer + - Built-in morse code reference + +## Installation + +Make sure you have [Deno](https://deno.land/) installed. + +```bash +# Clone or download this repository +cd morse-game + +# Make the script executable (optional) +chmod +x main.ts +``` + +## Usage + +### Interactive Mode (Default) + +Run the game and navigate through the menu: + +```bash +deno task start +# or +deno run --allow-read --allow-write --allow-env main.ts +``` + +### Quick Play Mode + +Start a game directly with custom settings: + +```bash +# Play 5 rounds of letters mode with 20 seconds per round +deno task start play --mode letters --rounds 5 --time 20 + +# Play expert mode +deno task start play --mode phrases --rounds 10 --time 45 +``` + +Options: +- `-m, --mode ` - Game mode: `letters`, `numbers`, `words`, or `phrases` (default: letters) +- `-r, --rounds ` - Number of rounds (default: 10) +- `-t, --time ` - Seconds per round (default: 30) + +### View Statistics + +```bash +deno task start stats +``` + +### Show Reference + +Display a morse code reference chart: + +```bash +deno task start reference +``` + +### Reset Statistics + +```bash +deno task start reset +``` + +## How to Play + +1. **Select a difficulty mode** - Choose from letters, numbers, words, or phrases +2. **Configure your game** - Set the number of rounds and time per round +3. **Translate to morse code** - You'll be shown a challenge (letter, number, word, or phrase) +4. **Enter your answer** - Type the morse code using: + - `.` (dot) for short signals + - `-` (dash) for long signals + - Space to separate letters + - `/` to separate words +5. **Get instant feedback** - See if you got it right and track your streak +6. **Review your performance** - After all rounds, view detailed statistics + +## Morse Code Basics + +- Use `.` for dots and `-` for dashes +- Separate letters with spaces +- Use `/` for word boundaries +- Example: `HELLO` = `.... . .-.. .-.. ---` +- Example: `SOS` = `... --- ...` + +## Statistics + +The game tracks: +- Total games played +- Total rounds completed +- Correct and incorrect answers +- Overall accuracy percentage +- Average time per round +- Best streak (consecutive correct answers) +- Per-mode statistics + +Stats are saved to `~/.morse-game/stats.json` + +## Development + +```bash +# Run in watch mode for development +deno task dev + +# Run with specific permissions +deno run --allow-read --allow-write --allow-env main.ts +``` + +## Project Structure + +``` +morse-game/ +ā”œā”€ā”€ main.ts # CLI entry point and command parser +ā”œā”€ā”€ game.ts # Game logic and session management +ā”œā”€ā”€ morse.ts # Morse code translation utilities +ā”œā”€ā”€ stats.ts # Statistics tracking and persistence +ā”œā”€ā”€ ui.ts # TUI interface and menus +ā”œā”€ā”€ deno.json # Deno configuration and dependencies +└── README.md # This file +``` + +## Dependencies + +- [@cliffy/command](https://jsr.io/@cliffy/command) - CLI framework +- [@cliffy/prompt](https://jsr.io/@cliffy/prompt) - Interactive prompts +- [@cliffy/ansi](https://jsr.io/@cliffy/ansi) - ANSI colors and formatting +- [@cliffy/table](https://jsr.io/@cliffy/table) - Table rendering +- [@std/path](https://jsr.io/@std/path) - Path utilities +- [@std/fs](https://jsr.io/@std/fs) - File system utilities + +## License + +MIT + +## Contributing + +Feel free to open issues or submit pull requests! + +--- + +**73** (Best regards in morse code) šŸ“” diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..3f2407e --- /dev/null +++ b/deno.json @@ -0,0 +1,18 @@ +{ + "tasks": { + "start": "deno run --allow-read --allow-write --allow-env main.ts", + "dev": "deno run --watch --allow-read --allow-write --allow-env main.ts" + }, + "imports": { + "@std/path": "jsr:@std/path@^1.0.0", + "@std/fs": "jsr:@std/fs@^1.0.0", + "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.7", + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.7", + "@cliffy/ansi": "jsr:@cliffy/ansi@^1.0.0-rc.7", + "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.7" + }, + "compilerOptions": { + "lib": ["deno.window"], + "strict": true + } +} diff --git a/game.ts b/game.ts new file mode 100644 index 0000000..b1c2e58 --- /dev/null +++ b/game.ts @@ -0,0 +1,158 @@ +// Game logic for morse code practice + +import { textToMorse, compareMorse, normalizeMorse } from "./morse.ts"; + +export type GameMode = 'letters' | 'numbers' | 'words' | 'phrases'; + +export interface GameConfig { + mode: GameMode; + rounds: number; + timePerRound: number; // seconds +} + +export interface RoundResult { + challenge: string; + expectedMorse: string; + userInput: string; + correct: boolean; + timeSpent: number; // milliseconds +} + +export interface GameSession { + config: GameConfig; + currentRound: number; + results: RoundResult[]; + currentStreak: number; + bestStreak: number; +} + +// Word banks for different difficulty levels +const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); +const NUMBERS = '0123456789'.split(''); +const WORDS = [ + 'HELLO', 'WORLD', 'CODE', 'TIME', 'GAME', 'TEST', 'PLAY', + 'FAST', 'SLOW', 'HELP', 'GOOD', 'BEST', 'NICE', 'COOL', + 'MORSE', 'SIGNAL', 'RADIO', 'SEND', 'MESSAGE', 'QUICK', + 'LEARN', 'PRACTICE', 'SKILL', 'MASTER', 'EXPERT', +]; +const PHRASES = [ + 'HELLO WORLD', + 'GOOD MORNING', + 'HOW ARE YOU', + 'THANK YOU', + 'SEE YOU SOON', + 'HAVE A NICE DAY', + 'MORSE CODE', + 'QUICK BROWN FOX', + 'THE END', + 'WELL DONE', +]; + +/** + * Get a random challenge based on game mode + */ +export function getChallenge(mode: GameMode): string { + switch (mode) { + case 'letters': + return LETTERS[Math.floor(Math.random() * LETTERS.length)]; + case 'numbers': + return NUMBERS[Math.floor(Math.random() * NUMBERS.length)]; + case 'words': + return WORDS[Math.floor(Math.random() * WORDS.length)]; + case 'phrases': + return PHRASES[Math.floor(Math.random() * PHRASES.length)]; + } +} + +/** + * Create a new game session + */ +export function createGameSession(config: GameConfig): GameSession { + return { + config, + currentRound: 0, + results: [], + currentStreak: 0, + bestStreak: 0, + }; +} + +/** + * Process a round result + */ +export function processRound( + session: GameSession, + challenge: string, + userInput: string, + timeSpent: number +): RoundResult { + const expectedMorse = textToMorse(challenge); + const normalizedInput = normalizeMorse(userInput); + const correct = compareMorse(expectedMorse, normalizedInput); + + const result: RoundResult = { + challenge, + expectedMorse, + userInput: normalizedInput, + correct, + timeSpent, + }; + + session.results.push(result); + session.currentRound++; + + // Update streak + if (correct) { + session.currentStreak++; + session.bestStreak = Math.max(session.bestStreak, session.currentStreak); + } else { + session.currentStreak = 0; + } + + return result; +} + +/** + * Check if game is complete + */ +export function isGameComplete(session: GameSession): boolean { + return session.currentRound >= session.config.rounds; +} + +/** + * Get game summary statistics + */ +export function getGameSummary(session: GameSession) { + const totalRounds = session.results.length; + const correct = session.results.filter(r => r.correct).length; + const incorrect = totalRounds - correct; + const accuracy = totalRounds > 0 ? (correct / totalRounds) * 100 : 0; + const averageTime = totalRounds > 0 + ? session.results.reduce((sum, r) => sum + r.timeSpent, 0) / totalRounds + : 0; + + return { + totalRounds, + correct, + incorrect, + accuracy, + averageTime, + streak: session.bestStreak, + }; +} + +/** + * Get difficulty description for a mode + */ +export function getDifficultyDescription(mode: GameMode): string { + switch (mode) { + case 'letters': + return 'Single letters A-Z'; + case 'numbers': + return 'Single digits 0-9'; + case 'words': + return 'Common 4-6 letter words'; + case 'phrases': + return 'Short phrases'; + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..155662c --- /dev/null +++ b/main.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env + +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import { + clearScreen, + printBanner, + showMainMenu, + selectGameMode, + configureGame, + playRound, + showGameResults, + showStats, + showReference, + confirmAction, +} from "./ui.ts"; +import { createGameSession, isGameComplete } from "./game.ts"; +import { loadStats, updateStats, resetStats, getAccuracy } from "./stats.ts"; +import type { GameResult } from "./stats.ts"; + +/** + * Main application class + */ +class MorseGame { + /** + * Run the interactive menu + */ + async runInteractive(): Promise { + while (true) { + clearScreen(); + printBanner(); + + const action = await showMainMenu(); + + switch (action) { + case "play": + await this.playGame(); + break; + case "stats": + await this.viewStats(); + break; + case "reference": + await this.showReference(); + break; + case "reset": + await this.resetStats(); + break; + case "exit": + console.log(colors.cyan("\nThanks for playing! 73 (Best regards in morse)\n")); + return; + } + } + } + + /** + * Play a game + */ + async playGame(): Promise { + const mode = await selectGameMode(); + const config = await configureGame(mode); + + const session = createGameSession({ + mode, + rounds: config.rounds, + timePerRound: config.timePerRound, + }); + + // Play all rounds + for (let i = 0; i < config.rounds; i++) { + await playRound(session, i + 1); + + if (isGameComplete(session)) { + break; + } + } + + // Show results + showGameResults(session); + + // Update stats + const summary = session.results.reduce( + (acc, result) => ({ + correct: acc.correct + (result.correct ? 1 : 0), + incorrect: acc.incorrect + (result.correct ? 0 : 1), + totalTime: acc.totalTime + result.timeSpent, + }), + { correct: 0, incorrect: 0, totalTime: 0 } + ); + + const gameResult: GameResult = { + mode, + rounds: session.results.length, + correct: summary.correct, + incorrect: summary.incorrect, + averageTime: summary.totalTime / session.results.length / 1000, + streak: session.bestStreak, + }; + + await updateStats(gameResult); + + // Wait for user to continue + console.log(colors.gray("\nPress Enter to continue...")); + await new Promise((resolve) => { + const buf = new Uint8Array(1); + Deno.stdin.setRaw(true); + Deno.stdin.read(buf).then(() => { + Deno.stdin.setRaw(false); + resolve(undefined); + }); + }); + } + + /** + * View statistics + */ + async viewStats(): Promise { + const stats = await loadStats(); + showStats(stats); + + // Wait for user to continue + console.log(colors.gray("Press Enter to continue...")); + await new Promise((resolve) => { + const buf = new Uint8Array(1); + Deno.stdin.setRaw(true); + Deno.stdin.read(buf).then(() => { + Deno.stdin.setRaw(false); + resolve(undefined); + }); + }); + } + + /** + * Show reference + */ + async showReference(): Promise { + showReference(); + + // Wait for user to continue + console.log(colors.gray("Press Enter to continue...")); + await new Promise((resolve) => { + const buf = new Uint8Array(1); + Deno.stdin.setRaw(true); + Deno.stdin.read(buf).then(() => { + Deno.stdin.setRaw(false); + resolve(undefined); + }); + }); + } + + /** + * Reset statistics + */ + async resetStats(): Promise { + const confirmed = await confirmAction( + "Are you sure you want to reset all statistics? This cannot be undone." + ); + + if (confirmed) { + await resetStats(); + console.log(colors.green("\nāœ“ Statistics have been reset.\n")); + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } + + /** + * Quick play with CLI arguments + */ + async quickPlay(mode: string, rounds: number, time: number): Promise { + const validModes = ["letters", "numbers", "words", "phrases"]; + if (!validModes.includes(mode)) { + console.error(colors.red(`Invalid mode: ${mode}`)); + console.log(`Valid modes: ${validModes.join(", ")}`); + Deno.exit(1); + } + + clearScreen(); + printBanner(); + + const session = createGameSession({ + mode: mode as any, + rounds, + timePerRound: time, + }); + + // Play all rounds + for (let i = 0; i < rounds; i++) { + await playRound(session, i + 1); + } + + // Show results + showGameResults(session); + + // Update stats + const summary = session.results.reduce( + (acc, result) => ({ + correct: acc.correct + (result.correct ? 1 : 0), + incorrect: acc.incorrect + (result.correct ? 0 : 1), + totalTime: acc.totalTime + result.timeSpent, + }), + { correct: 0, incorrect: 0, totalTime: 0 } + ); + + const gameResult: GameResult = { + mode: mode as any, + rounds: session.results.length, + correct: summary.correct, + incorrect: summary.incorrect, + averageTime: summary.totalTime / session.results.length / 1000, + streak: session.bestStreak, + }; + + await updateStats(gameResult); + } + + /** + * Show quick stats + */ + async showQuickStats(): Promise { + const stats = await loadStats(); + + if (stats.totalGames === 0) { + console.log(colors.yellow("No games played yet!")); + return; + } + + console.log(colors.bold.cyan("\nšŸ“ˆ QUICK STATS\n")); + console.log(`Games Played: ${colors.bold(stats.totalGames.toString())}`); + console.log(`Total Rounds: ${colors.bold(stats.totalRounds.toString())}`); + console.log(`Accuracy: ${colors.bold(getAccuracy(stats).toFixed(1) + "%")}`); + console.log(`Best Streak: ${colors.bold(stats.bestStreak.toString())}`); + console.log(); + } +} + +// CLI Setup +const app = new MorseGame(); + +await new Command() + .name("morse-game") + .version("1.0.0") + .description("A terminal-based morse code practice game") + .action(async () => { + // Default action: run interactive mode + await app.runInteractive(); + }) + .command("play", "Start a quick game with specified settings") + .option("-m, --mode ", "Game mode (letters, numbers, words, phrases)", { + default: "letters", + }) + .option("-r, --rounds ", "Number of rounds", { default: 10 }) + .option("-t, --time ", "Seconds per round", { default: 30 }) + .action(async (options) => { + await app.quickPlay(options.mode, options.rounds, options.time); + }) + .command("stats", "Display your statistics") + .action(async () => { + await app.showQuickStats(); + }) + .command("reset", "Reset all statistics") + .action(async () => { + const confirmed = await confirmAction( + "Are you sure you want to reset all statistics? This cannot be undone." + ); + + if (confirmed) { + await resetStats(); + console.log(colors.green("\nāœ“ Statistics have been reset.\n")); + } else { + console.log(colors.yellow("\nCancelled.\n")); + } + }) + .command("reference", "Show morse code reference") + .action(() => { + showReference(); + }) + .parse(Deno.args); diff --git a/morse.ts b/morse.ts new file mode 100644 index 0000000..1cf88b3 --- /dev/null +++ b/morse.ts @@ -0,0 +1,95 @@ +// Morse code translation utilities + +export const MORSE_CODE: Record = { + 'A': '.-', + 'B': '-...', + 'C': '-.-.', + 'D': '-..', + 'E': '.', + 'F': '..-.', + 'G': '--.', + 'H': '....', + 'I': '..', + 'J': '.---', + 'K': '-.-', + 'L': '.-..', + 'M': '--', + 'N': '-.', + 'O': '---', + 'P': '.--.', + 'Q': '--.-', + 'R': '.-.', + 'S': '...', + 'T': '-', + 'U': '..-', + 'V': '...-', + 'W': '.--', + 'X': '-..-', + 'Y': '-.--', + 'Z': '--..', + '0': '-----', + '1': '.----', + '2': '..---', + '3': '...--', + '4': '....-', + '5': '.....', + '6': '-....', + '7': '--...', + '8': '---..', + '9': '----.', + ' ': '/', + '.': '.-.-.-', + ',': '--..--', + '?': '..--..', + '!': '-.-.--', + '-': '-....-', + '/': '-..-.', + '@': '.--.-.', + '(': '-.--.', + ')': '-.--.-' +}; + +// Reverse mapping for morse to text +export const MORSE_TO_TEXT: Record = Object.fromEntries( + Object.entries(MORSE_CODE).map(([key, value]) => [value, key]) +); + +/** + * Convert text to morse code + */ +export function textToMorse(text: string): string { + return text + .toUpperCase() + .split('') + .map(char => MORSE_CODE[char] || char) + .join(' '); +} + +/** + * Convert morse code to text + */ +export function morseToText(morse: string): string { + return morse + .split(' ') + .map(code => MORSE_TO_TEXT[code] || code) + .join(''); +} + +/** + * Normalize morse code input (remove extra spaces, normalize separators) + */ +export function normalizeMorse(input: string): string { + return input + .trim() + .replace(/\s+/g, ' ') + .replace(/\//g, ' / '); +} + +/** + * Compare two morse code strings with tolerance for spacing differences + */ +export function compareMorse(expected: string, actual: string): boolean { + const normalizedExpected = normalizeMorse(expected); + const normalizedActual = normalizeMorse(actual); + return normalizedExpected === normalizedActual; +} diff --git a/stats.ts b/stats.ts new file mode 100644 index 0000000..2c7ebad --- /dev/null +++ b/stats.ts @@ -0,0 +1,126 @@ +// User statistics tracking and persistence + +import { join } from "@std/path"; +import { ensureDir } from "@std/fs"; + +export interface GameStats { + totalGames: number; + totalRounds: number; + correctAnswers: number; + incorrectAnswers: number; + averageTimePerRound: number; + bestStreak: number; + modeStats: { + letters: ModeStats; + numbers: ModeStats; + words: ModeStats; + phrases: ModeStats; + }; + lastPlayed?: string; +} + +export interface ModeStats { + gamesPlayed: number; + correctAnswers: number; + incorrectAnswers: number; + averageAccuracy: number; +} + +export interface GameResult { + mode: 'letters' | 'numbers' | 'words' | 'phrases'; + rounds: number; + correct: number; + incorrect: number; + averageTime: number; + streak: number; +} + +const DEFAULT_STATS: GameStats = { + totalGames: 0, + totalRounds: 0, + correctAnswers: 0, + incorrectAnswers: 0, + averageTimePerRound: 0, + bestStreak: 0, + modeStats: { + letters: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 }, + numbers: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 }, + words: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 }, + phrases: { gamesPlayed: 0, correctAnswers: 0, incorrectAnswers: 0, averageAccuracy: 0 }, + }, +}; + +function getStatsPath(): string { + const homeDir = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "."; + return join(homeDir, ".morse-game", "stats.json"); +} + +/** + * Load stats from disk + */ +export async function loadStats(): Promise { + try { + const statsPath = getStatsPath(); + const content = await Deno.readTextFile(statsPath); + return JSON.parse(content); + } catch { + return { ...DEFAULT_STATS }; + } +} + +/** + * Save stats to disk + */ +export async function saveStats(stats: GameStats): Promise { + const statsPath = getStatsPath(); + const dir = join(Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || ".", ".morse-game"); + + await ensureDir(dir); + await Deno.writeTextFile(statsPath, JSON.stringify(stats, null, 2)); +} + +/** + * Update stats with a new game result + */ +export async function updateStats(result: GameResult): Promise { + const stats = await loadStats(); + + // Update overall stats + stats.totalGames++; + stats.totalRounds += result.rounds; + stats.correctAnswers += result.correct; + stats.incorrectAnswers += result.incorrect; + stats.averageTimePerRound = + (stats.averageTimePerRound * (stats.totalRounds - result.rounds) + + result.averageTime * result.rounds) / stats.totalRounds; + stats.bestStreak = Math.max(stats.bestStreak, result.streak); + stats.lastPlayed = new Date().toISOString(); + + // Update mode-specific stats + const modeStats = stats.modeStats[result.mode]; + modeStats.gamesPlayed++; + modeStats.correctAnswers += result.correct; + modeStats.incorrectAnswers += result.incorrect; + const totalAnswers = modeStats.correctAnswers + modeStats.incorrectAnswers; + modeStats.averageAccuracy = totalAnswers > 0 + ? (modeStats.correctAnswers / totalAnswers) * 100 + : 0; + + await saveStats(stats); + return stats; +} + +/** + * Reset all stats + */ +export async function resetStats(): Promise { + await saveStats({ ...DEFAULT_STATS }); +} + +/** + * Get formatted accuracy percentage + */ +export function getAccuracy(stats: GameStats): number { + const total = stats.correctAnswers + stats.incorrectAnswers; + return total > 0 ? (stats.correctAnswers / total) * 100 : 0; +} diff --git a/ui.ts b/ui.ts new file mode 100644 index 0000000..027f057 --- /dev/null +++ b/ui.ts @@ -0,0 +1,348 @@ +// UI utilities and game interface + +import { colors } from "@cliffy/ansi/colors"; +import { Input } from "@cliffy/prompt/input"; +import { Select } from "@cliffy/prompt/select"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Table } from "@cliffy/table"; +import { textToMorse } from "./morse.ts"; +import type { GameStats } from "./stats.ts"; +import { getAccuracy } from "./stats.ts"; +import type { GameSession, RoundResult, GameMode } from "./game.ts"; +import { + createGameSession, + getChallenge, + processRound, + isGameComplete, + getGameSummary, + getDifficultyDescription, +} from "./game.ts"; + +/** + * Clear the terminal screen + */ +export function clearScreen(): void { + console.clear(); +} + +/** + * Print a banner/header + */ +export function printBanner(): void { + console.log(colors.bold.cyan(` + ╔═══════════════════════════════════════╗ + ā•‘ MORSE CODE PRACTICE GAME ā•‘ + ā•‘ .... . .-.. .-.. --- ā•‘ + ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + `)); +} + +/** + * Show main menu and get user selection + */ +export async function showMainMenu(): Promise { + const action = await Select.prompt({ + message: "What would you like to do?", + options: [ + { name: "Start New Game", value: "play" }, + { name: "View Statistics", value: "stats" }, + { name: "Morse Code Reference", value: "reference" }, + { name: "Reset Statistics", value: "reset" }, + { name: "Exit", value: "exit" }, + ], + }); + + return action; +} + +/** + * Select game mode + */ +export async function selectGameMode(): Promise { + const mode = await Select.prompt({ + message: "Select difficulty mode:", + options: [ + { + name: `${colors.green("Easy")} - Letters (A-Z)`, + value: "letters" as GameMode + }, + { + name: `${colors.yellow("Medium")} - Numbers (0-9)`, + value: "numbers" as GameMode + }, + { + name: `${colors.magenta("Hard")} - Words`, + value: "words" as GameMode + }, + { + name: `${colors.red("Expert")} - Phrases`, + value: "phrases" as GameMode + }, + ], + }); + + return mode; +} + +/** + * Configure game settings + */ +export async function configureGame(mode: GameMode): Promise<{ rounds: number; timePerRound: number }> { + const roundsInput = await Input.prompt({ + message: "How many rounds? (5-50)", + default: "10", + validate: (value) => { + const num = parseInt(value); + if (isNaN(num) || num < 5 || num > 50) { + return "Please enter a number between 5 and 50"; + } + return true; + }, + }); + + const timeInput = await Input.prompt({ + message: "Seconds per round? (10-60)", + default: "30", + validate: (value) => { + const num = parseInt(value); + if (isNaN(num) || num < 10 || num > 60) { + return "Please enter a number between 10 and 60"; + } + return true; + }, + }); + + return { + rounds: parseInt(roundsInput), + timePerRound: parseInt(timeInput), + }; +} + +/** + * Play a single round of the game + */ +export async function playRound( + session: GameSession, + roundNumber: number +): Promise { + const challenge = getChallenge(session.config.mode); + const startTime = Date.now(); + + clearScreen(); + printBanner(); + console.log(colors.bold(`\nRound ${roundNumber} of ${session.config.rounds}`)); + console.log(colors.gray(`Time limit: ${session.config.timePerRound}s | Current streak: ${session.currentStreak}\n`)); + + console.log(colors.bold.white(`Translate to Morse code: ${colors.yellow(challenge)}\n`)); + console.log(colors.dim("Tip: Use dots (.) and dashes (-), separate letters with spaces")); + console.log(colors.dim(" Use / for word spaces\n")); + + // Create a promise that rejects after the time limit + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Time's up!")); + }, session.config.timePerRound * 1000); + }); + + // Get user input with timeout + let userInput = ""; + try { + const inputPromise = Input.prompt({ + message: "Your answer:", + minLength: 0, + }); + + userInput = await Promise.race([inputPromise, timeoutPromise]); + } catch (error) { + if (error instanceof Error && error.message === "Time's up!") { + console.log(colors.red("\nā° Time's up!")); + userInput = ""; // Empty input for timeout + } else { + throw error; + } + } + + const timeSpent = Date.now() - startTime; + const result = processRound(session, challenge, userInput, timeSpent); + + // Show result + console.log(); + if (result.correct) { + console.log(colors.bold.green("āœ“ Correct!")); + } else { + console.log(colors.bold.red("āœ— Incorrect")); + console.log(colors.gray(`Expected: ${result.expectedMorse}`)); + console.log(colors.gray(`You entered: ${result.userInput || "(nothing)"}`)); + } + + // Wait for user to continue + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return result; +} + +/** + * Show game results + */ +export function showGameResults(session: GameSession): void { + clearScreen(); + printBanner(); + + const summary = getGameSummary(session); + + console.log(colors.bold.cyan("\nšŸŽ® GAME COMPLETE!\n")); + + const table = new Table() + .header([colors.bold("Metric"), colors.bold("Value")]) + .body([ + ["Total Rounds", summary.totalRounds.toString()], + ["Correct", colors.green(summary.correct.toString())], + ["Incorrect", colors.red(summary.incorrect.toString())], + ["Accuracy", `${summary.accuracy.toFixed(1)}%`], + ["Average Time", `${(summary.averageTime / 1000).toFixed(1)}s`], + ["Best Streak", colors.yellow(summary.streak.toString())], + ]) + .border(true) + .padding(1); + + table.render(); + + // Show detailed results + console.log(colors.bold("\nšŸ“Š Round Details:\n")); + + const detailsTable = new Table() + .header([ + colors.bold("#"), + colors.bold("Challenge"), + colors.bold("Expected"), + colors.bold("Your Answer"), + colors.bold("Result"), + colors.bold("Time"), + ]); + + session.results.forEach((result, index) => { + detailsTable.push([ + (index + 1).toString(), + result.challenge, + result.expectedMorse, + result.userInput || "-", + result.correct ? colors.green("āœ“") : colors.red("āœ—"), + `${(result.timeSpent / 1000).toFixed(1)}s`, + ]); + }); + + detailsTable.border(true).render(); +} + +/** + * Show statistics + */ +export function showStats(stats: GameStats): void { + clearScreen(); + printBanner(); + + console.log(colors.bold.cyan("\nšŸ“ˆ YOUR STATISTICS\n")); + + if (stats.totalGames === 0) { + console.log(colors.yellow("No games played yet! Start playing to see your stats.\n")); + return; + } + + // Overall stats + const overallTable = new Table() + .header([colors.bold("Overall Stats"), colors.bold("Value")]) + .body([ + ["Total Games", stats.totalGames.toString()], + ["Total Rounds", stats.totalRounds.toString()], + ["Correct Answers", colors.green(stats.correctAnswers.toString())], + ["Incorrect Answers", colors.red(stats.incorrectAnswers.toString())], + ["Overall Accuracy", `${getAccuracy(stats).toFixed(1)}%`], + ["Average Time/Round", `${stats.averageTimePerRound.toFixed(1)}s`], + ["Best Streak", colors.yellow(stats.bestStreak.toString())], + ["Last Played", stats.lastPlayed ? new Date(stats.lastPlayed).toLocaleDateString() : "Never"], + ]) + .border(true) + .padding(1); + + overallTable.render(); + + // Mode-specific stats + console.log(colors.bold("\nšŸ“Š Stats by Mode:\n")); + + const modeTable = new Table() + .header([ + colors.bold("Mode"), + colors.bold("Games"), + colors.bold("Correct"), + colors.bold("Incorrect"), + colors.bold("Accuracy"), + ]); + + Object.entries(stats.modeStats).forEach(([mode, modeStats]) => { + if (modeStats.gamesPlayed > 0) { + modeTable.push([ + mode.charAt(0).toUpperCase() + mode.slice(1), + modeStats.gamesPlayed.toString(), + colors.green(modeStats.correctAnswers.toString()), + colors.red(modeStats.incorrectAnswers.toString()), + `${modeStats.averageAccuracy.toFixed(1)}%`, + ]); + } + }); + + modeTable.border(true).render(); + console.log(); +} + +/** + * Show morse code reference + */ +export function showReference(): void { + clearScreen(); + printBanner(); + + console.log(colors.bold.cyan("\nšŸ“– MORSE CODE REFERENCE\n")); + + console.log(colors.bold("Letters:")); + const letterTable = new Table(); + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + + // Display in 3 columns + for (let i = 0; i < letters.length; i += 3) { + const row = []; + for (let j = 0; j < 3 && i + j < letters.length; j++) { + const letter = letters[i + j]; + row.push(`${colors.yellow(letter)} = ${textToMorse(letter)}`); + } + letterTable.push(row); + } + letterTable.render(); + + console.log(colors.bold("\nNumbers:")); + const numberTable = new Table(); + const numbers = "0123456789".split(""); + + for (let i = 0; i < numbers.length; i += 5) { + const row = []; + for (let j = 0; j < 5 && i + j < numbers.length; j++) { + const num = numbers[i + j]; + row.push(`${colors.yellow(num)} = ${textToMorse(num)}`); + } + numberTable.push(row); + } + numberTable.render(); + + console.log(colors.bold("\nSpecial:")); + console.log(`${colors.yellow("Space")} = /`); + console.log(`${colors.yellow(".")} = .-.-.-`); + console.log(`${colors.yellow(",")} = --..--`); + console.log(`${colors.yellow("?")} = ..--..`); + console.log(); +} + +/** + * Confirm action + */ +export async function confirmAction(message: string): Promise { + return await Confirm.prompt({ message, default: false }); +}