diff --git a/deno.json b/deno.json index 9fcf2d4..2ac98ed 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "tasks": { - "start": "deno run --allow-read --allow-write --allow-env main.ts", - "dev": "deno run --watch --allow-read --allow-write --allow-env main.ts" + "start": "deno run --allow-read --allow-write main.ts", + "dev": "deno run --watch --allow-read --allow-write main.ts" }, "imports": { "@std/path": "jsr:@std/path@^1.0.0", diff --git a/game.ts b/game.ts index 779fb20..5fdddb5 100644 --- a/game.ts +++ b/game.ts @@ -33,6 +33,11 @@ export interface GameSession { bestStreak: number; } +// Constants for word filtering +const MIN_WORD_LENGTH = 3; +const MAX_WORD_LENGTH = 8; +const WORD_POOL_SIZE = 500; + // Character sets for different difficulty levels const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); const NUMBERS = "0123456789".split(""); @@ -40,18 +45,32 @@ const PUNCTUATION = ".,?!-/()@".split(""); const ALPHANUMERIC = [...LETTERS, ...NUMBERS]; const FULL_SET = [...LETTERS, ...NUMBERS, ...PUNCTUATION]; +/** + * Fisher-Yates shuffle algorithm - O(n) complexity + */ +function shuffleArray(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + // Filter words for morse code practice (3-8 letters, common words) const ALL_WORDS = wordsArray .filter((word: string) => { const upper = word.toUpperCase(); return ( - word.length >= 3 && word.length <= 8 && /^[A-Z]+$/.test(upper) // Only letters, no special chars + word.length >= MIN_WORD_LENGTH && + word.length <= MAX_WORD_LENGTH && + /^[A-Z]+$/.test(upper) // Only letters, no special chars ); }) .map((word: string) => word.toUpperCase()); -// Randomly select a subset for variety -const WORDS = ALL_WORDS.sort(() => Math.random() - 0.5).slice(0, 500); +// Randomly select a subset for variety using Fisher-Yates +const WORDS = shuffleArray(ALL_WORDS).slice(0, WORD_POOL_SIZE); /** * Generate a random phrase from word combinations diff --git a/main.ts b/main.ts index c708fbd..4e5d093 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env +#!/usr/bin/env -S deno run --allow-read --allow-write import { Command } from "@cliffy/command"; import { colors } from "@cliffy/ansi/colors"; @@ -104,17 +104,24 @@ class MorseGame { streak: session.bestStreak, }; - await updateStats(gameResult); + try { + await updateStats(gameResult); + } catch (error) { + console.log(colors.red("\n⚠ Warning: Failed to save statistics")); + if (error instanceof Error) { + console.log(colors.gray(error.message)); + } + } // 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); - }); + Deno.stdin.read(buf) + .then(() => resolve(undefined)) + .catch(() => resolve(undefined)) + .finally(() => Deno.stdin.setRaw(false)); }); } @@ -130,10 +137,10 @@ class MorseGame { await new Promise((resolve) => { const buf = new Uint8Array(1); Deno.stdin.setRaw(true); - Deno.stdin.read(buf).then(() => { - Deno.stdin.setRaw(false); - resolve(undefined); - }); + Deno.stdin.read(buf) + .then(() => resolve(undefined)) + .catch(() => resolve(undefined)) + .finally(() => Deno.stdin.setRaw(false)); }); } @@ -148,10 +155,10 @@ class MorseGame { await new Promise((resolve) => { const buf = new Uint8Array(1); Deno.stdin.setRaw(true); - Deno.stdin.read(buf).then(() => { - Deno.stdin.setRaw(false); - resolve(undefined); - }); + Deno.stdin.read(buf) + .then(() => resolve(undefined)) + .catch(() => resolve(undefined)) + .finally(() => Deno.stdin.setRaw(false)); }); } @@ -171,8 +178,15 @@ class MorseGame { ); if (confirmed) { - await resetStats(); - console.log(colors.green("\n✓ Statistics have been reset.\n")); + try { + await resetStats(); + console.log(colors.green("\n✓ Statistics have been reset.\n")); + } catch (error) { + console.log(colors.red("\n⚠ Error: Failed to reset statistics")); + if (error instanceof Error) { + console.log(colors.gray(error.message)); + } + } await new Promise((resolve) => setTimeout(resolve, 1500)); } } @@ -224,7 +238,14 @@ class MorseGame { streak: session.bestStreak, }; - await updateStats(gameResult); + try { + await updateStats(gameResult); + } catch (error) { + console.log(colors.red("\n⚠ Warning: Failed to save statistics")); + if (error instanceof Error) { + console.log(colors.gray(error.message)); + } + } } /** @@ -284,8 +305,15 @@ await new Command() ); if (confirmed) { - await resetStats(); - console.log(colors.green("\n✓ Statistics have been reset.\n")); + try { + await resetStats(); + console.log(colors.green("\n✓ Statistics have been reset.\n")); + } catch (error) { + console.log(colors.red("\n⚠ Error: Failed to reset statistics")); + if (error instanceof Error) { + console.log(colors.gray(error.message)); + } + } } else { console.log(colors.yellow("\nCancelled.\n")); } diff --git a/morse.ts b/morse.ts index 1cf88b3..e643af1 100644 --- a/morse.ts +++ b/morse.ts @@ -54,24 +54,51 @@ export const MORSE_TO_TEXT: Record = Object.fromEntries( Object.entries(MORSE_CODE).map(([key, value]) => [value, key]) ); +/** + * Check if a character is supported in Morse code + */ +export function isCharacterSupported(char: string): boolean { + return char.toUpperCase() in MORSE_CODE; +} + +/** + * Validate if Morse code string is properly formatted + */ +export function isValidMorse(input: string): boolean { + // Valid morse contains only dots, dashes, spaces, and forward slashes + return /^[.\-\s/]*$/.test(input); +} + /** * Convert text to morse code + * Unsupported characters are silently skipped */ export function textToMorse(text: string): string { return text .toUpperCase() .split('') - .map(char => MORSE_CODE[char] || char) + .map(char => MORSE_CODE[char]) + .filter(code => code !== undefined) .join(' '); } /** * Convert morse code to text + * Invalid morse codes are replaced with '?' */ export function morseToText(morse: string): string { + if (!isValidMorse(morse)) { + return ""; + } + return morse .split(' ') - .map(code => MORSE_TO_TEXT[code] || code) + .map(code => { + // Handle empty strings (multiple spaces) + if (code === '') return ''; + // Return the decoded character or '?' for unknown codes + return MORSE_TO_TEXT[code] || '?'; + }) .join(''); } diff --git a/stats.ts b/stats.ts index 3d32f32..1a4311c 100644 --- a/stats.ts +++ b/stats.ts @@ -98,11 +98,17 @@ export async function loadStats(): Promise { * Save stats to JSON file */ export async function saveStats(stats: GameStats): Promise { - const statsPath = getStatsPath(); - const dataDir = join(Deno.cwd(), "data"); + try { + const statsPath = getStatsPath(); + const dataDir = join(Deno.cwd(), "data"); - await ensureDir(dataDir); - await Deno.writeTextFile(statsPath, JSON.stringify(stats, null, 2)); + await ensureDir(dataDir); + await Deno.writeTextFile(statsPath, JSON.stringify(stats, null, 2)); + } catch (error) { + throw new Error( + `Failed to save statistics: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } } /** diff --git a/ui.ts b/ui.ts index d7f886e..f0d5eff 100644 --- a/ui.ts +++ b/ui.ts @@ -1,6 +1,13 @@ // UI utilities and game interface import { colors } from "@cliffy/ansi/colors"; + +// UI Constants +const WAIT_TIMES = { + DEFAULT: 2000, // 2 seconds for default modes + WORDS: 6000, // 6 seconds for words mode + PHRASES: 8000, // 8 seconds for phrases mode +} as const; import { Confirm } from "@cliffy/prompt/confirm"; import { Input } from "@cliffy/prompt/input"; import { Select } from "@cliffy/prompt/select"; @@ -100,10 +107,10 @@ export async function showTranslator(): Promise { await new Promise((resolve) => { const buf = new Uint8Array(1); Deno.stdin.setRaw(true); - Deno.stdin.read(buf).then(() => { - Deno.stdin.setRaw(false); - resolve(undefined); - }); + Deno.stdin.read(buf) + .then(() => resolve(undefined)) + .catch(() => resolve(undefined)) + .finally(() => Deno.stdin.setRaw(false)); }); } @@ -248,23 +255,35 @@ export async function playRound( ); 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!")); - }, timeLimit * 1000); - }); - // Get user input with timeout let userInput = ""; + let timeoutId: number | undefined; + try { + // Create a promise that rejects after the time limit + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("Time's up!")); + }, timeLimit * 1000); + }); + const inputPromise = Input.prompt({ message: "Your answer:", minLength: 0, }); userInput = await Promise.race([inputPromise, timeoutPromise]); + + // Clear timeout if input was received first + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } catch (error) { + // Clear timeout on error + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + if (error instanceof Error && error.message === "Time's up!") { console.log(colors.red("\n⏰ Time's up!")); userInput = ""; // Empty input for timeout @@ -299,11 +318,11 @@ export async function playRound( } // Longer wait time for words and phrases - let waitTime = 2000; // Default: 2 seconds + let waitTime: number = WAIT_TIMES.DEFAULT; if (session.config.mode === "words") { - waitTime = 6000; // 6 seconds for words + waitTime = WAIT_TIMES.WORDS; } else if (session.config.mode === "phrases") { - waitTime = 8000; // 8 seconds for phrases + waitTime = WAIT_TIMES.PHRASES; } // Wait for user to continue