From 8d60c7f93407988ee0232ea90980028f299cb0f3 Mon Sep 17 00:00:00 2001 From: srdusr Date: Fri, 26 Sep 2025 13:39:28 +0200 Subject: Initial Commit --- crates/core/Cargo.toml | 52 +++ crates/core/src/app.rs | 275 +++++++++++++++ crates/core/src/config.rs | 42 +++ crates/core/src/game.rs | 632 +++++++++++++++++++++++++++++++++++ crates/core/src/input.rs | 182 ++++++++++ crates/core/src/lib.rs | 23 ++ crates/core/src/multiplayer.rs | 132 ++++++++ crates/core/src/stats.rs | 242 ++++++++++++++ crates/core/src/tests/game_tests.rs | 143 ++++++++ crates/core/src/tests/mod.rs | 3 + crates/core/src/tests/stats_tests.rs | 125 +++++++ crates/core/src/tests/text_tests.rs | 83 +++++ crates/core/src/text.rs | 72 ++++ crates/core/src/theme.rs | 24 ++ crates/core/src/types.rs | 148 ++++++++ crates/core/src/ui.rs | 187 +++++++++++ crates/core/src/wasm.rs | 98 ++++++ crates/tui/Cargo.toml | 32 ++ crates/tui/src/main.rs | 90 +++++ crates/wasm/Cargo.toml | 26 ++ crates/wasm/src/lib.rs | 213 ++++++++++++ 21 files changed, 2824 insertions(+) create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/app.rs create mode 100644 crates/core/src/config.rs create mode 100644 crates/core/src/game.rs create mode 100644 crates/core/src/input.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/multiplayer.rs create mode 100644 crates/core/src/stats.rs create mode 100644 crates/core/src/tests/game_tests.rs create mode 100644 crates/core/src/tests/mod.rs create mode 100644 crates/core/src/tests/stats_tests.rs create mode 100644 crates/core/src/tests/text_tests.rs create mode 100644 crates/core/src/text.rs create mode 100644 crates/core/src/theme.rs create mode 100644 crates/core/src/types.rs create mode 100644 crates/core/src/ui.rs create mode 100644 crates/core/src/wasm.rs create mode 100644 crates/tui/Cargo.toml create mode 100644 crates/tui/src/main.rs create mode 100644 crates/wasm/Cargo.toml create mode 100644 crates/wasm/src/lib.rs (limited to 'crates') diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..7da2b71 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "typerpunk-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "typerpunk_core" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +default = ["full", "serde", "wasm"] +full = ["dirs", "tui"] +wasm = ["getrandom/js", "dep:wasm-bindgen", "dep:js-sys", "dep:web-sys", "dep:wasm-bindgen-futures", "serde"] +tui = ["dep:crossterm", "dep:ratatui"] +web = ["dep:wasm-bindgen", "dep:web-sys"] +multiplayer = ["dep:tokio-tungstenite", "dep:futures-util", "dep:tokio"] +serde = ["dep:serde", "dep:serde_json"] + +[dependencies] +# Core dependencies +tokio = { workspace = true, optional = true, features = ["full"] } +serde = { workspace = true, features = ["derive"], optional = true } +serde_json = { workspace = true, optional = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +config = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } +rand = { workspace = true } +getrandom = { version = "0.2", optional = true } + +# Optional TUI dependencies +crossterm = { version = "0.27", optional = true } +ratatui = { version = "0.24", optional = true } + +# Optional Web dependencies +wasm-bindgen = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true } +js-sys = { workspace = true, optional = true } + +# Optional Multiplayer dependencies +tokio-tungstenite = { version = "0.20", optional = true } +futures-util = { version = "0.3", optional = true } + +# WASM dependencies +wasm-bindgen-futures = { workspace = true, optional = true } +serde-wasm-bindgen = "0.6" + +[dev-dependencies] +criterion = "0.5" +mockall = "0.12" +proptest = "1.3" \ No newline at end of file diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs new file mode 100644 index 0000000..b230bc3 --- /dev/null +++ b/crates/core/src/app.rs @@ -0,0 +1,275 @@ +use rand::Rng; +use crossterm::event::KeyEvent; +use crate::{ + config::Config, + stats::Stats, + text::Text, +}; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq)] +pub enum State { + MainMenu, + TypingGame, + EndScreen, +} + +pub struct App { + pub config: Config, + pub texts: Vec, + pub categories: Vec, + pub selected_category: Option, // None = Random + pub stats: Stats, + pub input: String, + pub current_text_index: usize, + pub should_exit: bool, + pub state: State, +} + +impl App { + pub fn new() -> Result> { + let config = Config::new(); + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + // texts.json is stored at repository root; this file is at crates/core/src/app.rs + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + language: "en".to_string(), + category: t.category, + }) + .collect(); + let stats = Stats::new(); + let input = String::new(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + let should_exit = false; + let state = State::MainMenu; + + Ok(App { + config, + texts, + categories, + selected_category: None, + stats, + input, + current_text_index, + should_exit, + state, + }) + } + + pub fn new_with_config(config: Config) -> Result> { + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + language: "en".to_string(), + category: t.category, + }) + .collect(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + Ok(Self { + state: State::MainMenu, + should_exit: false, + input: String::new(), + texts, + categories, + selected_category: None, + current_text_index, + stats: Stats::new(), + config, + }) + } + + pub fn reset(&mut self) { + self.input.clear(); + self.stats.reset(); + self.current_text_index = self.pick_random_index(); + } + + fn pick_random_index(&self) -> usize { + if self.texts.is_empty() { return 0; } + let pool: Vec = match &self.selected_category { + Some(cat) => self.texts.iter().enumerate().filter(|(_, t)| &t.category == cat).map(|(i, _)| i).collect(), + None => (0..self.texts.len()).collect(), + }; + if pool.is_empty() { return 0; } + let idx = rand::thread_rng().gen_range(0..pool.len()); + pool[idx] + } + + pub fn handle_input(&mut self, key: KeyEvent) { + match self.state { + State::MainMenu => { + match key.code { + crossterm::event::KeyCode::Enter => { + self.state = State::TypingGame; + self.reset(); + } + crossterm::event::KeyCode::Left => { + // cycle category backwards (None -> last) + if self.categories.is_empty() { + self.selected_category = None; + } else { + match &self.selected_category { + None => self.selected_category = Some(self.categories.last().unwrap().clone()), + Some(cur) => { + let pos = self.categories.iter().position(|c| c == cur).unwrap_or(0); + if pos == 0 { self.selected_category = None; } else { self.selected_category = Some(self.categories[pos-1].clone()); } + } + } + } + } + crossterm::event::KeyCode::Right => { + // cycle category forwards (None -> first) + if self.categories.is_empty() { + self.selected_category = None; + } else { + match &self.selected_category { + None => self.selected_category = Some(self.categories[0].clone()), + Some(cur) => { + let pos = self.categories.iter().position(|c| c == cur).unwrap_or(0); + if pos + 1 >= self.categories.len() { self.selected_category = None; } else { self.selected_category = Some(self.categories[pos+1].clone()); } + } + } + } + } + crossterm::event::KeyCode::Esc => { + self.should_exit = true; + } + _ => {} + } + } + State::TypingGame => { + match key.code { + crossterm::event::KeyCode::Char(c) => { + if !self.stats.is_running() { + self.stats.start(); + } + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + self.input.pop(); + self.update_stats(); + } + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::EndScreen => { + match key.code { + crossterm::event::KeyCode::Enter => { + self.state = State::TypingGame; + self.reset(); + } + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + } + + // Check if the current text is finished + if self.state == State::TypingGame && self.is_finished() { + self.state = State::EndScreen; + self.stats.stop(); + } + } + + pub fn update_stats(&mut self) { + if self.state == State::TypingGame { + let current_text = &self.texts[self.current_text_index].content; + self.stats.update(&self.input, current_text); + } + } + + pub fn is_finished(&self) -> bool { + self.input.trim() == self.texts[self.current_text_index].content.trim() + } + + pub fn current_text(&self) -> &Text { + &self.texts[self.current_text_index] + } + + pub fn get_input(&self) -> &str { + self.input.as_str() + } + + pub fn handle_backspace(&mut self) { + if self.state == State::TypingGame && !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + + pub fn handle_enter(&mut self) { + match self.state { + State::MainMenu => { + self.state = State::TypingGame; + self.reset(); + } + State::EndScreen => { + self.state = State::TypingGame; + self.reset(); + } + _ => {} + } + } + + pub fn handle_escape(&mut self) { + match self.state { + State::TypingGame => { + self.state = State::MainMenu; + self.reset(); + } + State::EndScreen => { + self.state = State::MainMenu; + self.reset(); + } + State::MainMenu => { + self.should_exit = true; + } + } + } + + pub fn get_progress(&self) -> f64 { + if self.input.is_empty() { + 0.0 + } else { + let total_chars = self.current_text().content.chars().count(); + let current_chars = self.input.chars().count(); + (current_chars as f64 / total_chars as f64) * 100.0 + } + } + + pub fn update(&mut self) { + if self.state == State::TypingGame { + self.update_stats(); + } + } +} \ No newline at end of file diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..3072438 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + pub mode: String, + pub time: u64, + pub words: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "dark".to_string(), + mode: "time".to_string(), + time: 60, + words: 50, + } + } +} + +impl Config { + pub fn new() -> Self { + Self::default() + } + + pub fn get_theme(&self) -> String { + self.theme.clone() + } + + pub fn get_mode(&self) -> String { + self.mode.clone() + } + + pub fn get_time(&self) -> u64 { + self.time + } + + pub fn get_words(&self) -> usize { + self.words + } +} diff --git a/crates/core/src/game.rs b/crates/core/src/game.rs new file mode 100644 index 0000000..cb57e5c --- /dev/null +++ b/crates/core/src/game.rs @@ -0,0 +1,632 @@ +use std::time::Instant; +use serde::{Deserialize, Serialize}; +use crate::types::Theme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option, + pub word_count: Option, + pub custom_text: Option, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::Normal, + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 50, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option; + fn get_word_count(&self) -> Option; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option { + self.time_limit + } + + fn get_word_count(&self) -> Option { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Game { + text: String, + input: String, + #[serde(skip)] + start_time: Option, + is_started: bool, + is_finished: bool, + error_positions: Vec, + current_streak: u32, + best_streak: u32, + theme: Theme, + correct_positions: Vec, + pub total_mistakes: u32, + total_errors_made: u32, + last_input_length: usize, + total_characters_typed: u32, + total_correct_characters: u32, +} + +impl Game { + pub fn new() -> Self { + Self { + text: String::new(), + input: String::new(), + start_time: None, + is_started: false, + is_finished: false, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + theme: Theme::default(), + correct_positions: Vec::new(), + total_mistakes: 0, + total_errors_made: 0, + last_input_length: 0, + total_characters_typed: 0, + total_correct_characters: 0, + } + } + + pub fn set_text(&mut self, text: String) { + self.text = text; + self.reset(); + } + + pub fn get_text(&self) -> String { + self.text.clone() + } + + pub fn get_input(&self) -> String { + self.input.clone() + } + + pub fn start(&mut self) { + self.is_started = true; + self.start_time = Some(Instant::now()); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + println!("DEBUG: handle_input called with input='{}'", input); + if self.is_finished() { + return Ok(()); + } + + // Validate UTF-8 only + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err("Invalid UTF-8 input".to_string()), + }; + + // Update input + self.input = input_str; + + // Update game state + self.update_game_state(); + Ok(()) + } + + pub fn is_finished(&self) -> bool { + self.is_finished + } + + pub fn get_error_positions(&self) -> Vec { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> u32 { + self.current_streak + } + + pub fn get_best_streak(&self) -> u32 { + self.best_streak + } + + pub fn get_theme(&self) -> Theme { + self.theme + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + pub fn get_wpm(&self) -> f64 { + if let Some(start_time) = self.start_time { + let elapsed = start_time.elapsed().as_secs_f64(); + if elapsed > 0.0 { + let words = self.input.len() as f64 / 5.0; + return (words * 60.0) / elapsed; + } + } + 0.0 + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + let accuracy = self.get_accuracy(); + let mistakes = self.get_total_mistakes(); + Ok((accuracy, mistakes)) + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + let (accuracy, mistakes) = self.get_stats()?; + Ok((self.input.clone(), accuracy, mistakes)) + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_characters_typed == 0 { + return 100.0; + } + + let accuracy = (self.total_correct_characters as f64 / self.total_characters_typed as f64) * 100.0; + accuracy.max(0.0).min(100.0) + } + + pub fn get_total_mistakes(&self) -> u32 { + // Return total errors made, not current mistakes + self.total_errors_made + } + + pub fn get_time_elapsed(&self) -> f64 { + if let Some(start_time) = self.start_time { + start_time.elapsed().as_secs_f64() + } else { + 0.0 + } + } + + fn reset(&mut self) { + self.input.clear(); + self.start_time = None; + self.is_started = false; + self.is_finished = false; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.correct_positions = vec![false; self.text.len()]; + self.total_mistakes = 0; + self.total_errors_made = 0; + self.last_input_length = 0; + self.total_characters_typed = 0; + self.total_correct_characters = 0; + } + + pub fn can_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn can_ctrl_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result { + if !self.can_backspace() { + return Ok(false); + } + + let mut new_input = self.input.clone(); + + if ctrl { + // Find start of current word + let chars: Vec = new_input.chars().collect(); + let mut word_start = 0; + let mut in_word = false; + for (i, c) in chars.iter().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + // If at start of word, find previous error word start + if new_input.len() == word_start && !self.error_positions.is_empty() { + // Find the last error position before the current word + let prev_error = self.error_positions.iter().rev().find(|&&pos| pos < word_start); + if let Some(&err_pos) = prev_error { + // Find the start of the word containing this error + let mut prev_word_start = 0; + let mut in_word = false; + for (i, c) in chars.iter().enumerate() { + if i > err_pos { break; } + if c.is_whitespace() { + if in_word { + prev_word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + new_input = new_input[..prev_word_start].to_string(); + } + } else { + // Normal: delete to start of current word + new_input = new_input[..word_start].to_string(); + } + } else { + // Regular backspace: delete one character + if !self.can_backspace_to_position(new_input.len() - 1) { + return Ok(false); + } + new_input.pop(); + } + + self.input = new_input; + self.update_game_state(); + Ok(true) + } + + /// Check if there are any errors before a specific position + fn has_errors_before_position(&self, position: usize) -> bool { + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(position) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error + } + } + } + false + } + + /// Check if backspace is allowed to a specific position + /// Returns true if: + /// 1. We're in the current word (can always backspace within current word) + /// 2. There are errors in previous words (can backspace to fix them) + fn can_backspace_to_position(&self, target_pos: usize) -> bool { + if target_pos >= self.input.len() { + return false; + } + + // Always allow backspace within the current word + let current_word_start = self.get_current_word_start(); + if target_pos >= current_word_start { + return true; + } + + // Check if there are any errors in the text before the target position + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(target_pos + 1) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error, allow backspace + } + } + } + + false // No errors found, don't allow backspace to previous words + } + + /// Get the start position of the current word + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + word_start + } + + fn update_game_state(&mut self) { + println!("DEBUG: update_game_state called, input='{}', text='{}'", self.input, self.text); + self.error_positions.clear(); + let total_chars = self.input.len().min(self.text.len()); + + // Use string slices for comparison + let input_slice = &self.input[..total_chars]; + let text_slice = &self.text[..total_chars]; + + let mut _correct = 0; + let mut current_mistakes = 0; + let mut current_streak = 0; + let mut best_streak = 0; + + // Track new characters typed since last update + let new_chars_typed = if self.input.len() > self.last_input_length { + self.input.len() - self.last_input_length + } else { + 0 + }; + + // Add new characters to total typed + self.total_characters_typed += new_chars_typed as u32; + + // Compare characters using chars() iterator to handle UTF-8 correctly + for (i, (input_char, text_char)) in input_slice.chars().zip(text_slice.chars()).enumerate() { + if input_char == text_char { + _correct += 1; + current_streak += 1; + best_streak = best_streak.max(current_streak); + + // Count correct characters (only for new positions) + if i >= self.last_input_length { + self.total_correct_characters += 1; + } + } else { + current_mistakes += 1; + current_streak = 0; + self.error_positions.push(i); + + // Count new errors (only if this is a new character position) + if i >= self.last_input_length { + self.total_errors_made += 1; + } + } + } + + println!("DEBUG: before extra char logic, input.len()={}, text.len()={}", self.input.len(), self.text.len()); + // Add extra characters as current mistakes + if self.input.len() > self.text.len() { + current_mistakes += (self.input.len() - self.text.len()) as u32; + // Always count any new extra characters as errors + if self.input.len() > self.last_input_length { + let prev_extra = if self.last_input_length > self.text.len() { + self.last_input_length - self.text.len() + } else { + 0 + }; + let curr_extra = self.input.len() - self.text.len(); + let new_extra = curr_extra.saturating_sub(prev_extra); + println!("DEBUG: last_input_length={}, input.len()={}, prev_extra={}, curr_extra={}, new_extra={}", self.last_input_length, self.input.len(), prev_extra, curr_extra, new_extra); + if new_extra > 0 { + self.total_errors_made += new_extra as u32; + } + } + } + + self.total_mistakes = current_mistakes; + self.current_streak = current_streak as u32; + self.best_streak = best_streak as u32; + self.last_input_length = self.input.len(); + + // Check if game is finished - use both length and content comparison + let is_complete = !self.input.is_empty() && + self.input.len() >= self.text.len() && + self.input.trim() == self.text.trim(); + self.is_finished = is_complete; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backspace_within_current_word() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello").unwrap(); + + // Should be able to backspace within current word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hell"); + } + + #[test] + fn test_backspace_to_previous_word_with_error() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + + // Should be able to backspace to fix the error in previous word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo worl"); + + // Should be able to backspace more to fix the error + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo wor"); + } + + #[test] + fn test_backspace_to_previous_word_without_error() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello world").unwrap(); // No errors + + // Should not be able to backspace to previous word when no errors + let initial_input = game.get_input(); + assert!(!game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), initial_input); + } + + #[test] + fn test_ctrl_backspace() { + let mut game = Game::new(); + game.set_text("Hello world test".to_string()); + game.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Ctrl+backspace should delete the current word since there are errors before it + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "Hallo world "); + + // Test with no errors - should not allow ctrl+backspace + let mut game2 = Game::new(); + game2.set_text("Hello world test".to_string()); + game2.handle_input("Hello world test").unwrap(); // No errors + + assert!(!game2.handle_backspace(true).unwrap()); + + // Test going back to previous word with errors + let mut game3 = Game::new(); + game3.set_text("Hello world test".to_string()); + game3.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Should be able to ctrl+backspace to go back to the word with error + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo world "); + + // Should be able to ctrl+backspace again to go back further + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo world "); + } + + #[test] + fn test_game_completion_detection() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + + // Should not be finished initially + assert!(!game.is_finished()); + + // Should be finished when text is completed + game.handle_input("Hello world").unwrap(); + assert!(game.is_finished()); + + // Should be finished even with extra spaces (but we can't input more than text length) + let mut game2 = Game::new(); + game2.set_text("Hello world".to_string()); + game2.handle_input("Hello world").unwrap(); + assert!(game2.is_finished()); + } + + #[test] + fn test_error_counting() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + + // Type with errors + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + println!("After first error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should count the error + + // Correct the error + game.handle_input("Hello world").unwrap(); // Corrected + println!("After correction: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should still show 1 error (total made) + + // Make another error by typing extra characters + game.handle_input("Hello worldx").unwrap(); // Extra character + println!("After second error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 2); // Should now show 2 total errors + } + + #[test] + fn test_ctrl_backspace_one_error_word_at_a_time() { + let mut game = Game::new(); + game.set_text("foo bar baz qux".to_string()); + game.handle_input("fao bar bzz qux").unwrap(); // errors in 'fao' and 'bzz' + // Cursor at end, ctrl+backspace should delete to start of 'bzz' + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar "); + // Another ctrl+backspace should delete to start of 'fao' + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), ""); + } +} \ No newline at end of file diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs new file mode 100644 index 0000000..803ddbd --- /dev/null +++ b/crates/core/src/input.rs @@ -0,0 +1,182 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; +use crate::app::App; + +#[derive(Debug, Clone)] +pub struct Input { + pub content: String, + pub cursor_position: usize, + pub history: Vec, + pub history_index: usize, +} + +impl Input { + pub fn new() -> Self { + Self { + content: String::new(), + cursor_position: 0, + history: Vec::new(), + history_index: 0, + } + } + + pub fn handle_event(&mut self, event: Event) -> bool { + match event { + Event::Key(KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + }) => { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + true + } + Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Delete, + .. + }) => { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Left, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Right, + .. + }) => { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Home, + .. + }) => { + self.cursor_position = 0; + true + } + Event::Key(KeyEvent { + code: KeyCode::End, + .. + }) => { + self.cursor_position = self.content.len(); + true + } + _ => false, + } + } + + pub fn clear(&mut self) { + if !self.content.is_empty() { + self.history.push(self.content.clone()); + if self.history.len() > 100 { + self.history.remove(0); + } + } + self.content.clear(); + self.cursor_position = 0; + self.history_index = self.history.len(); + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn get_cursor_position(&self) -> usize { + self.cursor_position + } + + pub fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + } + + pub fn move_cursor_to_start(&mut self) { + self.cursor_position = 0; + } + + pub fn move_cursor_to_end(&mut self) { + self.cursor_position = self.content.len(); + } + + pub fn insert_char(&mut self, c: char) { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + } + + pub fn delete_char(&mut self) -> bool { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + true + } else { + false + } + } + + pub fn backspace(&mut self) -> bool { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + true + } else { + false + } + } +} + +impl Default for Input { + fn default() -> Self { + Self::new() + } +} + +pub struct InputHandler { + pub app: App, + last_tick: Instant, + tick_rate: Duration, +} + +impl InputHandler { + pub fn new(app: App) -> Self { + Self { + app, + last_tick: Instant::now(), + tick_rate: Duration::from_millis(100), + } + } +} \ No newline at end of file diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..6c365f1 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,23 @@ +pub mod config; +pub mod game; +pub mod stats; +pub mod text; +pub mod types; + +#[cfg(feature = "tui")] +pub mod app; +#[cfg(feature = "tui")] +pub mod input; +#[cfg(feature = "tui")] +pub mod ui; +#[cfg(feature = "tui")] +pub mod theme; + +#[cfg(feature = "multiplayer")] +pub mod multiplayer; + +#[cfg(feature = "wasm")] +pub mod wasm; + +#[cfg(target_arch = "wasm32")] +pub use wasm::TyperPunkGame; \ No newline at end of file diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs new file mode 100644 index 0000000..2154ac6 --- /dev/null +++ b/crates/core/src/multiplayer.rs @@ -0,0 +1,132 @@ +#[cfg(feature = "multiplayer")] +use tokio_tungstenite::tungstenite::Message; +#[cfg(feature = "multiplayer")] +use futures_util::{SinkExt, StreamExt}; +use serde::{Serialize, Deserialize}; +use std::net::SocketAddr; +use crate::{app::App, config::GameConfig, game::Platform}; +use std::error::Error; + +#[derive(Debug, Serialize, Deserialize)] +pub enum MultiplayerMessage { + Join, + Progress { progress: f32, wpm: f32 }, + Finish { wpm: f32, time: f32 }, +} + +#[derive(Debug)] +pub struct MultiplayerManager { + config: GameConfig, +} + +impl MultiplayerManager { + pub fn new(config: GameConfig) -> Self { + Self { config } + } + + #[cfg(feature = "multiplayer")] + pub async fn start_server(&self, addr: &str) -> Result<(), Box> { + tokio::spawn(async move { + let server = tokio::net::TcpListener::bind(addr).await?; + while let Ok((stream, _)) = server.accept().await { + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + let (write, read) = ws_stream.split(); + read.forward(write).await?; + } + Ok::<(), Box>(()) + }); + Ok(()) + } + + #[cfg(feature = "multiplayer")] + pub async fn host_game(&mut self, addr: &str) -> Result<(), Box> { + tokio::spawn(async move { + let server = tokio::net::TcpListener::bind(addr).await?; + while let Ok((stream, _)) = server.accept().await { + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + let (write, read) = ws_stream.split(); + read.forward(write).await?; + } + Ok::<(), Box>(()) + }); + Ok(()) + } + + #[cfg(feature = "multiplayer")] + pub async fn join_game(&mut self, url: &str) -> Result<(), Box> { + let (ws_stream, _) = tokio_tungstenite::connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send join message + let join_msg = serde_json::to_string(&MultiplayerMessage::Join)?; + write.send(Message::Text(join_msg)).await?; + + // Handle incoming messages + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + println!("Received: {}", text); + } + Err(e) => { + eprintln!("Error receiving message: {}", e); + break; + } + _ => {} + } + } + }); + + Ok(()) + } + + pub fn send_progress(&self, _progress: f32, _wpm: f32) { + #[cfg(feature = "multiplayer")] + let _msg = MultiplayerMessage::Progress { progress: _progress, wpm: _wpm }; + // Send message implementation + } + + pub fn send_finish(&self, _wpm: f32, _time: f32) { + #[cfg(feature = "multiplayer")] + let _msg = MultiplayerMessage::Finish { wpm: _wpm, time: _time }; + // Send message implementation + } +} + +#[cfg(target_arch = "wasm32")] +impl MultiplayerManager { + pub async fn connect_web(&mut self, _room_id: &str) -> Result<(), Box> { + // Connect to WebSocket server for web multiplayer + Ok(()) + } +} + +#[cfg(feature = "multiplayer")] +impl MultiplayerManager { + pub async fn connect_to_server(&self, url: &str) -> Result<(), Box> { + let (ws_stream, _) = tokio_tungstenite::connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Send join message + let join_msg = serde_json::to_string(&MultiplayerMessage::Join)?; + write.send(Message::Text(join_msg)).await?; + + // Handle incoming messages + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + println!("Received: {}", text); + } + Err(e) => { + eprintln!("Error receiving message: {}", e); + break; + } + _ => {} + } + } + }); + + Ok(()) + } +} \ No newline at end of file diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs new file mode 100644 index 0000000..59d56c9 --- /dev/null +++ b/crates/core/src/stats.rs @@ -0,0 +1,242 @@ +use std::time::{Duration, Instant}; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone)] +pub struct Stats { + start_time: Option, + end_time: Option, + error_positions: Vec, + current_streak: usize, + best_streak: usize, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedStats { + wpm: f64, + accuracy: f64, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, + time_elapsed_secs: u64, + error_positions: Vec, + current_streak: usize, + best_streak: usize, +} + +impl From<&Stats> for SerializedStats { + fn from(stats: &Stats) -> Self { + let time_elapsed = stats.start_time + .and_then(|start| stats.end_time.map(|end| end.duration_since(start))) + .unwrap_or(Duration::from_secs(0)); + + let wpm = if time_elapsed.as_secs() > 0 { + (stats.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + }; + + let accuracy = if stats.total_chars > 0 { + (stats.correct_chars as f64 / stats.total_chars as f64) * 100.0 + } else { + 0.0 + }; + + Self { + wpm, + accuracy, + total_chars: stats.total_chars, + correct_chars: stats.correct_chars, + incorrect_chars: stats.incorrect_chars, + total_words: stats.total_words, + correct_words: stats.correct_words, + errors: stats.errors, + time_elapsed_secs: time_elapsed.as_secs(), + error_positions: stats.error_positions.clone(), + current_streak: stats.current_streak, + best_streak: stats.best_streak, + } + } +} + +impl Stats { + pub fn new() -> Self { + Self { + start_time: None, + end_time: None, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + total_chars: 0, + correct_chars: 0, + incorrect_chars: 0, + total_words: 0, + correct_words: 0, + errors: 0, + } + } + + pub fn reset(&mut self) { + self.start_time = None; + self.end_time = None; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.total_chars = 0; + self.correct_chars = 0; + self.incorrect_chars = 0; + self.total_words = 0; + self.correct_words = 0; + self.errors = 0; + } + + pub fn start(&mut self) { + self.start_time = Some(Instant::now()); + } + + pub fn update(&mut self, input: &str, target: &str) { + // Recompute everything from scratch for current input + self.error_positions.clear(); + let mut streak = 0; + let mut best_streak_local = 0; + let mut correct_chars = 0usize; + let mut incorrect_chars = 0usize; + let mut total_words = 0usize; + let mut correct_words = 0usize; + + // Tokenize by whitespace to count words + let input_words: Vec<&str> = input.split_whitespace().collect(); + let target_words: Vec<&str> = target.split_whitespace().collect(); + total_words = input_words.len(); + for (iw, tw) in input_words.iter().zip(target_words.iter()) { + if *iw == *tw { correct_words += 1; } + } + + for (i, (input_char, target_char)) in input.chars().zip(target.chars()).enumerate() { + if input_char == target_char { + streak += 1; + correct_chars += 1; + if streak > best_streak_local { best_streak_local = streak; } + } else { + self.error_positions.push(i); + streak = 0; + incorrect_chars += 1; + } + } + + // Extra characters beyond target count as incorrect + if input.len() > target.len() { + incorrect_chars += input.len() - target.len(); + } + + self.total_chars = input.len(); + self.correct_chars = correct_chars; + self.incorrect_chars = incorrect_chars; + self.total_words = total_words; + self.correct_words = correct_words; + self.errors = self.error_positions.len(); + self.current_streak = streak; + self.best_streak = self.best_streak.max(best_streak_local); + } + + pub fn finish(&mut self) { + self.end_time = Some(Instant::now()); + } + + pub fn get_error_positions(&self) -> Vec { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> usize { + self.current_streak + } + + pub fn get_best_streak(&self) -> usize { + self.best_streak + } + + pub fn get_wpm(&self) -> f64 { + let time_elapsed = match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + }; + + if time_elapsed.as_secs_f64() > 0.0 { + (self.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + } + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_chars > 0 { + (self.correct_chars as f64 / self.total_chars as f64) * 100.0 + } else { + 0.0 + } + } + + pub fn get_time_elapsed(&self) -> Duration { + match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + } + } + + pub fn wpm(&self) -> f64 { self.get_wpm() } + pub fn accuracy(&self) -> f64 { self.get_accuracy() } + pub fn elapsed_time(&self) -> std::time::Duration { self.get_time_elapsed() } + pub fn is_running(&self) -> bool { self.start_time.is_some() && self.end_time.is_none() } + pub fn stop(&mut self) { self.end_time = Some(std::time::Instant::now()); } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.start_time, None); + assert_eq!(stats.end_time, None); + assert_eq!(stats.error_positions, Vec::new()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); + } + + #[test] + fn test_stats_update() { + let mut stats = Stats::new(); + stats.start_time = Some(Instant::now()); + stats.update("hello", "hello"); + stats.end_time = Some(Instant::now()); + + assert_eq!(stats.start_time, Some(Instant::now())); + assert_eq!(stats.end_time, Some(Instant::now())); + assert_eq!(stats.error_positions, Vec::new()); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + } + + #[test] + fn test_stats_word_counting() { + let mut stats = Stats::new(); + stats.total_words = 2; + stats.correct_words = 2; + + assert_eq!(stats.total_words, 2); + assert_eq!(stats.correct_words, 2); + } +} \ No newline at end of file diff --git a/crates/core/src/tests/game_tests.rs b/crates/core/src/tests/game_tests.rs new file mode 100644 index 0000000..6bc67ed --- /dev/null +++ b/crates/core/src/tests/game_tests.rs @@ -0,0 +1,143 @@ +use crate::game::{Game, GameMode}; +use crate::text::Text; + +#[test] +fn test_game_initialization() { + let game = Game::new(GameMode::Time(60)); + assert_eq!(game.mode(), GameMode::Time(60)); + assert_eq!(game.is_finished(), false); +} + +#[test] +fn test_game_time_mode() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + // Simulate some typing + game.update("Hello"); + assert_eq!(game.is_finished(), false); + + // Simulate time running out + game.update_time(61); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_words_mode() { + let mut game = Game::new(GameMode::Words(10)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + // Simulate typing some words + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + + // Simulate completing all words + game.update("Hello world! This is a test."); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_reset() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + game.reset(); + + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), ""); +} + +#[test] +fn test_game_mode_transition() { + let mut game = Game::new(GameMode::Time(60)); + game.set_mode(GameMode::Words(10)); + assert_eq!(game.mode(), GameMode::Words(10)); +} + +#[test] +fn test_game_text_update() { + let mut game = Game::new(GameMode::Time(60)); + let text1 = Text::new("First text"); + let text2 = Text::new("Second text"); + + game.set_text(text1); + assert_eq!(game.current_text().content(), "First text"); + + game.set_text(text2); + assert_eq!(game.current_text().content(), "Second text"); +} + +#[test] +fn test_game_partial_completion() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Hello world!"); +} + +#[test] +fn test_game_error_handling() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Helo world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Helo world!"); +} + +#[test] +fn test_game_time_remaining() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update_time(30); + assert_eq!(game.time_remaining(), 30); +} + +#[test] +fn test_game_words_remaining() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.words_remaining(), 3); +} + +#[test] +fn test_game_progress() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.progress(), 0.4); // 2 out of 5 words completed +} + +#[test] +fn test_game_state_persistence() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + let input = game.current_input(); + let is_finished = game.is_finished(); + + game.reset(); + assert_eq!(game.current_input(), ""); + assert_eq!(game.is_finished(), false); + + game.update(input); + assert_eq!(game.current_input(), input); + assert_eq!(game.is_finished(), is_finished); +} \ No newline at end of file diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs new file mode 100644 index 0000000..f39330c --- /dev/null +++ b/crates/core/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod stats_tests; +mod text_tests; +mod game_tests; \ No newline at end of file diff --git a/crates/core/src/tests/stats_tests.rs b/crates/core/src/tests/stats_tests.rs new file mode 100644 index 0000000..1b210c8 --- /dev/null +++ b/crates/core/src/tests/stats_tests.rs @@ -0,0 +1,125 @@ +use crate::stats::Stats; +use std::time::Duration; + +#[test] +fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_start_stop() { + let mut stats = Stats::new(); + assert!(!stats.is_running()); + + stats.start(); + assert!(stats.is_running()); + + stats.stop(); + assert!(!stats.is_running()); +} + +#[test] +fn test_stats_update() { + let mut stats = Stats::new(); + stats.start(); + + // Test perfect typing + stats.update("hello", "hello"); + assert_eq!(stats.correct_chars, 5); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_chars, 5); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + // Test with errors + stats.update("helo", "hello"); + assert_eq!(stats.correct_chars, 3); + assert_eq!(stats.incorrect_chars, 1); + assert_eq!(stats.total_chars, 4); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_reset() { + let mut stats = Stats::new(); + stats.start(); + stats.update("hello", "hello"); + stats.reset(); + + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_wpm_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 60 characters (12 words) in 1 minute + stats.update("hello world hello world hello world", "hello world hello world hello world"); + assert_eq!(stats.wpm, 12.0); +} + +#[test] +fn test_stats_accuracy_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 10 characters with 2 errors + stats.update("hello wrld", "hello world"); + assert_eq!(stats.accuracy, 80.0); +} + +#[test] +fn test_stats_streak_tracking() { + let mut stats = Stats::new(); + stats.start(); + + // Test streak building and breaking + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + stats.update("helo", "hello"); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); + + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_error_positions() { + let mut stats = Stats::new(); + stats.start(); + + stats.update("helo", "hello"); + assert_eq!(stats.error_positions, vec![3]); + + stats.update("hllo", "hello"); + assert_eq!(stats.error_positions, vec![1]); +} \ No newline at end of file diff --git a/crates/core/src/tests/text_tests.rs b/crates/core/src/tests/text_tests.rs new file mode 100644 index 0000000..2964e2a --- /dev/null +++ b/crates/core/src/tests/text_tests.rs @@ -0,0 +1,83 @@ +use crate::text::Text; + +#[test] +fn test_text_creation() { + let content = "Hello, world!"; + let text = Text::new(content); + assert_eq!(text.content(), content); + assert_eq!(text.words().len(), 2); +} + +#[test] +fn test_text_word_count() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_empty() { + let text = Text::new(""); + assert_eq!(text.content(), ""); + assert_eq!(text.words().len(), 0); +} + +#[test] +fn test_text_with_special_chars() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); + assert_eq!(text.content(), "Hello, world! This is a test..."); +} + +#[test] +fn test_text_word_boundaries() { + let text = Text::new("Hello-world! This_is_a_test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_multiple_spaces() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_numbers() { + let text = Text::new("Hello 123 world! 456 test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_with_punctuation() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_mixed_case() { + let text = Text::new("Hello WORLD! This IS a TEST."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_unicode() { + let text = Text::new("Hello 世界! This is a テスト."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_emojis() { + let text = Text::new("Hello 👋 world! This is a test 🎯."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_tabs() { + let text = Text::new("Hello\tworld!\tThis\tis\ta\ttest."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_newlines() { + let text = Text::new("Hello\nworld!\nThis\nis\na\ntest."); + assert_eq!(text.words().len(), 6); +} \ No newline at end of file diff --git a/crates/core/src/text.rs b/crates/core/src/text.rs new file mode 100644 index 0000000..0e0e248 --- /dev/null +++ b/crates/core/src/text.rs @@ -0,0 +1,72 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub struct Text { + pub content: String, + pub source: String, + pub language: String, + pub category: String, +} + +impl Text { + pub fn new() -> Self { + Self { + content: String::new(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str(content: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_source(content: &str, source: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_language(content: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_str_with_source_and_language(content: &str, source: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_all(content: &str, source: &str, language: &str, category: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: category.to_string(), + } + } +} + +impl fmt::Display for Text { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.content) + } +} + \ No newline at end of file diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..8ebad89 --- /dev/null +++ b/crates/core/src/theme.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "tui")] +use ratatui::style::Color; + +#[cfg(feature = "tui")] +pub struct Theme { + pub background: Color, + pub foreground: Color, + pub accent: Color, + pub error: Color, + pub success: Color, +} + +#[cfg(feature = "tui")] +impl Default for Theme { + fn default() -> Self { + Self { + background: Color::Black, + foreground: Color::White, + accent: Color::Cyan, + error: Color::Red, + success: Color::Green, + } + } +} \ No newline at end of file diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs new file mode 100644 index 0000000..dc57c9c --- /dev/null +++ b/crates/core/src/types.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option, + pub word_count: Option, + pub custom_text: Option, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::default(), + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 1, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option; + fn get_word_count(&self) -> Option; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option { + self.time_limit + } + + fn get_word_count(&self) -> Option { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Dark + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Theme::Light => write!(f, "light"), + Theme::Dark => write!(f, "dark"), + } + } +} \ No newline at end of file diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs new file mode 100644 index 0000000..f503474 --- /dev/null +++ b/crates/core/src/ui.rs @@ -0,0 +1,187 @@ +#[cfg(feature = "tui")] +use std::io; + +#[cfg(feature = "tui")] +use ratatui::{ + backend::Backend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +#[cfg(feature = "tui")] +use crate::app::App; + +#[cfg(feature = "tui")] +use ratatui::prelude::{Line, Alignment}; +use crate::app::State; + +pub fn draw(f: &mut Frame, app: &App) { + match app.state { + State::MainMenu => draw_main_menu(f, app), + State::TypingGame => draw_typing_game(f, app), + State::EndScreen => draw_end_screen(f, app), + } +} + +pub fn draw_main_menu(f: &mut Frame, _app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let category_label = { + let cat = _app.selected_category.as_ref().map(String::as_str).unwrap_or("Random"); + format!("Category: {} (←/→ to change)", cat) + }; + let main_menu = vec![ + Line::from(Span::styled("Welcome to Typerpunk!", Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled(category_label, Style::default().fg(Color::Cyan))), + Line::from(Span::styled("Press Enter to Start", Style::default())), + Line::from(Span::styled("Press Esc to Quit", Style::default())), + ]; + + f.render_widget( + Paragraph::new(main_menu) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); +} + +pub fn draw_typing_game(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Stats + Constraint::Min(0), // Text + ]) + .split(f.size()); + + // Draw stats + let stats = vec![ + Line::from(vec![ + Span::styled(format!("WPM: {:.1}", app.stats.wpm()), Style::default().fg(Color::Yellow)), + Span::styled(" | ", Style::default()), + Span::styled(format!("Time: {:.1}s", app.stats.elapsed_time().as_secs_f64()), Style::default().fg(Color::Cyan)), + Span::styled(" | ", Style::default()), + Span::styled(format!("Accuracy: {:.1}%", app.stats.accuracy()), Style::default().fg(Color::Green)), + ]), + ]; + + f.render_widget( + Paragraph::new(stats) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); + + // Draw text + let text_chars: Vec = app.current_text().content.chars().collect(); + let input_chars: Vec = app.input.chars().collect(); + let mut colored_text: Vec = Vec::new(); + let cursor_pos = app.input.len(); + + for (i, &c) in text_chars.iter().enumerate() { + let style = if i < input_chars.len() { + if input_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + Style::default().fg(Color::Gray) + }; + + let span = if i == cursor_pos { + Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED)) + } else { + Span::styled(c.to_string(), style) + }; + colored_text.push(span); + } + + // Add any remaining incorrect characters + if input_chars.len() > text_chars.len() { + for &c in &input_chars[text_chars.len()..] { + colored_text.push(Span::styled( + c.to_string(), + Style::default().fg(Color::Red), + )); + } + } + + let text = vec![ + Line::from(Span::styled( + "Type the following text:", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(colored_text), + ]; + + f.render_widget( + Paragraph::new(text) + .alignment(Alignment::Left) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + // Render attribution beneath the text box + let attribution = app.current_text().source.clone(); + if !attribution.is_empty() { + let area = ratatui::layout::Rect { + x: chunks[1].x, + y: chunks[1].y.saturating_add(chunks[1].height.saturating_sub(2)), + width: chunks[1].width, + height: 2, + }; + let attribution_line = Line::from(Span::styled( + format!("— {}", attribution), + Style::default().fg(Color::Gray), + )); + f.render_widget( + Paragraph::new(vec![attribution_line]) + .alignment(Alignment::Right) + .wrap(Wrap { trim: true }), + area, + ); + } +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let end_screen = vec![ + Line::from(Span::styled("Game Over!", Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled( + format!("Words Per Minute: {:.1}", app.stats.wpm()), + Style::default(), + )), + Line::from(Span::styled( + format!("Time Taken: {:.1} seconds", app.stats.elapsed_time().as_secs_f64()), + Style::default(), + )), + Line::from(Span::styled( + format!("Accuracy: {:.1}%", app.stats.accuracy()), + Style::default(), + )), + Line::from(Span::styled("Press Enter to Play Again", Style::default())), + Line::from(Span::styled("Press Esc to Quit", Style::default())), + ]; + + f.render_widget( + Paragraph::new(end_screen) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), + chunks[0], + ); +} \ No newline at end of file diff --git a/crates/core/src/wasm.rs b/crates/core/src/wasm.rs new file mode 100644 index 0000000..c71072e --- /dev/null +++ b/crates/core/src/wasm.rs @@ -0,0 +1,98 @@ +use crate::game::Game; +use crate::types::Theme; + +pub struct TyperPunkGame { + pub game: Game, +} + +impl TyperPunkGame { + pub fn new() -> Self { + Self { + game: Game::new(), + } + } + + pub fn set_text(&mut self, text: String) { + self.game.set_text(text); + } + + pub fn get_text(&self) -> String { + self.game.get_text() + } + + pub fn get_input(&self) -> String { + self.game.get_input().to_string() + } + + pub fn start(&mut self) { + self.game.start(); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + self.game.handle_input(input) + } + + pub fn is_finished(&self) -> bool { + self.game.is_finished() + } + + pub fn get_error_positions(&self) -> Vec { + self.game.get_error_positions() + } + + pub fn get_current_streak(&self) -> u32 { + self.game.get_current_streak() + } + + pub fn get_best_streak(&self) -> u32 { + self.game.get_best_streak() + } + + pub fn get_theme(&self) -> String { + self.game.get_theme().to_string() + } + + pub fn set_theme(&mut self, theme: String) { + let theme = match theme.as_str() { + "light" => Theme::Light, + _ => Theme::Dark, + }; + self.game.set_theme(theme); + } + + pub fn get_wpm(&self) -> f64 { + self.game.get_wpm() + } + + pub fn get_accuracy(&self) -> f64 { + self.game.get_accuracy() + } + + pub fn get_time_elapsed(&self) -> f64 { + self.game.get_time_elapsed() + } + + pub fn can_backspace(&self) -> bool { + self.game.can_backspace() + } + + pub fn can_ctrl_backspace(&self) -> bool { + self.game.can_ctrl_backspace() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result { + self.game.handle_backspace(ctrl) + } + + pub fn get_total_mistakes(&self) -> u32 { + self.game.get_total_mistakes() + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + self.game.get_stats() + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + self.game.get_stats_and_input() + } +} \ No newline at end of file diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..8798269 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "typerpunk-tui" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk" +path = "src/main.rs" + +[features] +default = ["tui"] +tui = ["typerpunk-core/tui"] +multiplayer = ["typerpunk-core/multiplayer"] + +[dependencies] +typerpunk-core = { path = "../core", features = ["tui"] } +crossterm.workspace = true +ratatui.workspace = true +tokio = { workspace = true, features = ["full"] } +anyhow.workspace = true +config.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +dirs.workspace = true +rlua.workspace = true +rand.workspace = true +tokio-tungstenite = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } \ No newline at end of file diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..1819103 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,90 @@ +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; +use std::{io, error::Error as StdError}; +use typerpunk_core::{ + app::App, + input::InputHandler, + ui::draw, +}; + +fn main() -> Result<(), Box> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app and run it + let app = match App::new() { + Ok(app) => app, + Err(e) => { + cleanup_terminal(&mut terminal)?; + return Err(e); + } + }; + + let mut input_handler = InputHandler::new(app); + let res = run_app(&mut terminal, &mut input_handler); + + // Restore terminal + cleanup_terminal(&mut terminal)?; + + if let Err(err) = res { + println!("Error: {:?}", err); + } + + Ok(()) +} + +fn cleanup_terminal(terminal: &mut Terminal) -> io::Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn run_app( + terminal: &mut Terminal, + input_handler: &mut InputHandler, +) -> io::Result<()> { + let mut last_render = std::time::Instant::now(); + let render_interval = std::time::Duration::from_millis(16); // ~60 FPS + + loop { + // Update app state to refresh timers and stats + input_handler.app.update(); + terminal.draw(|f| draw(f, &input_handler.app))?; + + if event::poll(std::time::Duration::from_millis(0))? { + if let Event::Key(key) = event::read()? { + input_handler.app.handle_input(key); + if input_handler.app.should_exit { + return Ok(()); + } + } + } + + // Limit render rate + let now = std::time::Instant::now(); + if now.duration_since(last_render) < render_interval { + std::thread::sleep(render_interval - now.duration_since(last_render)); + } + last_render = now; + + if input_handler.app.should_exit { + return Ok(()); + } + } +} \ No newline at end of file diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..8092566 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "typerpunk-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["wasm", "serde"] } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +web-sys = { version = "0.3", features = [ + "Document", + "Element", + "HtmlElement", + "Window", + "console", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "CssStyleDeclaration", + "MouseEvent", + "KeyboardEvent", +] } +serde = { workspace = true } +console_error_panic_hook = "0.1" \ No newline at end of file diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..d17009d --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,213 @@ +use wasm_bindgen::prelude::*; +use typerpunk_core::game::Game; + +// Re-export TyperPunkGame as TyperPunk +pub use typerpunk_core::wasm::TyperPunkGame as TyperPunk; + +#[wasm_bindgen] +pub struct TyperPunkGame { + game: Option, +} + +#[wasm_bindgen] +impl TyperPunkGame { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + game: Some(Game::new()), + } + } + + #[wasm_bindgen] + pub fn set_text(&mut self, text: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let text_str = match std::str::from_utf8(text.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 text")), + }; + + game.set_text(text_str); + Ok(()) + } + + #[wasm_bindgen] + pub fn get_text(&self) -> String { + self.game.as_ref() + .map(|game| game.get_text()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn get_input(&self) -> String { + self.game.as_ref() + .map(|game| game.get_input()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn handle_input(&mut self, input: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 input")), + }; + + // Process input + game.handle_input(&input_str) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn handle_backspace(&mut self, ctrl: bool) -> Result { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + if !game.can_backspace() { + return Ok(false); + } + + // Check if ctrl+backspace is allowed + if ctrl && !game.can_ctrl_backspace() { + return Ok(false); + } + + // Perform backspace + game.handle_backspace(ctrl) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn get_stats(&self) -> Result { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + let (accuracy, mistakes) = game.get_stats() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let array = js_sys::Array::new(); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn get_stats_and_input(&self) -> Result { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Get input first to avoid recursive use + let input = game.get_input(); + + // Then get stats + let (_, accuracy, mistakes) = game.get_stats_and_input() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let array = js_sys::Array::new(); + array.push(&JsValue::from_str(&input)); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn is_finished(&self) -> bool { + self.game.as_ref() + .map(|game| { + let input = game.get_input(); + let text = game.get_text(); + // Use the same logic as the core game + !input.is_empty() && + input.len() >= text.len() && + input.trim() == text.trim() + }) + .unwrap_or(false) + } + + #[wasm_bindgen] + pub fn start(&mut self) { + if let Some(game) = &mut self.game { + game.start(); + } + } + + #[wasm_bindgen] + pub fn get_wpm(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_wpm()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn get_time_elapsed(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_time_elapsed()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn free(&mut self) { + self.game = None; + } + + #[wasm_bindgen] + pub fn can_backspace_to_position(&self, position: usize) -> bool { + self.game.as_ref() + .map(|game| { + // This is a simplified check - the actual logic is in the core game + // For now, we'll allow backspace if there are any errors before this position + let input = game.get_input(); + let text = game.get_text(); + + if position >= input.len() { + return false; + } + + // Check if there are any errors before this position + let check_len = position.min(text.len()).min(input.len()); + for i in 0..check_len { + let input_char = input.chars().nth(i); + let text_char = text.chars().nth(i); + if input_char != text_char { + return true; // Found an error, allow backspace + } + } + + false + }) + .unwrap_or(false) + } + + #[wasm_bindgen] + pub fn get_current_word_start(&self) -> usize { + self.game.as_ref() + .map(|game| { + let input = game.get_input(); + let mut word_start = 0; + let mut in_word = false; + + for (i, c) in input.chars().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + word_start + }) + .unwrap_or(0) + } +} + +#[wasm_bindgen] +pub fn init() { + console_error_panic_hook::set_once(); +} \ No newline at end of file -- cgit v1.2.3