aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/Cargo.toml52
-rw-r--r--crates/core/src/app.rs275
-rw-r--r--crates/core/src/config.rs42
-rw-r--r--crates/core/src/game.rs632
-rw-r--r--crates/core/src/input.rs182
-rw-r--r--crates/core/src/lib.rs23
-rw-r--r--crates/core/src/multiplayer.rs132
-rw-r--r--crates/core/src/stats.rs242
-rw-r--r--crates/core/src/tests/game_tests.rs143
-rw-r--r--crates/core/src/tests/mod.rs3
-rw-r--r--crates/core/src/tests/stats_tests.rs125
-rw-r--r--crates/core/src/tests/text_tests.rs83
-rw-r--r--crates/core/src/text.rs72
-rw-r--r--crates/core/src/theme.rs24
-rw-r--r--crates/core/src/types.rs148
-rw-r--r--crates/core/src/ui.rs187
-rw-r--r--crates/core/src/wasm.rs98
-rw-r--r--crates/tui/Cargo.toml32
-rw-r--r--crates/tui/src/main.rs90
-rw-r--r--crates/wasm/Cargo.toml26
-rw-r--r--crates/wasm/src/lib.rs213
21 files changed, 2824 insertions, 0 deletions
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<Text>,
+ pub categories: Vec<String>,
+ pub selected_category: Option<String>, // 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<Self, Box<dyn std::error::Error + Send + Sync>> {
+ 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<RawText> = serde_json::from_str(RAW_TEXTS)?;
+ let texts: Vec<Text> = 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::<Vec<_>>()
+ };
+ 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<Self, Box<dyn std::error::Error + Send + Sync>> {
+ #[derive(Deserialize)]
+ struct RawText { category: String, content: String, attribution: String }
+ const RAW_TEXTS: &str = include_str!("../../../texts.json");
+ let parsed: Vec<RawText> = serde_json::from_str(RAW_TEXTS)?;
+ let texts: Vec<Text> = 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::<Vec<_>>()
+ };
+ 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<usize> = 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<u64>,
+ pub word_count: Option<usize>,
+ pub custom_text: Option<String>,
+ 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<u64>;
+ fn get_word_count(&self) -> Option<usize>;
+ 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<u64> {
+ self.time_limit
+ }
+
+ fn get_word_count(&self) -> Option<usize> {
+ 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<Instant>,
+ is_started: bool,
+ is_finished: bool,
+ error_positions: Vec<usize>,
+ current_streak: u32,
+ best_streak: u32,
+ theme: Theme,
+ correct_positions: Vec<bool>,
+ 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<usize> {
+ 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<bool, String> {
+ if !self.can_backspace() {
+ return Ok(false);
+ }
+
+ let mut new_input = self.input.clone();
+
+ if ctrl {
+ // Find start of current word
+ let chars: Vec<char> = 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<String>,
+ 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<dyn Error + Send + Sync>> {
+ 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<dyn Error + Send + Sync>>(())
+ });
+ Ok(())
+ }
+
+ #[cfg(feature = "multiplayer")]
+ pub async fn host_game(&mut self, addr: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
+ 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<dyn Error + Send + Sync>>(())
+ });
+ Ok(())
+ }
+
+ #[cfg(feature = "multiplayer")]
+ pub async fn join_game(&mut self, url: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
+ 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<dyn Error + Send + Sync>> {
+ // Connect to WebSocket server for web multiplayer
+ Ok(())
+ }
+}
+
+#[cfg(feature = "multiplayer")]
+impl MultiplayerManager {
+ pub async fn connect_to_server(&self, url: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
+ 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<Instant>,
+ end_time: Option<Instant>,
+ error_positions: Vec<usize>,
+ 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<usize>,
+ 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<usize> {
+ 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<u64>,
+ pub word_count: Option<usize>,
+ pub custom_text: Option<String>,
+ 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<u64>;
+ fn get_word_count(&self) -> Option<usize>;
+ 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<u64> {
+ self.time_limit
+ }
+
+ fn get_word_count(&self) -> Option<usize> {
+ 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<char> = app.current_text().content.chars().collect();
+ let input_chars: Vec<char> = app.input.chars().collect();
+ let mut colored_text: Vec<Span> = 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<usize> {
+ 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<bool, String> {
+ 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<dyn StdError>> {
+ // 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<B: ratatui::backend::Backend + std::io::Write>(terminal: &mut Terminal<B>) -> io::Result<()> {
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+ Ok(())
+}
+
+fn run_app<B: ratatui::backend::Backend>(
+ terminal: &mut Terminal<B>,
+ 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<Game>,
+}
+
+#[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<bool, JsValue> {
+ 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<JsValue, JsValue> {
+ 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<JsValue, JsValue> {
+ 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