From 3f9871276605e2cfd7953b1e8521dc14dcb88ace Mon Sep 17 00:00:00 2001 From: Irinel Sarbu Date: Fri, 21 Nov 2025 13:03:06 +0100 Subject: [PATCH] first commit --- .gitignore | 16 +++ README.md | 169 ++++++++++++++++++++++++++ deno.json | 18 +++ game.ts | 158 ++++++++++++++++++++++++ main.ts | 276 ++++++++++++++++++++++++++++++++++++++++++ morse.ts | 95 +++++++++++++++ stats.ts | 126 +++++++++++++++++++ ui.ts | 348 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1206 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deno.json create mode 100644 game.ts create mode 100644 main.ts create mode 100644 morse.ts create mode 100644 stats.ts create mode 100644 ui.ts 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 }); +}