diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'crates')
64 files changed, 8756 insertions, 0 deletions
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..7147ccf --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,48 @@ +[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 = ["serde"] +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 } + +# 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..63d3df4 --- /dev/null +++ b/crates/core/src/app.rs @@ -0,0 +1,1247 @@ +use rand::Rng; +use crossterm::event::KeyEvent; +use crate::{ + config::Config, + custom_text::{parse_custom_content, CustomText}, + stats::Stats, + text::Text, + words::{generate_words, word_count_for_duration}, +}; +use serde::Deserialize; +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq)] +pub enum State { + MainMenu, + CustomTextPrompt, + TypingGame, + PassiveMode, + EndScreen, + Login, + Leaderboard, + Friends, + MultiplayerLobby, + MultiplayerRace, + MultiplayerResults, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LoginField { + Username, + Password, +} + +// Requests the TUI binary's own network worker fulfils - kept as plain data +// here (no reqwest/tokio types) so typerpunk-core stays free of a hard +// networking dependency, since this crate also compiles to wasm for the web +// build. +#[derive(Debug, Clone)] +pub enum NetworkAction { + Login { username: String, password: String }, + Register { username: String, password: String }, + FetchLeaderboard { mode: String }, + FetchFriends, + SendFriendRequest { username: String }, + AcceptFriendRequest { id: String }, + RemoveFriendship { id: String }, + // Progress/Finish aren't here - those flow continuously once a race is + // underway, not from a single keypress, so the tui binary's main loop + // drives them directly every tick instead of through this one-shot + // per-keypress queue. See MultiplayerRace's fields below. + CreateMultiplayerRoom, + JoinMultiplayerRoom { code: String }, + MultiplayerReady, +} + +/// One entry in the multiplayer lobby/race player list. Plain data, not +/// typerpunk_core::multiplayer::PlayerInfo - that type only exists behind +/// the "multiplayer" cargo feature (unneeded for the wasm/web build this +/// crate also compiles to), and app.rs otherwise has no reason to depend on +/// it. The tui binary's network layer converts at the boundary. +#[derive(Debug, Clone)] +pub struct MpPlayer { + pub id: String, + pub name: String, + pub ready: bool, + pub progress: f32, + pub wpm: f32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LeaderboardRow { + pub username: String, + pub wpm: f64, + pub accuracy: f64, + #[serde(default)] + pub device_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FriendRow { + pub friendship_id: String, + pub username: String, +} + +pub struct App { + pub config: Config, + pub texts: Vec<Text>, + pub categories: Vec<String>, + pub selected_category: Option<String>, // None = Random + pub words_mode_selected: bool, + pub word_count: usize, + pub words_punctuation: bool, + pub words_numbers: bool, + pub time_mode_selected: bool, + pub time_duration: u64, + pub current_words: Option<Text>, + pub stats: Stats, + pub input: String, + pub current_text_index: usize, + pub should_exit: bool, + pub state: State, + pub wpm_history: Vec<u64>, + + pub custom_mode_selected: bool, + pub custom_text: Option<CustomText>, + pub custom_index: usize, + pub using_custom: bool, + pub current_custom: Option<Text>, + pub path_input: String, + pub path_error: Option<String>, + + pub passive_active_index: usize, + pub passive_typed: String, + pub passive_correct: u32, + pub passive_total: u32, + pub passive_start: Option<Instant>, + pub passive_schedule: Vec<f64>, + pub passive_done: bool, + + // Online: account, leaderboard, friends - see NetworkAction above for + // how this crate hands network work off to the tui binary. + pub auth_token: Option<String>, + pub logged_in_username: Option<String>, + pub login_register_mode: bool, // false = Login, true = Register + pub login_field: LoginField, + pub login_username: String, + pub login_password: String, + pub net_busy: bool, + pub net_status: Option<String>, + pub pending_network_action: Option<NetworkAction>, + pub leaderboard_mode: String, + pub leaderboard_entries: Vec<LeaderboardRow>, + pub friends_list: Vec<FriendRow>, + pub friends_incoming: Vec<FriendRow>, + pub friends_outgoing: Vec<FriendRow>, + pub friends_add_input: String, + pub friends_selected: usize, + + // Multiplayer: lobby (create/join by code, ready check), the live race + // itself, and results. current_text() prefers mp_race_text over every + // other source while a race is active, so State::MultiplayerRace reuses + // TypingGame's exact keystroke handling and rendering unchanged. + pub mp_room_code_input: String, + pub mp_own_room_code: Option<String>, + pub mp_players: Vec<MpPlayer>, + pub mp_local_player_id: Option<String>, + pub mp_countdown: Option<u32>, + pub mp_race_text: Option<Text>, + pub mp_results: Vec<(String, String, f32, u32)>, // (player_id, name, wpm, place) + pub mp_status: Option<String>, + pub mp_finish_sent: bool, +} + +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, + // Plain-language prose, not code - leave language empty so + // highlight_classes() doesn't run its string-literal scan + // over apostrophes in contractions and possessives. + language: String::new(), + 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, + words_mode_selected: false, + word_count: 25, + words_punctuation: false, + words_numbers: false, + time_mode_selected: false, + time_duration: 30, + current_words: None, + stats, + input, + current_text_index, + should_exit, + state, + wpm_history: Vec::new(), + custom_mode_selected: false, + custom_text: None, + custom_index: 0, + using_custom: false, + current_custom: None, + path_input: String::new(), + path_error: None, + passive_active_index: 0, + passive_typed: String::new(), + passive_correct: 0, + passive_total: 0, + passive_start: None, + passive_schedule: Vec::new(), + passive_done: false, + auth_token: None, + logged_in_username: None, + login_register_mode: false, + login_field: LoginField::Username, + login_username: String::new(), + login_password: String::new(), + net_busy: false, + net_status: None, + pending_network_action: None, + leaderboard_mode: "time-30".to_string(), + leaderboard_entries: Vec::new(), + friends_list: Vec::new(), + friends_incoming: Vec::new(), + friends_outgoing: Vec::new(), + friends_add_input: String::new(), + friends_selected: 0, + mp_room_code_input: String::new(), + mp_own_room_code: None, + mp_players: Vec::new(), + mp_local_player_id: None, + mp_countdown: None, + mp_race_text: None, + mp_results: Vec::new(), + mp_status: None, + mp_finish_sent: false, + }) + } + + // Called by the tui binary once its network worker restores a saved + // token from the local config file at startup, so a returning user + // doesn't have to log in again every session. + pub fn restore_session(&mut self, token: String, username: String) { + self.auth_token = Some(token); + self.logged_in_username = Some(username); + } + + pub fn set_net_busy(&mut self, busy: bool) { + self.net_busy = busy; + } + + pub fn set_net_error(&mut self, message: String) { + self.net_busy = false; + self.net_status = Some(message); + } + + pub fn set_login_success(&mut self, username: String, token: Option<String>) { + self.net_busy = false; + self.logged_in_username = Some(username); + if let Some(token) = token { + self.auth_token = Some(token); + } + self.login_password.clear(); + self.net_status = Some("Signed in.".to_string()); + self.state = State::MainMenu; + } + + pub fn set_leaderboard(&mut self, entries: Vec<LeaderboardRow>) { + self.net_busy = false; + self.net_status = None; + self.leaderboard_entries = entries; + } + + pub fn set_friends(&mut self, list: Vec<FriendRow>, incoming: Vec<FriendRow>, outgoing: Vec<FriendRow>) { + self.net_busy = false; + self.net_status = None; + self.friends_list = list; + self.friends_incoming = incoming; + self.friends_outgoing = outgoing; + self.friends_selected = 0; + } + + pub fn set_mp_error(&mut self, message: String) { + self.net_busy = false; + self.mp_status = Some(message); + } + + pub fn set_mp_room_created(&mut self, code: String) { + self.net_busy = false; + self.mp_own_room_code = Some(code); + self.mp_status = None; + } + + pub fn set_mp_joined(&mut self, player_id: String) { + self.net_busy = false; + self.mp_local_player_id = Some(player_id); + self.mp_status = None; + } + + pub fn set_mp_player_list(&mut self, players: Vec<MpPlayer>) { + self.mp_players = players; + } + + pub fn set_mp_countdown(&mut self, seconds: u32) { + self.mp_countdown = Some(seconds); + } + + /// The server hands every racer the identical passage (see + /// crates/server's room lifecycle) - this is what makes current_text() + /// pick it up for the race the same way TypingGame reads its own pool. + pub fn set_mp_start(&mut self, text: String) { + self.mp_countdown = None; + self.mp_race_text = Some(Text { content: text, source: "Multiplayer".to_string(), language: String::new(), category: String::new() }); + self.mp_finish_sent = false; + self.input.clear(); + self.stats = Stats::new(); + self.stats.start(); + self.state = State::MultiplayerRace; + } + + pub fn set_mp_player_progress(&mut self, player_id: &str, percent: f32, wpm: f32) { + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = percent; + p.wpm = wpm; + } + } + + pub fn set_mp_player_finished(&mut self, player_id: &str, name_fallback: &str, wpm: f32, place: u32) { + let name = self.mp_players.iter().find(|p| p.id == player_id).map(|p| p.name.clone()).unwrap_or_else(|| name_fallback.to_string()); + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = 100.0; + p.wpm = wpm; + } + // Deduped by player id, not name - two unauthenticated players both + // default to "Guest", and keying on name silently dropped the + // second Guest's own finish as an apparent duplicate of the first's. + if !self.mp_results.iter().any(|(id, _, _, _)| id == player_id) { + self.mp_results.push((player_id.to_string(), name, wpm, place)); + self.mp_results.sort_by_key(|(_, _, _, place)| *place); + } + // Only the local player's own finish should leave the race screen -- + // the room may keep racing after we're done, and other players' + // PlayerFinished messages arrive well before that happens. + if self.mp_local_player_id.as_deref() == Some(player_id) { + self.state = State::MultiplayerResults; + } + } + + pub fn set_mp_room_closed(&mut self, reason: String) { + self.mp_status = Some(format!("Room closed: {reason}")); + self.state = State::MainMenu; + } + + pub fn reset_multiplayer(&mut self) { + self.mp_room_code_input.clear(); + self.mp_own_room_code = None; + self.mp_players.clear(); + self.mp_local_player_id = None; + self.mp_countdown = None; + self.mp_race_text = None; + self.mp_results.clear(); + self.mp_status = None; + self.mp_finish_sent = false; + self.net_busy = false; + } + + fn handle_backspace_with_rules(&mut self, ctrl: bool) { + if self.input.is_empty() { return; } + let current_text = self.current_text().content.clone(); + let current_text = ¤t_text; + if ctrl { + // Standard word-backward delete: skip any trailing whitespace + // first (e.g. cursor sitting right after a space you just typed), + // then skip back over the word before it. + let chars: Vec<char> = self.input.chars().collect(); + let mut end = chars.len(); + while end > 0 && chars[end - 1].is_whitespace() { + end -= 1; + } + let mut word_start = end; + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + if word_start < self.input.len() { + self.input = chars[..word_start].iter().collect(); + self.update_stats(); + } + return; + } + + // Deleting one character. Only allow crossing into previous word if there are errors before. + let target_pos = self.input.len().saturating_sub(1); + let current_word_start = self.get_current_word_start(); + if target_pos < current_word_start { + if !self.has_errors_before_position(current_text, current_word_start) { + // No errors before; do not allow moving back into previous words + return; + } + } + self.input.pop(); + self.update_stats(); + } + + 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 has_errors_before_position(&self, text: &str, position: usize) -> bool { + let compare_len = self.input.len().min(text.len()); + for (i, (ic, tc)) in self.input.chars().zip(text.chars()).take(compare_len).enumerate() { + if i >= position { break; } + if ic != tc { return true; } + } + false + } + + 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, + // Plain-language prose, not code - leave language empty so + // highlight_classes() doesn't run its string-literal scan + // over apostrophes in contractions and possessives. + language: String::new(), + 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, + words_mode_selected: false, + word_count: 25, + words_punctuation: false, + words_numbers: false, + time_mode_selected: false, + time_duration: 30, + current_words: None, + current_text_index, + stats: Stats::new(), + config, + wpm_history: Vec::new(), + custom_mode_selected: false, + custom_text: None, + custom_index: 0, + using_custom: false, + current_custom: None, + path_input: String::new(), + path_error: None, + passive_active_index: 0, + passive_typed: String::new(), + passive_correct: 0, + passive_total: 0, + passive_start: None, + passive_schedule: Vec::new(), + passive_done: false, + auth_token: None, + logged_in_username: None, + login_register_mode: false, + login_field: LoginField::Username, + login_username: String::new(), + login_password: String::new(), + net_busy: false, + net_status: None, + pending_network_action: None, + leaderboard_mode: "time-30".to_string(), + leaderboard_entries: Vec::new(), + friends_list: Vec::new(), + friends_incoming: Vec::new(), + friends_outgoing: Vec::new(), + friends_add_input: String::new(), + friends_selected: 0, + mp_room_code_input: String::new(), + mp_own_room_code: None, + mp_players: Vec::new(), + mp_local_player_id: None, + mp_countdown: None, + mp_race_text: None, + mp_results: Vec::new(), + mp_status: None, + mp_finish_sent: false, + }) + } + + pub fn reset(&mut self) { + if self.using_custom { + self.start_custom_chunk(); + return; + } + self.input.clear(); + self.stats.reset(); + self.wpm_history.clear(); + if self.words_mode_selected { + let text = generate_words(self.word_count, self.words_punctuation, self.words_numbers); + self.current_words = Some(Text::from_all(&text, "", "en", "words")); + } else if self.time_mode_selected { + let count = word_count_for_duration(self.time_duration); + let text = generate_words(count, self.words_punctuation, self.words_numbers); + self.current_words = Some(Text::from_all(&text, "", "en", "time")); + } else { + self.current_words = None; + self.current_text_index = self.pick_random_index(); + } + } + + const WORD_COUNTS: [usize; 4] = [10, 25, 50, 100]; + const TIME_DURATIONS: [u64; 4] = [15, 30, 60, 120]; + + fn cycle_word_count(&mut self, forward: bool) { + let pos = Self::WORD_COUNTS.iter().position(|&c| c == self.word_count).unwrap_or(1); + let len = Self::WORD_COUNTS.len(); + let next = if forward { (pos + 1) % len } else { (pos + len - 1) % len }; + self.word_count = Self::WORD_COUNTS[next]; + } + + fn cycle_time_duration(&mut self, forward: bool) { + let pos = Self::TIME_DURATIONS.iter().position(|&d| d == self.time_duration).unwrap_or(1); + let len = Self::TIME_DURATIONS.len(); + let next = if forward { (pos + 1) % len } else { (pos + len - 1) % len }; + self.time_duration = Self::TIME_DURATIONS[next]; + } + + 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] + } + + /// Left/Right in the main menu cycle through: Random -> each category -> + /// Custom -> back to Random. Custom is a distinct slot, not a category. + /// Slot order matches the web menu: Random -> Words -> Time -> each + /// category -> Custom -> back to Random. + fn cycle_mode(&mut self, forward: bool) { + let total = self.categories.len() + 4; // Random + Words + Time + categories + Custom + let current_pos = if self.custom_mode_selected { + total - 1 + } else if self.time_mode_selected { + 2 + } else if self.words_mode_selected { + 1 + } else { + match &self.selected_category { + None => 0, + Some(cur) => self.categories.iter().position(|c| c == cur).map(|p| p + 3).unwrap_or(0), + } + }; + let next_pos = if forward { + (current_pos + 1) % total + } else { + (current_pos + total - 1) % total + }; + self.custom_mode_selected = false; + self.words_mode_selected = false; + self.time_mode_selected = false; + self.selected_category = None; + if next_pos == total - 1 { + self.custom_mode_selected = true; + } else if next_pos == 1 { + self.words_mode_selected = true; + } else if next_pos == 2 { + self.time_mode_selected = true; + } else if next_pos != 0 { + self.selected_category = Some(self.categories[next_pos - 3].clone()); + } + } + + fn start_custom_chunk(&mut self) { + if let Some(ct) = &self.custom_text { + if let Some(chunk) = ct.chunks.get(self.custom_index) { + self.current_custom = Some(Text::from_all( + &chunk.content, + &ct.name, + ct.language.as_deref().unwrap_or(""), + "custom", + )); + } + } + self.input.clear(); + self.stats.reset(); + self.wpm_history.clear(); + } + + fn advance_custom_index(&mut self) -> bool { + if let Some(ct) = &self.custom_text { + if self.custom_index + 1 < ct.chunks.len() { + self.custom_index += 1; + return true; + } + } + false + } + + fn load_custom_text(&mut self) { + let path = self.path_input.trim().to_string(); + if path.is_empty() { + self.path_error = Some("Enter a file path.".to_string()); + return; + } + match std::fs::read_to_string(&path) { + Ok(raw) => { + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(&path) + .to_string(); + let ct = parse_custom_content(&raw, &filename); + if ct.chunks.is_empty() { + self.path_error = Some("No typeable text found in that file.".to_string()); + } else { + self.custom_text = Some(ct); + self.custom_mode_selected = true; + self.path_error = None; + self.state = State::MainMenu; + } + } + Err(e) => { + self.path_error = Some(format!("Could not read file: {}", e)); + } + } + } + + fn start_passive(&mut self) { + let Some(ct) = &self.custom_text else { return }; + self.passive_schedule = build_passive_schedule(&ct.chunks); + self.passive_active_index = 0; + self.passive_typed.clear(); + self.passive_correct = 0; + self.passive_total = 0; + self.passive_done = false; + self.passive_start = Some(Instant::now()); + self.state = State::PassiveMode; + } + + fn advance_passive_to(&mut self, target: usize) { + if let Some(ct) = &self.custom_text { + if let Some(chunk) = ct.chunks.get(self.passive_active_index) { + let target_chars: Vec<char> = chunk.content.chars().collect(); + let typed_chars: Vec<char> = self.passive_typed.chars().collect(); + for (i, tc) in typed_chars.iter().enumerate() { + self.passive_total += 1; + if target_chars.get(i) == Some(tc) { + self.passive_correct += 1; + } + } + } + } + self.passive_active_index = target; + self.passive_typed.clear(); + } + + fn update_passive(&mut self) { + if self.passive_done { + return; + } + let Some(start) = self.passive_start else { return }; + let elapsed = start.elapsed().as_secs_f64(); + + let mut target = self.passive_active_index; + while target + 1 < self.passive_schedule.len() && elapsed >= self.passive_schedule[target + 1] { + target += 1; + } + if target != self.passive_active_index { + self.advance_passive_to(target); + } + + let total_duration = self.passive_schedule.last().copied().unwrap_or(0.0) + 4.0; + if elapsed >= total_duration { + self.passive_done = true; + } + } + + pub fn passive_accuracy(&self) -> f64 { + if self.passive_total == 0 { + 100.0 + } else { + (self.passive_correct as f64 / self.passive_total as f64) * 100.0 + } + } + + pub fn handle_input(&mut self, key: KeyEvent) { + match self.state { + State::MainMenu => { + match key.code { + crossterm::event::KeyCode::Enter => { + if self.custom_mode_selected { + if let Some(ct) = &self.custom_text { + if !ct.chunks.is_empty() { + self.using_custom = true; + self.custom_index = 0; + self.state = State::TypingGame; + self.start_custom_chunk(); + } + } else { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Char('p') | crossterm::event::KeyCode::Char('P') + if self.custom_mode_selected => + { + if let Some(ct) = &self.custom_text { + if ct.timed && !ct.chunks.is_empty() { + self.start_passive(); + } + } + } + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') => { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + crossterm::event::KeyCode::Char('x') | crossterm::event::KeyCode::Char('X') + if self.custom_mode_selected => + { + self.custom_text = None; + self.custom_mode_selected = false; + } + crossterm::event::KeyCode::Char('l') | crossterm::event::KeyCode::Char('L') => { + self.net_status = None; + self.state = State::Leaderboard; + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + crossterm::event::KeyCode::Char('a') | crossterm::event::KeyCode::Char('A') => { + self.net_status = None; + self.state = State::Login; + } + crossterm::event::KeyCode::Char('f') | crossterm::event::KeyCode::Char('F') => { + self.net_status = None; + self.state = State::Friends; + if self.logged_in_username.is_some() { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchFriends); + } + } + crossterm::event::KeyCode::Char('m') | crossterm::event::KeyCode::Char('M') => { + self.reset_multiplayer(); + self.state = State::MultiplayerLobby; + } + crossterm::event::KeyCode::Left => self.cycle_mode(false), + crossterm::event::KeyCode::Right => self.cycle_mode(true), + crossterm::event::KeyCode::Up if self.words_mode_selected => { + self.cycle_word_count(true) + } + crossterm::event::KeyCode::Down if self.words_mode_selected => { + self.cycle_word_count(false) + } + crossterm::event::KeyCode::Up if self.time_mode_selected => { + self.cycle_time_duration(true) + } + crossterm::event::KeyCode::Down if self.time_mode_selected => { + self.cycle_time_duration(false) + } + crossterm::event::KeyCode::Esc => { + self.should_exit = true; + } + _ => {} + } + } + State::CustomTextPrompt => match key.code { + crossterm::event::KeyCode::Enter => self.load_custom_text(), + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.path_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + self.path_input.push(c); + } + _ => {} + }, + State::TypingGame => { + match key.code { + crossterm::event::KeyCode::Char(c) => { + // Handle control-word delete (Ctrl+W, or Ctrl+H on terminals + // that send it in place of Ctrl+Backspace). + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && (c == 'w' || c == 'W' || c == 'h' || c == 'H') + { + self.handle_backspace_with_rules(true); + return; + } + // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok) + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + // Record keystroke correctness before mutating input + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + if pos < current_text.len() { + // Compare with target at this position + current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + } else { + false // extra chars are considered incorrect + } + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms + let ctrl_or_alt = key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, + ); + self.handle_backspace_with_rules(ctrl_or_alt); + } + crossterm::event::KeyCode::Tab => { + // Quick restart: abandon the current attempt and start + // a fresh one of the same mode, without leaving the + // typing screen. + self.reset(); + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::PassiveMode => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Enter if self.passive_done => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.passive_typed.pop(); + } + crossterm::event::KeyCode::Char(c) if !self.passive_done => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.passive_typed.push(c); + } + } + _ => {} + }, + State::EndScreen => { + match key.code { + // Tab is the keyboard-shortcut equivalent of Enter here -- + // both play again / advance to the next custom segment. + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Tab => { + if self.using_custom { + if self.advance_custom_index() { + self.state = State::TypingGame; + self.start_custom_chunk(); + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + } + } else { + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::Login => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Tab => { + self.login_field = match self.login_field { + LoginField::Username => LoginField::Password, + LoginField::Password => LoginField::Username, + }; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + self.login_register_mode = !self.login_register_mode; + } + crossterm::event::KeyCode::Enter => { + if self.logged_in_username.is_some() { + // Already signed in - Enter here just backs out, + // there's no form to submit. + self.state = State::MainMenu; + return; + } + if self.login_username.is_empty() || self.login_password.is_empty() { + self.net_status = Some("Enter a username and password.".to_string()); + return; + } + self.net_busy = true; + self.net_status = None; + let username = self.login_username.clone(); + let password = self.login_password.clone(); + self.pending_network_action = Some(if self.login_register_mode { + NetworkAction::Register { username, password } + } else { + NetworkAction::Login { username, password } + }); + } + crossterm::event::KeyCode::Backspace => { + match self.login_field { + LoginField::Username => { self.login_username.pop(); } + LoginField::Password => { self.login_password.pop(); } + } + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + match self.login_field { + LoginField::Username => self.login_username.push(c), + LoginField::Password => self.login_password.push(c), + } + } + } + _ => {} + }, + State::Leaderboard => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') => { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + _ => {} + }, + State::Friends => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Up => { + if self.friends_selected > 0 { self.friends_selected -= 1; } + } + crossterm::event::KeyCode::Down => { + if self.friends_selected + 1 < self.friends_incoming.len() { + self.friends_selected += 1; + } + } + // Guarded on the add-username field being empty - otherwise + // typing a 'y' or 'd' into a username (both letters are legal + // in one, per validate_username) would get eaten as an + // accept/decline instead of landing in the text field. + crossterm::event::KeyCode::Char('y') | crossterm::event::KeyCode::Char('Y') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::AcceptFriendRequest { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Char('d') | crossterm::event::KeyCode::Char('D') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::RemoveFriendship { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Enter => { + if !self.friends_add_input.is_empty() { + self.net_busy = true; + self.net_status = None; + let username = self.friends_add_input.clone(); + self.friends_add_input.clear(); + self.pending_network_action = Some(NetworkAction::SendFriendRequest { username }); + } + } + crossterm::event::KeyCode::Backspace => { + self.friends_add_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.friends_add_input.push(c); + } + } + _ => {} + }, + State::MultiplayerLobby => match key.code { + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + // Only before a room exists - once connected (own room + // created, or joined and appearing in the player list), + // 'c'/'r' below take over instead of these editing the + // now-irrelevant join-code field. + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') + if self.mp_own_room_code.is_none() && self.mp_local_player_id.is_none() => + { + self.net_busy = true; + self.mp_status = None; + self.pending_network_action = Some(NetworkAction::CreateMultiplayerRoom); + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if self.mp_local_player_id.is_some() => + { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::MultiplayerReady); + } + crossterm::event::KeyCode::Enter + if self.mp_local_player_id.is_none() && !self.mp_room_code_input.trim().is_empty() => + { + self.net_busy = true; + self.mp_status = None; + let code = self.mp_room_code_input.trim().to_uppercase(); + self.pending_network_action = Some(NetworkAction::JoinMultiplayerRoom { code }); + } + crossterm::event::KeyCode::Backspace if self.mp_local_player_id.is_none() => { + self.mp_room_code_input.pop(); + } + crossterm::event::KeyCode::Char(c) if self.mp_local_player_id.is_none() => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.mp_room_code_input.push(c); + } + } + _ => {} + }, + State::MultiplayerRace => match key.code { + crossterm::event::KeyCode::Char(c) => { + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + pos < current_text.len() && current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + if !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + State::MultiplayerResults => match key.code { + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + } + + // Check if the current text is finished + if self.state == State::TypingGame && self.is_finished() { + self.state = State::EndScreen; + self.stats.stop(); + } + // Multiplayer doesn't jump to a results screen the instant typing + // finishes - the tui binary's main loop notices is_finished() here, + // sends Finish over the connection, and set_mp_player_finished (see + // above) is what actually transitions to MultiplayerResults, once + // the server confirms it rather than the client assuming it. + if self.state == State::MultiplayerRace && self.is_finished() && self.stats.is_running() { + self.stats.stop(); + } + } + + pub fn update_stats(&mut self) { + if self.state == State::TypingGame || self.state == State::MultiplayerRace { + let current_text = self.current_text().content.clone(); + self.stats.update(&self.input, ¤t_text); + } + } + + // Length-only, matching the web engine's game.rs - an uncorrected + // mistake anywhere in the input must still let the test end once you've + // typed as many characters as the text has. An exact-match requirement + // meant one uncorrected typo anywhere (not just the last character) made + // the test unfinishable, since nothing here caps input at the text's + // length the way the web frontend does. + pub fn is_finished(&self) -> bool { + !self.input.is_empty() && self.input.len() >= self.current_text().content.len() + } + + pub fn current_text(&self) -> &Text { + self.mp_race_text.as_ref() + .or(self.current_custom.as_ref()) + .or(self.current_words.as_ref()) + .unwrap_or(&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.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::EndScreen => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::CustomTextPrompt | State::PassiveMode | State::Login | State::Leaderboard | State::Friends => { + self.state = State::MainMenu; + } + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + 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(); + // Time mode ends on the clock rather than at the end of the + // text, so it needs its own check here - is_finished() only + // fires from a keystroke, and the clock keeps running even if + // the typist pauses. + if self.time_mode_selected + && self.stats.is_running() + && self.stats.elapsed_time().as_secs_f64() >= self.time_duration as f64 + { + self.state = State::EndScreen; + self.stats.stop(); + } + // Sample WPM once per elapsed second to build a compact sparkline + let secs = self.stats.elapsed_time().as_secs() as usize; + while self.wpm_history.len() < secs { + self.wpm_history.push(self.stats.wpm().round() as u64); + } + } else if self.state == State::PassiveMode { + self.update_passive(); + } else if self.state == State::MultiplayerRace { + self.update_stats(); + } + } +} + +const PASSIVE_PACE_WPM: f64 = 130.0; +const PASSIVE_LINE_GAP_SECONDS: f64 = 0.6; + +fn build_passive_schedule(chunks: &[crate::custom_text::CustomChunk]) -> Vec<f64> { + let all_timed = chunks.iter().all(|c| c.time.is_some()); + if all_timed && !chunks.is_empty() { + let base = chunks[0].time.unwrap_or(0.0); + return chunks.iter().map(|c| c.time.unwrap_or(base) - base).collect(); + } + let mut t = 0.0; + let mut offsets = Vec::with_capacity(chunks.len()); + for c in chunks { + offsets.push(t); + let duration = (c.content.chars().count() as f64 / 5.0) / (PASSIVE_PACE_WPM / 60.0); + t += duration + PASSIVE_LINE_GAP_SECONDS; + } + offsets +}
\ 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/custom_text.rs b/crates/core/src/custom_text.rs new file mode 100644 index 0000000..0aff569 --- /dev/null +++ b/crates/core/src/custom_text.rs @@ -0,0 +1,345 @@ +#[derive(Debug, Clone)] +pub struct CustomChunk { + pub content: String, + pub time: Option<f64>, +} + +#[derive(Debug, Clone)] +pub struct CustomText { + pub name: String, + pub chunks: Vec<CustomChunk>, + pub language: Option<String>, + pub timed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SynClass { + Keyword, + StringLit, + Comment, + Number, +} + +fn language_for_filename(filename: &str) -> Option<&'static str> { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "js" | "jsx" | "mjs" | "ts" | "tsx" => Some("javascript"), + "py" => Some("python"), + "rs" => Some("rust"), + "c" | "h" | "cpp" | "hpp" | "cc" | "java" | "go" | "cs" => Some("clike"), + "sh" | "bash" | "zsh" => Some("shell"), + _ => None, + } +} + +fn keywords_for(language: &str) -> &'static [&'static str] { + match language { + "javascript" => &[ + "const", "let", "var", "function", "return", "if", "else", "for", "while", "class", + "import", "export", "from", "new", "this", "async", "await", "try", "catch", + "switch", "case", "break", "continue", "typeof", "null", "undefined", "true", "false", + ], + "python" => &[ + "def", "return", "if", "elif", "else", "for", "while", "class", "import", "from", + "as", "try", "except", "with", "lambda", "None", "True", "False", "pass", "break", + "continue", "yield", "self", + ], + "rust" => &[ + "fn", "let", "mut", "return", "if", "else", "for", "while", "loop", "match", + "struct", "enum", "impl", "trait", "pub", "use", "mod", "self", "Self", "true", + "false", "const", "static", + ], + "clike" => &[ + "int", "float", "double", "char", "void", "if", "else", "for", "while", "return", + "struct", "class", "public", "private", "static", "const", "new", "true", "false", + "null", + ], + "shell" => &[ + "if", "then", "else", "fi", "for", "do", "done", "while", "function", "echo", + "export", "local", + ], + _ => &[], + } +} + +fn line_comment_for(language: &str) -> &'static str { + match language { + "javascript" | "rust" | "clike" => "//", + "shell" | "python" => "#", + _ => "", + } +} + +/// Per-character syntax class for the neutral (untyped) portion of a line of +/// code. Best-effort: a small hand-rolled tokenizer, not a real parser. +pub fn highlight_classes(text: &str, language: Option<&str>) -> Vec<Option<SynClass>> { + let chars: Vec<char> = text.chars().collect(); + let mut classes = vec![None; chars.len()]; + let Some(language) = language else { return classes }; + let keywords = keywords_for(language); + let comment_marker = line_comment_for(language); + + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + + if c == '"' || c == '\'' || c == '`' { + let quote = c; + let start = i; + i += 1; + while i < chars.len() && chars[i] != quote { + if chars[i] == '\\' && i + 1 < chars.len() { + i += 1; + } + i += 1; + } + if i < chars.len() { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::StringLit); + } + continue; + } + + if !comment_marker.is_empty() && matches_at(&chars, i, comment_marker) { + let start = i; + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::Comment); + } + continue; + } + + if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::Number); + } + continue; + } + + if c.is_alphabetic() || c == '_' { + let start = i; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + if keywords.contains(&word.as_str()) { + for j in start..i { + classes[j] = Some(SynClass::Keyword); + } + } + continue; + } + + i += 1; + } + + classes +} + +fn matches_at(chars: &[char], pos: usize, marker: &str) -> bool { + let marker_chars: Vec<char> = marker.chars().collect(); + if pos + marker_chars.len() > chars.len() { + return false; + } + chars[pos..pos + marker_chars.len()] == marker_chars[..] +} + +fn split_long(s: &str, max_len: usize) -> Vec<String> { + if s.chars().count() <= max_len { + return vec![s.to_string()]; + } + let chars: Vec<char> = s.chars().collect(); + let mut parts = Vec::new(); + let mut start = 0; + while start < chars.len() { + let end = (start + max_len).min(chars.len()); + let slice = &chars[start..end]; + let min_cut = (max_len as f64 * 0.4) as usize; + let last_newline = slice.iter().rposition(|&c| c == '\n'); + let last_space = slice.iter().rposition(|&c| c == ' '); + let cut = match (last_newline, last_space) { + (Some(n), _) if n > min_cut => n + 1, + (_, Some(sp)) if sp > min_cut => sp + 1, + _ => slice.len(), + }; + parts.push(chars[start..start + cut].iter().collect::<String>()); + start += cut; + } + parts +} + +pub fn chunk_plain_text(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + let paragraphs: Vec<&str> = trimmed + .split("\n\n") + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .collect(); + let source: Vec<&str> = if paragraphs.len() > 1 { + paragraphs + } else { + trimmed + .split('\n') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect() + }; + + let mut chunks = Vec::new(); + for block in source { + for piece in split_long(block, 400) { + let piece = piece.trim(); + if !piece.is_empty() { + chunks.push(CustomChunk { content: piece.to_string(), time: None }); + } + } + } + chunks +} + +fn time_to_seconds(s: &str) -> Option<f64> { + let normalized = s.replace(',', "."); + let parts: Vec<&str> = normalized.split(':').collect(); + let nums: Result<Vec<f64>, _> = parts.iter().map(|p| p.parse::<f64>()).collect(); + let nums = nums.ok()?; + match nums.len() { + 3 => Some(nums[0] * 3600.0 + nums[1] * 60.0 + nums[2]), + 2 => Some(nums[0] * 60.0 + nums[1]), + 1 => Some(nums[0]), + _ => None, + } +} + +fn parse_srt(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let mut chunks = Vec::new(); + for block in normalized.trim().split("\n\n") { + let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + continue; + } + let mut idx = 0; + if lines[idx].trim().chars().all(|c| c.is_ascii_digit()) { + idx += 1; + } + let mut time = None; + if idx < lines.len() && lines[idx].contains("-->") { + if let Some(start) = lines[idx].split("-->").next() { + time = time_to_seconds(start.trim()); + } + idx += 1; + } + let text = lines[idx..].join(" "); + let text = strip_tags(&text); + if !text.trim().is_empty() { + chunks.push(CustomChunk { content: text.trim().to_string(), time }); + } + } + chunks +} + +fn parse_vtt(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let body = normalized.trim(); + let body = body.strip_prefix("WEBVTT").map(|s| s.trim_start()).unwrap_or(body); + let mut chunks = Vec::new(); + for block in body.split("\n\n") { + let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + continue; + } + let mut idx = 0; + if !lines[idx].contains("-->") { + idx += 1; + } + let mut time = None; + if idx < lines.len() && lines[idx].contains("-->") { + if let Some(start) = lines[idx].split("-->").next() { + time = time_to_seconds(start.trim()); + } + idx += 1; + } + if idx > lines.len() { + continue; + } + let text = lines[idx.min(lines.len())..].join(" "); + let text = strip_tags(&text); + if !text.trim().is_empty() { + chunks.push(CustomChunk { content: text.trim().to_string(), time }); + } + } + chunks +} + +fn parse_lrc(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let mut chunks = Vec::new(); + for line in normalized.lines() { + let line = line.trim(); + if !line.starts_with('[') { + continue; + } + let mut rest = line; + let mut first_time = None; + while let Some(close) = rest.find(']') { + if !rest.starts_with('[') { + break; + } + let stamp = &rest[1..close]; + if stamp.len() >= 5 && stamp.chars().next().unwrap().is_ascii_digit() { + if first_time.is_none() { + first_time = time_to_seconds(stamp); + } + rest = &rest[close + 1..]; + } else { + break; + } + } + let text = rest.trim(); + if !text.is_empty() { + chunks.push(CustomChunk { content: text.to_string(), time: first_time }); + } + } + chunks +} + +fn strip_tags(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_tag = false; + for c in s.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + out +} + +pub fn parse_custom_content(raw: &str, filename: &str) -> CustomText { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + let name = filename.to_string(); + match ext.as_str() { + "srt" => CustomText { name, chunks: parse_srt(raw), language: None, timed: true }, + "vtt" => CustomText { name, chunks: parse_vtt(raw), language: None, timed: true }, + "lrc" => CustomText { name, chunks: parse_lrc(raw), language: None, timed: true }, + _ => { + let language = language_for_filename(filename).map(|s| s.to_string()); + CustomText { name, chunks: chunk_plain_text(raw), language, timed: false } + } + } +} diff --git a/crates/core/src/game.rs b/crates/core/src/game.rs new file mode 100644 index 0000000..1362189 --- /dev/null +++ b/crates/core/src/game.rs @@ -0,0 +1,666 @@ +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> { + 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 { + // Standard word-backward delete: skip any trailing whitespace + // first (e.g. cursor sitting right after a space you just typed), + // then skip back over the word before it. + let chars: Vec<char> = new_input.chars().collect(); + let mut end = chars.len(); + while end > 0 && chars[end - 1].is_whitespace() { + end -= 1; + } + let mut word_start = end; + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + + // Same restriction plain Backspace enforces via + // can_backspace_to_position, applied here too: only reach back + // into an already-completed word when there's an actual + // uncorrected mistake somewhere to fix. Otherwise clamp to the + // start of the word currently being typed. + let current_word_start = self.get_current_word_start(); + if word_start < current_word_start && !self.has_errors_before_position(chars.len()) { + word_start = current_word_start; + } + if word_start >= chars.len() { + return Ok(false); + } + new_input = chars[..word_start].iter().collect(); + } 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) { + self.error_positions.clear(); + + // Everything here counts CHARACTERS, never bytes. It used to mix the + // two: `total_characters_typed` accumulated `self.input.len()` deltas + // (a byte count) while `total_correct_characters` only incremented + // when a char index reached `last_input_length` (that same byte + // count). On ASCII the two are equal and it worked. On any text with + // multi-byte characters the byte count outruns the char index, so the + // correct-character counter never advanced and accuracy collapsed -- + // a Spanish passage reported 6% while every character matched. The + // old byte slicing (`&self.input[..n]`) would also panic outright if + // `n` ever landed inside a multi-byte character. + let input_chars: Vec<char> = self.input.chars().collect(); + let text_chars: Vec<char> = self.text.chars().collect(); + + let mut current_mistakes = 0; + let mut current_streak = 0; + let mut best_streak = 0; + + let new_chars_typed = input_chars.len().saturating_sub(self.last_input_length); + self.total_characters_typed += new_chars_typed as u32; + + let compared = input_chars.len().min(text_chars.len()); + for i in 0..compared { + if input_chars[i] == text_chars[i] { + current_streak += 1; + best_streak = best_streak.max(current_streak); + // Only positions newly reached this update count toward the + // lifetime totals, or every keystroke would re-score the + // whole input from the start. + if i >= self.last_input_length { + self.total_correct_characters += 1; + } + } else { + current_mistakes += 1; + current_streak = 0; + self.error_positions.push(i); + if i >= self.last_input_length { + self.total_errors_made += 1; + } + } + } + + // Anything typed past the end of the passage is a mistake too. + if input_chars.len() > text_chars.len() { + current_mistakes += (input_chars.len() - text_chars.len()) as u32; + if input_chars.len() > self.last_input_length { + let prev_extra = self.last_input_length.saturating_sub(text_chars.len()); + let curr_extra = input_chars.len() - text_chars.len(); + let new_extra = curr_extra.saturating_sub(prev_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 = input_chars.len(); + + // Length-only: an uncorrected mistake anywhere in the input must + // still let the test end once you've reached the end of the text, + // exactly like every other typing-test engine (errors are scored via + // accuracy, not something that blocks completion). Requiring an + // exact match here meant a single uncorrected typo anywhere in the + // text - not just the last character - made the test unfinishable: + // the frontend caps input at text.len(), so once you hit that length + // with any mismatch left in place, you can't type further and + // is_finished() would never become true. + let is_complete = !input_chars.is_empty() && input_chars.len() >= text_chars.len(); + self.is_finished = is_complete; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Regression: accuracy used to be computed by comparing a character index + // against a byte-length counter, so any multi-byte character sent it to + // near zero even when every keystroke was correct. + #[test] + fn accuracy_is_correct_for_multibyte_text() { + let mut game = Game::new(); + game.set_text("años después".to_string()); + game.start(); + game.handle_input("años después").unwrap(); + assert_eq!(game.get_accuracy(), 100.0, "every character matched"); + assert_eq!(game.get_total_mistakes(), 0); + assert!(game.is_finished(), "reaching the end must finish the test"); + } + + #[test] + fn multibyte_mistakes_are_counted_by_character() { + let mut game = Game::new(); + game.set_text("años".to_string()); + game.start(); + // Four characters typed, one of them wrong -> 75%, not a byte-derived + // figure (the passage is five bytes long). + game.handle_input("anos").unwrap(); + assert_eq!(game.get_accuracy(), 75.0); + } + + #[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(); + // Longer than the typed input - typing exactly text.len() characters + // finishes the game (see test_game_completion_detection), which + // blocks backspace entirely. Mid-test, not-yet-complete input is also + // the only way this scenario happens for a real user. + game.set_text("Hello world today".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() { + // Longer than the typed input in each case below - see the comment + // on test_backspace_to_previous_word_with_error for why. + let mut game = Game::new(); + game.set_text("Hello world test today".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 to cross + // into the previous (already-completed) word. Trailing space puts + // the cursor right at the word boundary rather than mid-word -- + // mid-word, ctrl+backspace deleting the current word in progress is + // always allowed regardless of errors, since that's not "reaching + // back" into anything. + let mut game2 = Game::new(); + game2.set_text("Hello world test today".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 today".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, since + // the error in "Hallo" is still uncorrected - this was asserting + // the same "Hallo world " value as the press above it (a copy-paste + // leftover), which would mean this second press did nothing despite + // reporting success. + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo "); + } + + #[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(); + // Longer than every input below - reaching text.len() characters + // finishes the game (see test_game_completion_detection), which + // stops handle_input from processing anything further. + game.set_text("Hello world today".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(); + // Longer than the typed input - see test_backspace_to_previous_word_with_error. + game.set_text("foo bar baz qux end".to_string()); + game.handle_input("fao bar bzz qux").unwrap(); // errors in 'fao' and 'bzz' + + // First press deletes 'qux' - the word currently being typed, always + // allowed regardless of errors since it's not "reaching back" into a + // completed word. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar bzz "); + + // Second press deletes 'bzz' - now the current word, same as above. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar "); + + // Third press crosses from 'bar' back into 'fao' - allowed because + // 'fao' has an uncorrected error. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao "); + } +} 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..5f06405 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,25 @@ +pub mod config; +pub mod custom_text; +pub mod game; +pub mod stats; +pub mod text; +pub mod types; +pub mod words; + +#[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..a25a659 --- /dev/null +++ b/crates/core/src/multiplayer.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +// Wire protocol for the multiplayer WebSocket, shared between the server +// (crates/server) and any Rust client that ends up speaking it. The actual +// room/connection logic (registry, matchmaking, idle timeout) lives in +// crates/server - this file only defines the message shapes, so it has no +// tokio/network dependency of its own. + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ClientMessage { + /// `device_type` lets the server enforce a room's device-matching + /// preference (see ServerMessage::Error and the room's device_filter) -- + /// a room created "desktop only" rejects a mobile joiner right here, + /// before they ever appear in a PlayerList. + Join { name: String, device_type: String }, + Ready, + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerInfo { + pub id: String, + pub name: String, + pub ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ServerMessage { + /// Sent once, only to the connection that just joined - the only way a + /// client can tell which entry in PlayerList is itself, since ids are + /// assigned server-side per connection. + Joined { player_id: String }, + PlayerList { players: Vec<PlayerInfo> }, + Countdown { seconds: u32 }, + Start { text: String }, + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 }, + RoomClosed { reason: String }, + Error { message: String }, +} diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs new file mode 100644 index 0000000..fe94468 --- /dev/null +++ b/crates/core/src/stats.rs @@ -0,0 +1,266 @@ +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, + // Persistent keystroke-level tracking (CLI): + // counts every typed character (excluding control sequences) and how many were incorrect at time of keypress + keystrokes_total: usize, + keystrokes_incorrect: 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, + keystrokes_total: 0, + keystrokes_incorrect: 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; + self.keystrokes_total = 0; + self.keystrokes_incorrect = 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); + } + + // Record a single keypress for persistent accuracy tracking (CLI only). + // If the typed char at the time of keypress was incorrect, mark it as incorrect permanently. + pub fn note_keypress(&mut self, was_correct: bool) { + self.keystrokes_total = self.keystrokes_total.saturating_add(1); + if !was_correct { + self.keystrokes_incorrect = self.keystrokes_incorrect.saturating_add(1); + } + } + + 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 { + // Prefer persistent keystroke accuracy for CLI to avoid resetting to 100% after fixes. + if self.keystrokes_total > 0 { + let correct = (self.keystrokes_total - self.keystrokes_incorrect) as f64; + return (correct / self.keystrokes_total as f64) * 100.0; + } + 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::<usize>::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()); + + // Comparing against a freshly-captured Instant::now() (as this + // asserted before) can never pass - nanosecond-resolution clocks + // don't produce two equal readings taken at different times. What's + // actually being verified is that these got set at all. + assert!(stats.start_time.is_some()); + assert!(stats.end_time.is_some()); + assert_eq!(stats.error_positions, Vec::<usize>::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..7acf927 --- /dev/null +++ b/crates/core/src/ui.rs @@ -0,0 +1,729 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Paragraph, Wrap}, + Frame, +}; +use ratatui::prelude::{Alignment, Line}; + +use crate::app::{App, LoginField, State}; +use crate::custom_text::{highlight_classes, SynClass}; + +pub fn draw(f: &mut Frame, app: &App) { + match app.state { + State::MainMenu => draw_main_menu(f, app), + State::CustomTextPrompt => draw_custom_text_prompt(f, app), + State::TypingGame => draw_typing_game(f, app), + State::PassiveMode => draw_passive_mode(f, app), + State::EndScreen => draw_end_screen(f, app), + State::Login => draw_login(f, app), + State::Leaderboard => draw_leaderboard(f, app), + State::Friends => draw_friends(f, app), + State::MultiplayerLobby => draw_multiplayer_lobby(f, app), + State::MultiplayerRace => draw_multiplayer_race(f, app), + State::MultiplayerResults => draw_multiplayer_results(f, app), + } +} + +pub fn draw_main_menu(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mode_label = if app.custom_mode_selected { + match &app.custom_text { + Some(ct) => format!("Mode: Custom ({}, {} segment{})", ct.name, ct.chunks.len(), if ct.chunks.len() == 1 { "" } else { "s" }), + None => "Mode: Custom (not loaded)".to_string(), + } + } else if app.words_mode_selected { + format!("Mode: Words ({})", app.word_count) + } else if app.time_mode_selected { + format!("Mode: Time ({}s)", app.time_duration) + } else { + let cat = app.selected_category.as_deref().unwrap_or("Random"); + format!("Mode: {}", cat) + }; + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "TYPERPUNK", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled(mode_label, Style::default().fg(Color::Cyan))), + Line::from(Span::from("")), + Line::from(Span::styled("Start: Enter", Style::default())), + Line::from(Span::styled("Change Mode: \u{2190} / \u{2192}", Style::default())), + Line::from(Span::styled("Load Custom Text: c", Style::default())), + ]; + + if app.custom_mode_selected { + if let Some(ct) = &app.custom_text { + if ct.timed { + lines.push(Line::from(Span::styled("Passive Mode: p", Style::default()))); + } + lines.push(Line::from(Span::styled("Clear Custom Text: x", Style::default()))); + } + } + if app.words_mode_selected { + lines.push(Line::from(Span::styled("Word Count: \u{2191} / \u{2193}", Style::default()))); + } + if app.time_mode_selected { + lines.push(Line::from(Span::styled("Time Duration: \u{2191} / \u{2193}", Style::default()))); + } + lines.push(Line::from(Span::from(""))); + let account_label = match &app.logged_in_username { + Some(name) => format!("Account: {} (a)", name), + None => "Sign In: a".to_string(), + }; + lines.push(Line::from(Span::styled(account_label, Style::default()))); + lines.push(Line::from(Span::styled("Leaderboard: l Friends: f Multiplayer: m", Style::default()))); + lines.push(Line::from(Span::styled("Quit: Esc", Style::default()))); + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()), + chunks[0], + ); +} + +pub fn draw_login(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "ACCOUNT", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if let Some(name) = &app.logged_in_username { + lines.push(Line::from(Span::styled(format!("Signed in as {}", name), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Esc: Back", Style::default()))); + } else { + let mode_label = if app.login_register_mode { "Register" } else { "Log In" }; + lines.push(Line::from(Span::styled(format!("Mode: {} (Ctrl+R to switch)", mode_label), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + + let user_style = if app.login_field == LoginField::Username { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let pass_style = if app.login_field == LoginField::Password { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + lines.push(Line::from(Span::styled(format!("Username: {}", app.login_username), user_style))); + lines.push(Line::from(Span::styled(format!("Password: {}", "*".repeat(app.login_password.chars().count())), pass_style))); + lines.push(Line::from(Span::from(""))); + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Tab: switch field Enter: submit Esc: Cancel", Style::default()))); + } + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_leaderboard(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + format!("LEADERBOARD ({})", app.leaderboard_mode), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.net_busy { + lines.push(Line::from(Span::styled("Loading...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } else if app.leaderboard_entries.is_empty() { + lines.push(Line::from(Span::styled("No results yet for this mode.", Style::default().fg(Color::DarkGray)))); + } else { + for (i, row) in app.leaderboard_entries.iter().enumerate() { + let device = if row.device_type == "mobile" { " [mobile]" } else { "" }; + lines.push(Line::from(Span::styled( + format!("#{:<3} {:<20} {:>6.1} wpm {:>5.1}%{}", i + 1, row.username, row.wpm, row.accuracy, device), + Style::default(), + ))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("r: refresh Esc: Back", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_friends(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "FRIENDS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.logged_in_username.is_none() { + lines.push(Line::from(Span::styled("Sign in first - press a from the main menu.", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Esc: Back", Style::default()))); + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); + return; + } + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + + if !app.friends_incoming.is_empty() { + lines.push(Line::from(Span::styled("Requests (y: accept, d: decline):", Style::default().fg(Color::Cyan)))); + for (i, row) in app.friends_incoming.iter().enumerate() { + let marker = if i == app.friends_selected { ">" } else { " " }; + lines.push(Line::from(Span::from(format!("{} {}", marker, row.username)))); + } + lines.push(Line::from(Span::from(""))); + } + + if !app.friends_outgoing.is_empty() { + lines.push(Line::from(Span::styled("Sent:", Style::default().fg(Color::Cyan)))); + for row in &app.friends_outgoing { + lines.push(Line::from(Span::from(format!(" {} (pending)", row.username)))); + } + lines.push(Line::from(Span::from(""))); + } + + lines.push(Line::from(Span::styled("Friends:", Style::default().fg(Color::Cyan)))); + if app.friends_list.is_empty() { + lines.push(Line::from(Span::styled(" No friends yet.", Style::default().fg(Color::DarkGray)))); + } else { + for row in &app.friends_list { + lines.push(Line::from(Span::from(format!(" {}", row.username)))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled(format!("Add: {}", app.friends_add_input), Style::default().fg(Color::Yellow)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Type a username, Enter: send request Esc: Back", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_custom_text_prompt(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "LOAD CUSTOM TEXT", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + "Enter a path to a .txt, .md, code file, .srt/.vtt, or .lrc file:", + Style::default().fg(Color::Gray), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + format!("> {}", app.path_input), + Style::default().fg(Color::Cyan), + )), + Line::from(Span::from("")), + ]; + + if let Some(err) = &app.path_error { + lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red)))); + lines.push(Line::from(Span::from(""))); + } + + lines.push(Line::from(Span::styled("Enter: Load Esc: Cancel", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +// Shared between draw_typing_game and draw_multiplayer_race - both render +// the identical character-by-character correctness coloring against +// app.current_text()/app.input, differing only in surrounding layout (a +// player-progress header above the text in the multiplayer case). +fn build_colored_text(app: &App) -> Vec<Span<'static>> { + let current_text = app.current_text(); + let text_chars: Vec<char> = current_text.content.chars().collect(); + let input_chars: Vec<char> = app.input.chars().collect(); + let language = if current_text.language.is_empty() { None } else { Some(current_text.language.as_str()) }; + let syntax = highlight_classes(¤t_text.content, language); + let mut colored_text: Vec<Span> = Vec::new(); + let cursor_pos = app.input.len(); + + // Long buffers (words/time mode) can run to hundreds of words -- + // centering the whole thing in one wrapped Line would either overflow + // the screen or collide with the anchored stats row at the bottom. + // Show a bounded window around the cursor instead, so the view scrolls + // forward as the typist progresses. This is a no-op for short texts + // (quotes, custom chunks), since the window is wider than they are. + // CHARS_AFTER comfortably covers Words mode's largest buffer (100 words, + // ~585 chars measured) so that mode still shows its whole text upfront + // exactly as before; only Time mode's much longer buffers actually get + // windowed down. + const CHARS_BEFORE: usize = 150; + const CHARS_AFTER: usize = 620; + let win_start = cursor_pos.saturating_sub(CHARS_BEFORE); + let win_end = (cursor_pos + CHARS_AFTER).min(text_chars.len()); + + for (i, &c) in text_chars.iter().enumerate().skip(win_start).take(win_end.saturating_sub(win_start)) { + let style = if i < input_chars.len() { + if input_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + match syntax.get(i).copied().flatten() { + Some(SynClass::Keyword) => Style::default().fg(Color::Magenta), + Some(SynClass::StringLit) => Style::default().fg(Color::Yellow), + Some(SynClass::Comment) => Style::default().fg(Color::DarkGray), + Some(SynClass::Number) => Style::default().fg(Color::Blue), + None => 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); + } + + 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))); + } + } + colored_text +} + +pub fn draw_typing_game(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let colored_text = build_colored_text(app); + + let mut lines = Vec::new(); + if app.using_custom { + if let Some(ct) = &app.custom_text { + lines.push(Line::from(Span::styled( + format!("{} \u{b7} segment {}/{}", ct.name, app.custom_index + 1, ct.chunks.len()), + Style::default().fg(Color::DarkGray), + ))); + } + } + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(colored_text)); + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()) + .wrap(Wrap { trim: true }), + chunks[0], + ); + + // Attribution under text + if !app.current_text().source.is_empty() { + let att_area = ratatui::layout::Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(chunks[0].height.saturating_sub(5)), + width: chunks[0].width, + height: 2, + }; + let attribution_line = Line::from(Span::styled( + format!("- {}", app.current_text().source), + Style::default().fg(Color::Gray), + )); + f.render_widget( + Paragraph::new(vec![attribution_line]) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + att_area, + ); + } + + // Anchored stats: WPM (left), ACC (right), TIME (bottom center) + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(2), width: 20, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + // Time mode counts down to zero instead of counting up, since the test + // ends on the clock rather than at the end of the text. + let time_value = if app.time_mode_selected { + (app.time_duration as f64 - app.stats.elapsed_time().as_secs_f64()).max(0.0) + } else { + app.stats.elapsed_time().as_secs_f64() + }; + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", time_value), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let area = f.size(); + // We don't render a central RESULTS section to avoid duplication. + // We only render anchored stats and bottom buttons. + + // Anchored stats at the edges + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(5), width: 20, height: 2 }; + let buttons_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 20, y: area.y + area.height.saturating_sub(2), width: 40, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + let buttons = Paragraph::new(vec![ + Line::from(Span::styled("Enter / Tab: Play Again", Style::default())), + Line::from(Span::styled("Esc: Main Menu", Style::default())), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); + f.render_widget(buttons, buttons_rect); +} + +fn colorize_against(target: &str, typed: &str) -> Vec<Span<'static>> { + let target_chars: Vec<char> = target.chars().collect(); + let typed_chars: Vec<char> = typed.chars().collect(); + let mut spans = Vec::new(); + for (i, &c) in target_chars.iter().enumerate() { + let style = if i < typed_chars.len() { + if typed_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(c.to_string(), style)); + } + spans +} + +pub fn draw_passive_mode(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let Some(ct) = &app.custom_text else { + f.render_widget( + Paragraph::new("No custom text loaded.").alignment(Alignment::Center), + chunks[0], + ); + return; + }; + + let prev = app.passive_active_index.checked_sub(1).and_then(|i| ct.chunks.get(i)); + let active = ct.chunks.get(app.passive_active_index); + let next = ct.chunks.get(app.passive_active_index + 1); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + format!("{} \u{b7} passive mode", ct.name), + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::from("")), + ]; + + if let Some(p) = prev { + lines.push(Line::from(Span::styled(p.content.clone(), Style::default().fg(Color::DarkGray)))); + } + if let Some(a) = active { + lines.push(Line::from(colorize_against(&a.content, &app.passive_typed))); + } + if let Some(n) = next { + lines.push(Line::from(Span::styled(n.content.clone(), Style::default().fg(Color::DarkGray)))); + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled( + format!("Accuracy: {:.0}%", app.passive_accuracy()), + Style::default().fg(Color::Green), + ))); + + if app.passive_done { + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled( + "Session complete", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + format!("{} lines \u{b7} {:.0}% accuracy", ct.chunks.len(), app.passive_accuracy()), + Style::default().fg(Color::Gray), + ))); + lines.push(Line::from(Span::styled("Enter / Esc: Back to Menu", Style::default()))); + } else { + lines.push(Line::from(Span::styled("Esc: Back to Menu", Style::default().fg(Color::Gray)))); + } + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).wrap(Wrap { trim: true }), + chunks[0], + ); +} + +pub fn draw_multiplayer_lobby(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "MULTIPLAYER", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if let Some(seconds) = app.mp_countdown { + lines.push(Line::from(Span::styled(format!("Starting in {seconds}..."), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + } + + if let Some(code) = &app.mp_own_room_code { + lines.push(Line::from(Span::styled(format!("Room code: {code}"), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::styled("Share this code so someone else can join.", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::from(""))); + } + + if app.mp_local_player_id.is_none() { + lines.push(Line::from(Span::styled(format!("Join code: {}", app.mp_room_code_input), Style::default().fg(Color::Yellow)))); + lines.push(Line::from(Span::from(""))); + } + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.mp_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + + if !app.mp_players.is_empty() { + lines.push(Line::from(Span::styled("Players:", Style::default().fg(Color::Cyan)))); + for p in &app.mp_players { + let is_me = app.mp_local_player_id.as_deref() == Some(p.id.as_str()); + let label = if is_me { format!("{} (you)", p.name) } else { p.name.clone() }; + let ready = if p.ready { "ready" } else { "not ready" }; + lines.push(Line::from(Span::from(format!(" {label} - {ready}")))); + } + lines.push(Line::from(Span::from(""))); + } + + let hint = if app.mp_local_player_id.is_some() { + "r: toggle ready Esc: Leave" + } else if app.mp_own_room_code.is_none() { + "c: Create room Type a code, Enter: Join Esc: Back" + } else { + "Waiting for someone to join with the code above... Esc: Back" + }; + lines.push(Line::from(Span::styled(hint, Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_multiplayer_race(f: &mut Frame, app: &App) { + let area = f.size(); + let header_height = (app.mp_players.len() as u16).max(1) + 2; + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Length(header_height), Constraint::Min(0)]) + .split(area); + + let mut header_lines = vec![Line::from(Span::styled( + "RACE IN PROGRESS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))]; + const BAR_WIDTH: usize = 24; + for p in &app.mp_players { + let is_me = app.mp_local_player_id.as_deref() == Some(p.id.as_str()); + let filled = ((p.progress / 100.0 * BAR_WIDTH as f32).round() as usize).min(BAR_WIDTH); + let bar: String = "#".repeat(filled) + &".".repeat(BAR_WIDTH - filled); + let label = if is_me { format!("{} (you)", p.name) } else { p.name.clone() }; + let color = if is_me { Color::Green } else { Color::Cyan }; + header_lines.push(Line::from(Span::styled( + format!("{:<16} [{bar}] {:>3.0}% {:>3.0}wpm", label, p.progress, p.wpm), + Style::default().fg(color), + ))); + } + f.render_widget(Paragraph::new(header_lines).alignment(Alignment::Center), chunks[0]); + + let colored_text = build_colored_text(app); + f.render_widget( + Paragraph::new(vec![Line::from(Span::from("")), Line::from(colored_text)]) + .alignment(Alignment::Center) + .block(Block::default()) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled(format!("{:.0}", app.stats.wpm()), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))), + ]).alignment(Alignment::Left); + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled(format!("{:.0}%", app.stats.accuracy()), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))), + ]).alignment(Alignment::Right); + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); +} + +pub fn draw_multiplayer_results(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "RESULTS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.mp_results.is_empty() { + lines.push(Line::from(Span::styled("Waiting for results...", Style::default().fg(Color::DarkGray)))); + } else { + for (_id, name, wpm, place) in &app.mp_results { + lines.push(Line::from(Span::styled( + format!("#{place} {name:<16} {wpm:>5.0} wpm"), + Style::default().fg(Color::Cyan), + ))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Enter / Esc: Back to Menu", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} 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/core/src/words.rs b/crates/core/src/words.rs new file mode 100644 index 0000000..3c101c5 --- /dev/null +++ b/crates/core/src/words.rs @@ -0,0 +1,81 @@ +use rand::Rng; + +const COMMON_WORDS: &[&str] = &[ + "the", "be", "to", "of", "and", "a", "in", "that", "have", "it", + "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", + "but", "his", "by", "from", "they", "we", "say", "her", "she", "or", + "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", + "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", + "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", + "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", + "then", "now", "look", "only", "come", "its", "over", "think", "also", "back", + "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", + "new", "want", "because", "any", "these", "give", "day", "most", "us", "water", + "long", "find", "here", "thing", "place", "hand", "part", "child", "eye", "life", + "world", "school", "state", "family", "student", "group", "country", "problem", "fact", "month", + "right", "study", "book", "word", "business", "issue", "side", "kind", "head", "house", + "service", "friend", "father", "power", "hour", "game", "line", "end", "member", "law", + "car", "city", "community", "name", "president", "team", "minute", "idea", "body", "information", + "parent", "face", "others", "level", "office", "door", "health", "person", "art", "war", + "history", "party", "result", "change", "morning", "reason", "research", "girl", "guy", "moment", + "air", "teacher", "force", "education", "foot", "boy", "age", "policy", "process", "music", + "market", "sense", "nation", "plan", "college", "interest", "death", "experience", "effect", "model", +]; + +/// Builds a random passage of the given word count: a flat stream of common +/// words with no fixed meaning, used for count-based practice instead of a +/// memorized quote. `numbers` occasionally swaps a word for a random number; +/// `punctuation` breaks the stream into capitalized, comma- and +/// period-punctuated "sentences" - mirrors web/src/wordGenerator.js. +pub fn generate_words(count: usize, punctuation: bool, numbers: bool) -> String { + let mut rng = rand::thread_rng(); + let mut words: Vec<String> = Vec::with_capacity(count); + let mut since_comma = 0i32; + let mut since_sentence_start = 0i32; + + for i in 0..count { + let mut word = if numbers && rng.gen_bool(0.12) { + rng.gen_range(0..1000).to_string() + } else { + COMMON_WORDS[rng.gen_range(0..COMMON_WORDS.len())].to_string() + }; + + if punctuation { + if since_sentence_start == 0 { + word = capitalize(&word); + } + since_comma += 1; + since_sentence_start += 1; + + let at_end = i == count - 1; + if at_end { + word.push('.'); + } else if since_sentence_start >= 6 && rng.gen_bool(0.15) { + word.push('.'); + since_sentence_start = 0; + } else if since_comma >= 4 && rng.gen_bool(0.2) { + word.push(','); + since_comma = 0; + } + } + + words.push(word); + } + words.join(" ") +} + +fn capitalize(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + None => String::new(), + } +} + +/// For time-based mode the buffer has to outlast the timer regardless of how +/// fast the typist is. 6 words/sec (360 WPM) is well past the sustained +/// world-record typing speed (~216 WPM), so this is generous headroom rather +/// than a tight fit. +pub fn word_count_for_duration(seconds: u64) -> usize { + ((seconds * 6) as usize).max(20) +} diff --git a/crates/server/.env.example b/crates/server/.env.example new file mode 100644 index 0000000..6dabec5 --- /dev/null +++ b/crates/server/.env.example @@ -0,0 +1,17 @@ +DATABASE_URL=sqlite://typerpunk.db +PORT=8787 +FRONTEND_ORIGIN=http://localhost:4173 + +# Shared dataset multiplayer rooms draw race passages from - defaults to +# texts.json in the process's working directory (the repo root, normally). +TEXTS_JSON_PATH=texts.json + +# Set to 1 behind TLS in production. Left off for local HTTP dev, where a +# Secure cookie would silently never be set by the browser. +COOKIE_SECURE=0 + +# Required for the Lyrics mode's "Connect Spotify" flow (phase 5). Register +# an app at https://developer.spotify.com/dashboard to get these - this app +# never asks end users for their own API keys, only its own. +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..d0292fc --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "typerpunk-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk-server" +path = "src/main.rs" + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["multiplayer", "serde"] } + +tokio.workspace = true +axum.workspace = true +axum-extra.workspace = true +tower.workspace = true +tower-http.workspace = true +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +thiserror.workspace = true +argon2.workspace = true +uuid.workspace = true +time.workspace = true +dashmap.workspace = true +dotenvy.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +rand.workspace = true +futures-util.workspace = true +reqwest.workspace = true + +[dev-dependencies] +reqwest = { workspace = true, features = ["cookies"] } diff --git a/crates/server/migrations/0001_users_sessions.sql b/crates/server/migrations/0001_users_sessions.sql new file mode 100644 index 0000000..1fe3cee --- /dev/null +++ b/crates/server/migrations/0001_users_sessions.sql @@ -0,0 +1,14 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); diff --git a/crates/server/migrations/0002_test_results.sql b/crates/server/migrations/0002_test_results.sql new file mode 100644 index 0000000..aa876b5 --- /dev/null +++ b/crates/server/migrations/0002_test_results.sql @@ -0,0 +1,13 @@ +CREATE TABLE test_results ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mode_key TEXT NOT NULL, + wpm REAL NOT NULL, + raw_wpm REAL NOT NULL, + accuracy REAL NOT NULL, + time_seconds REAL NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_test_results_user_id ON test_results(user_id); +CREATE INDEX idx_test_results_mode_key ON test_results(mode_key); diff --git a/crates/server/migrations/0003_friendships.sql b/crates/server/migrations/0003_friendships.sql new file mode 100644 index 0000000..c44e62a --- /dev/null +++ b/crates/server/migrations/0003_friendships.sql @@ -0,0 +1,11 @@ +CREATE TABLE friendships ( + id TEXT PRIMARY KEY, + requester_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted')), + created_at TEXT NOT NULL, + UNIQUE (requester_id, addressee_id) +); + +CREATE INDEX idx_friendships_requester ON friendships(requester_id); +CREATE INDEX idx_friendships_addressee ON friendships(addressee_id); diff --git a/crates/server/migrations/0004_spotify_tokens.sql b/crates/server/migrations/0004_spotify_tokens.sql new file mode 100644 index 0000000..0129660 --- /dev/null +++ b/crates/server/migrations/0004_spotify_tokens.sql @@ -0,0 +1,6 @@ +CREATE TABLE spotify_tokens ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at TEXT NOT NULL +); diff --git a/crates/server/migrations/0005_fairness.sql b/crates/server/migrations/0005_fairness.sql new file mode 100644 index 0000000..28152d6 --- /dev/null +++ b/crates/server/migrations/0005_fairness.sql @@ -0,0 +1,2 @@ +ALTER TABLE test_results ADD COLUMN device_type TEXT NOT NULL DEFAULT 'desktop'; +ALTER TABLE test_results ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0; diff --git a/crates/server/migrations/0006_cosmetics.sql b/crates/server/migrations/0006_cosmetics.sql new file mode 100644 index 0000000..a795e0b --- /dev/null +++ b/crates/server/migrations/0006_cosmetics.sql @@ -0,0 +1,33 @@ +- Scoped to 'caret' and 'flair' only, not 'theme' - the app already has 7 +- free built-in themes (see web/src/theme.js), and selling some themes +- while others stay free in the same picker would be a confusing mix of +- paid and unpaid options in one list. Caret color and a small username +- flair badge are both genuinely new, non-overlapping cosmetic slots. +CREATE TABLE cosmetics ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('caret', 'flair')), + price_cents INTEGER NOT NULL, + - Caret: a CSS color. Flair: a short glyph/emoji shown next to a + - username on the leaderboard and public profile. + value TEXT NOT NULL +); + +CREATE TABLE user_cosmetics ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE, + acquired_at TEXT NOT NULL, + PRIMARY KEY (user_id, cosmetic_id) +); + +ALTER TABLE users ADD COLUMN equipped_caret TEXT; +ALTER TABLE users ADD COLUMN equipped_flair TEXT; + +- A small seed catalog so the store isn't empty on a fresh install. +INSERT INTO cosmetics (id, name, category, price_cents, value) VALUES + ('caret-magenta', 'Magenta Caret', 'caret', 199, '#ff2fb0'), + ('caret-amber', 'Amber Caret', 'caret', 199, '#ffb703'), + ('caret-cyan', 'Cyan Caret', 'caret', 199, '#00e5ff'), + ('flair-star', 'Star', 'flair', 149, 'star'), + ('flair-bolt', 'Bolt', 'flair', 149, 'bolt'), + ('flair-skull', 'Skull', 'flair', 149, 'skull'); diff --git a/crates/server/migrations/0007_api_tokens.sql b/crates/server/migrations/0007_api_tokens.sql new file mode 100644 index 0000000..61b6697 --- /dev/null +++ b/crates/server/migrations/0007_api_tokens.sql @@ -0,0 +1,8 @@ +- Long-lived personal-access tokens for CLI-style clients (the TUI) that +- have no cookie jar. Separate from the browser's session cookie so +- revoking one doesn't touch the other. +CREATE TABLE api_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL +); diff --git a/crates/server/src/anticheat.rs b/crates/server/src/anticheat.rs new file mode 100644 index 0000000..44887f5 --- /dev/null +++ b/crates/server/src/anticheat.rs @@ -0,0 +1,81 @@ +// Heuristic, not proof - this flags a result for exclusion from the public +// leaderboard, it never rejects a submission outright. A false positive +// against a genuine elite typist costs them a leaderboard slot until +// reviewed; a false negative just means a bot's score sits on a personal +// profile nobody but that account holder sees ranked. Rejecting outright +// would make the first kind of mistake much more costly for no real gain +// against the second. + +/// Above the sustained world record (~216 WPM) but generous enough that a +/// genuine top-tier human run doesn't get flagged just for being very fast. +const WPM_CEILING: f64 = 220.0; + +/// Below this many samples, a coefficient-of-variation read is too noisy to +/// act on - a handful of keystrokes can look "uniform" by chance alone. +const MIN_KEYSTROKE_SAMPLES: usize = 20; + +/// Real human inter-keystroke timing varies quite a bit even at a steady +/// pace; a script replaying fixed or near-fixed delays reads as unusually +/// uniform by comparison. This threshold is deliberately loose - it's +/// meant to catch obviously mechanical timing, not to be a precise +/// biometric classifier. +const MIN_PLAUSIBLE_CV: f64 = 0.15; + +pub fn should_flag(wpm: f64, keystroke_intervals_ms: &Option<Vec<f64>>) -> bool { + if wpm > WPM_CEILING { + return true; + } + + if let Some(intervals) = keystroke_intervals_ms { + if intervals.len() >= MIN_KEYSTROKE_SAMPLES { + let mean = intervals.iter().sum::<f64>() / intervals.len() as f64; + if mean > 0.0 { + let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / intervals.len() as f64; + let cv = variance.sqrt() / mean; + if cv < MIN_PLAUSIBLE_CV { + return true; + } + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_impossible_wpm() { + assert!(should_flag(300.0, &None)); + } + + #[test] + fn does_not_flag_ordinary_human_run() { + assert!(!should_flag(85.0, &None)); + } + + #[test] + fn flags_suspiciously_uniform_timing() { + let intervals: Vec<f64> = (0..30).map(|_| 120.0).collect(); + assert!(should_flag(90.0, &Some(intervals))); + } + + #[test] + fn does_not_flag_naturally_varied_timing() { + // Realistic-looking human variation around a 120ms average. + let intervals = vec![ + 95.0, 140.0, 110.0, 160.0, 100.0, 130.0, 90.0, 150.0, 120.0, 105.0, 135.0, 115.0, 145.0, 95.0, + 125.0, 110.0, 155.0, 100.0, 130.0, 120.0, 90.0, 140.0, 105.0, 160.0, 115.0, 135.0, 95.0, 150.0, + 120.0, 110.0, + ]; + assert!(!should_flag(90.0, &Some(intervals))); + } + + #[test] + fn ignores_too_few_samples() { + let intervals: Vec<f64> = (0..5).map(|_| 120.0).collect(); + assert!(!should_flag(90.0, &Some(intervals))); + } +} diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 0000000..d5294c4 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,285 @@ +use crate::error::AppError; +use crate::state::AppState; +use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::{ConnectInfo, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::net::SocketAddr; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +pub const SESSION_COOKIE: &str = "typerpunk_session"; +const SESSION_LIFETIME_DAYS: i64 = 30; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/auth/register", post(register)) + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/me", get(me)) + .route("/api/auth/token", post(issue_token)) +} + +#[derive(Debug, Serialize)] +pub struct UserView { + pub id: String, + pub username: String, +} + +#[derive(Debug, Deserialize)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +// Deliberately permissive-but-bounded: rejects the empty/absurdly-long +// inputs that would either be meaningless or a resource-exhaustion vector +// against argon2 hashing, without being a picky validator of what a +// username "should" look like. +fn validate_username(username: &str) -> Result<(), AppError> { + if username.len() < 3 || username.len() > 24 { + return Err(AppError::InvalidInput("username must be 3-24 characters".into())); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(AppError::InvalidInput("username may only contain letters, numbers, and underscores".into())); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 || password.len() > 256 { + return Err(AppError::InvalidInput("password must be 8-256 characters".into())); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result<String, AppError> { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing failed: {e}"))) +} + +fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { return false }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} + +// `OffsetDateTime::to_string()` is NOT RFC3339 (it's e.g. "2024-01-01 +// 12:34:56.0 +00:00:00"), so timestamps are always written and read back via +// this explicit RFC3339 formatter - mismatching the two silently broke +// session lookups (parse failure treated as "no session found"). +pub(crate) fn format_timestamp(dt: OffsetDateTime) -> String { + dt.format(&time::format_description::well_known::Rfc3339).expect("valid RFC3339 timestamp") +} + +async fn create_session(db: &sqlx::SqlitePool, user_id: &str) -> Result<String, AppError> { + let session_id = uuid::Uuid::new_v4().to_string(); + let expires_at = OffsetDateTime::now_utc() + TimeDuration::days(SESSION_LIFETIME_DAYS); + sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)") + .bind(&session_id) + .bind(user_id) + .bind(format_timestamp(expires_at)) + .execute(db) + .await?; + Ok(session_id) +} + +fn session_cookie(id: String, secure: bool) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE, id)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(TimeDuration::days(SESSION_LIFETIME_DAYS)) + .build() +} + +/// Resolves the signed-in user from the session cookie, if any and unexpired. +pub async fn current_user(db: &sqlx::SqlitePool, jar: &CookieJar) -> Option<UserView> { + let session_id = jar.get(SESSION_COOKIE)?.value().to_string(); + let row = sqlx::query( + "SELECT users.id as id, users.username as username, sessions.expires_at as expires_at + FROM sessions JOIN users ON users.id = sessions.user_id + WHERE sessions.id = ?", + ) + .bind(&session_id) + .fetch_optional(db) + .await + .ok()??; + + let expires_at: String = row.try_get("expires_at").ok()?; + let expires_at = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339).ok()?; + if expires_at < OffsetDateTime::now_utc() { + return None; + } + + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +fn client_ip(addr: &SocketAddr) -> std::net::IpAddr { + addr.ip() +} + +async fn register( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + validate_username(&creds.username)?; + validate_password(&creds.password)?; + + let existing = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(&state.db) + .await?; + if existing.is_some() { + return Err(AppError::UsernameTaken); + } + + let user_id = uuid::Uuid::new_v4().to_string(); + let password_hash = hash_password(&creds.password)?; + sqlx::query("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(&user_id) + .bind(&creds.username) + .bind(&password_hash) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + let session_id = create_session(&state.db, &user_id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(UserView { id: user_id, username: creds.username }))) +} + +/// Verifies username/password against the users table. Shared by the +/// cookie-session login and the CLI-style token login below, so a +/// nonexistent username always takes the same dummy-hash timing path +/// regardless of which endpoint is asking. +async fn authenticate(db: &sqlx::SqlitePool, creds: &Credentials) -> Result<UserView, AppError> { + let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + let _ = verify_password(&creds.password, "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$dummydummydummydummydummydummy"); + return Err(AppError::InvalidCredentials); + }; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let username: String = row.try_get("username").map_err(|e| AppError::Internal(e.into()))?; + let password_hash: String = row.try_get("password_hash").map_err(|e| AppError::Internal(e.into()))?; + + if !verify_password(&creds.password, &password_hash) { + return Err(AppError::InvalidCredentials); + } + + Ok(UserView { id: user_id, username }) +} + +async fn login( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let session_id = create_session(&state.db, &user.id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(user))) +} + +#[derive(Debug, Serialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +// CLI-style clients (the TUI) have no cookie jar to hold a session, and +// replicating browser cookie semantics in a terminal app isn't worth it -- +// this hands back a long-lived bearer token instead, meant to be saved in a +// local config file and sent as `Authorization: Bearer <token>`. +async fn issue_token( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let token = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO api_tokens (token, user_id, created_at) VALUES (?, ?, ?)") + .bind(&token) + .bind(&user.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + Ok(Json(TokenResponse { token, user })) +} + +/// Resolves a signed-in user from a bearer token (see api_tokens above), +/// for clients with no cookie jar. +pub async fn user_from_token(db: &sqlx::SqlitePool, token: &str) -> Option<UserView> { + let row = sqlx::query( + "SELECT users.id as id, users.username as username + FROM api_tokens JOIN users ON users.id = api_tokens.user_id + WHERE api_tokens.token = ?", + ) + .bind(token) + .fetch_optional(db) + .await + .ok()??; + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +/// Cookie session first (the browser's path), then an `Authorization: +/// Bearer <token>` header (the CLI's path) - lets a handler serve both +/// kinds of client without needing to know which one it's talking to. +pub async fn current_user_or_token( + db: &sqlx::SqlitePool, + jar: &CookieJar, + headers: &axum::http::HeaderMap, +) -> Option<UserView> { + if let Some(user) = current_user(db, jar).await { + return Some(user); + } + let token = headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ")?; + user_from_token(db, token).await +} + +async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + if let Some(cookie) = jar.get(SESSION_COOKIE) { + sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(cookie.value()) + .execute(&state.db) + .await?; + } + let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + Ok(jar) +} + +async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + match current_user(&state.db, &jar).await { + Some(user) => Ok(Json(user)), + None => Err(AppError::Unauthorized), + } +} diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs new file mode 100644 index 0000000..b3eee28 --- /dev/null +++ b/crates/server/src/cosmetics.rs @@ -0,0 +1,153 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/cosmetics", get(list_catalog)) + .route("/api/cosmetics/me", get(my_cosmetics)) + .route("/api/cosmetics/:id/purchase", post(purchase)) + .route("/api/cosmetics/:id/equip", post(equip)) + .route("/api/cosmetics/unequip", post(unequip)) +} + +#[derive(Debug, Serialize)] +struct Cosmetic { + id: String, + name: String, + category: String, + price_cents: i64, + value: String, +} + +async fn list_catalog(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { + let rows = sqlx::query("SELECT id, name, category, price_cents, value FROM cosmetics ORDER BY category, price_cents") + .fetch_all(&state.db) + .await?; + let items: Vec<Cosmetic> = rows + .into_iter() + .map(|row| Cosmetic { + id: row.try_get("id").unwrap_or_default(), + name: row.try_get("name").unwrap_or_default(), + category: row.try_get("category").unwrap_or_default(), + price_cents: row.try_get("price_cents").unwrap_or_default(), + value: row.try_get("value").unwrap_or_default(), + }) + .collect(); + Ok(Json(items)) +} + +#[derive(Debug, Serialize)] +struct MyCosmetics { + owned: Vec<String>, + equipped_caret: Option<String>, + equipped_flair: Option<String>, +} + +async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let owned_rows = sqlx::query("SELECT cosmetic_id FROM user_cosmetics WHERE user_id = ?") + .bind(&user.id) + .fetch_all(&state.db) + .await?; + let owned: Vec<String> = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect(); + + let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair FROM users WHERE id = ?") + .bind(&user.id) + .fetch_one(&state.db) + .await?; + + Ok(Json(MyCosmetics { + owned, + // Explicit Option<String> turbofish - try_get(...).ok() here was + // ambiguous enough that type inference picked plain String, and + // sqlx's SQLite decode of a NULL column into String silently + // produced an empty string instead of erroring the way decoding + // into Option<String> correctly does. Found via a real NULL column + // round-tripping as "" instead of JSON null. + equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None), + equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None), + })) +} + +// Stub: grants ownership immediately with no actual charge. Wiring a real +// payment processor (Stripe or otherwise) needs the project owner's own +// merchant account - same situation as the Spotify integration needing its +// own developer credentials. This endpoint is the seam a real charge would +// slot into later without changing the ownership/equip logic around it. +async fn purchase(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let exists = sqlx::query("SELECT id FROM cosmetics WHERE id = ?") + .bind(&cosmetic_id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + + sqlx::query("INSERT OR IGNORE INTO user_cosmetics (user_id, cosmetic_id, acquired_at) VALUES (?, ?, ?)") + .bind(&user.id) + .bind(&cosmetic_id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn equip(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let row = sqlx::query( + "SELECT cosmetics.category as category FROM cosmetics + JOIN user_cosmetics ON user_cosmetics.cosmetic_id = cosmetics.id + WHERE cosmetics.id = ? AND user_cosmetics.user_id = ?", + ) + .bind(&cosmetic_id) + .bind(&user.id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::InvalidInput("you don't own this cosmetic".into()))?; + + let category: String = row.try_get("category").map_err(|e| AppError::Internal(e.into()))?; + let column = match category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::Internal(anyhow::anyhow!("unknown cosmetic category"))), + }; + + // Column name comes from a hardcoded match above, never from request + // input, so this is safe despite not being a bind parameter. + let query = format!("UPDATE users SET {column} = ? WHERE id = ?"); + sqlx::query(&query).bind(&cosmetic_id).bind(&user.id).execute(&state.db).await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct UnequipRequest { + category: String, +} + +async fn unequip(State(state): State<Arc<AppState>>, jar: CookieJar, Json(body): Json<UnequipRequest>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let column = match body.category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::InvalidInput("category must be 'caret' or 'flair'".into())), + }; + let query = format!("UPDATE users SET {column} = NULL WHERE id = ?"); + sqlx::query(&query).bind(&user.id).execute(&state.db).await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..639dc09 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,52 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +// A single error type for every handler, so failure modes map to HTTP status +// consistently instead of each handler picking its own. Messages returned to +// the client are deliberately generic for anything auth-related - see +// AppError::InvalidCredentials - so a failed login never reveals whether +// the username exists. +#[derive(Debug)] +pub enum AppError { + InvalidCredentials, + UsernameTaken, + InvalidInput(String), + Unauthorized, + NotFound, + NotConfigured(String), + RateLimited, + Internal(anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid username or password".to_string()), + AppError::UsernameTaken => (StatusCode::CONFLICT, "username already taken".to_string()), + AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()), + AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + AppError::NotConfigured(msg) => (StatusCode::NOT_IMPLEMENTED, msg.clone()), + AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later".to_string()), + AppError::Internal(err) => { + tracing::error!("internal error: {err:#}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +impl From<anyhow::Error> for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err) + } +} + +impl From<sqlx::Error> for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::Internal(err.into()) + } +} diff --git a/crates/server/src/friends.rs b/crates/server/src/friends.rs new file mode 100644 index 0000000..e720dbf --- /dev/null +++ b/crates/server/src/friends.rs @@ -0,0 +1,193 @@ +use crate::auth::{current_user_or_token, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::HeaderMap; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/friends", get(list_friends)) + .route("/api/friends/request", post(send_request)) + .route("/api/friends/:id/accept", post(accept_request)) + .route("/api/friends/:id", delete(remove_friendship)) +} + +#[derive(Debug, Serialize)] +struct FriendEntry { + friendship_id: String, + user_id: String, + username: String, +} + +#[derive(Debug, Serialize)] +struct FriendsList { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn row_to_entry(row: &sqlx::sqlite::SqliteRow, friendship_id_col: &str, user_id_col: &str, username_col: &str) -> FriendEntry { + FriendEntry { + friendship_id: row.try_get(friendship_id_col).unwrap_or_default(), + user_id: row.try_get(user_id_col).unwrap_or_default(), + username: row.try_get(username_col).unwrap_or_default(), + } +} + +async fn list_friends(State(state): State<Arc<AppState>>, jar: CookieJar, headers: HeaderMap) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + // Accepted, in either direction - the "other" user is whichever side + // isn't us, so this always returns the friend's identity regardless of + // who originally sent the request. + let accepted = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships + JOIN users ON users.id = CASE WHEN friendships.requester_id = ? THEN friendships.addressee_id ELSE friendships.requester_id END + WHERE friendships.status = 'accepted' AND (friendships.requester_id = ? OR friendships.addressee_id = ?)", + ) + .bind(&user.id).bind(&user.id).bind(&user.id) + .fetch_all(&state.db) + .await?; + + let incoming = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.requester_id + WHERE friendships.status = 'pending' AND friendships.addressee_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + let outgoing = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.addressee_id + WHERE friendships.status = 'pending' AND friendships.requester_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + Ok(Json(FriendsList { + friends: accepted.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + incoming_requests: incoming.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + outgoing_requests: outgoing.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + })) +} + +#[derive(Debug, Deserialize)] +struct FriendRequestBody { + username: String, +} + +async fn send_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Json(body): Json<FriendRequestBody>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let target = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&body.username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let target_id: String = target.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + + if target_id == user.id { + return Err(AppError::InvalidInput("can't friend yourself".into())); + } + + let existing = sqlx::query( + "SELECT id, requester_id, status FROM friendships + WHERE (requester_id = ? AND addressee_id = ?) OR (requester_id = ? AND addressee_id = ?)", + ) + .bind(&user.id).bind(&target_id) + .bind(&target_id).bind(&user.id) + .fetch_optional(&state.db) + .await?; + + if let Some(row) = existing { + let status: String = row.try_get("status").unwrap_or_default(); + let requester_id: String = row.try_get("requester_id").unwrap_or_default(); + if status == "accepted" { + return Err(AppError::InvalidInput("already friends".into())); + } + // The other person already sent a request - accept it instead of + // creating a second, redundant pending row in the opposite direction. + if requester_id == target_id { + let id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + sqlx::query("UPDATE friendships SET status = 'accepted' WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await?; + return Ok(axum::http::StatusCode::NO_CONTENT); + } + return Err(AppError::InvalidInput("request already pending".into())); + } + + sqlx::query("INSERT INTO friendships (id, requester_id, addressee_id, status, created_at) VALUES (?, ?, ?, 'pending', ?)") + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&target_id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn accept_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query( + "UPDATE friendships SET status = 'accepted' WHERE id = ? AND addressee_id = ? AND status = 'pending'", + ) + .bind(&id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} + +// Covers declining an incoming request, cancelling one you sent, and +// unfriending an accepted one - all three are just "delete the row", scoped +// to rows the caller is actually a party to. +async fn remove_friendship( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query("DELETE FROM friendships WHERE id = ? AND (requester_id = ? OR addressee_id = ?)") + .bind(&id) + .bind(&user.id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/lyrics.rs b/crates/server/src/lyrics.rs new file mode 100644 index 0000000..d7c8710 --- /dev/null +++ b/crates/server/src/lyrics.rs @@ -0,0 +1,67 @@ +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub fn router() -> Router<Arc<AppState>> { + Router::new().route("/api/lyrics", get(get_lyrics)) +} + +#[derive(Debug, Deserialize)] +pub struct LyricsQuery { + pub artist: String, + pub track: String, + pub duration: Option<u64>, +} + +#[derive(Debug, Deserialize)] +struct LrclibResponse { + #[serde(rename = "syncedLyrics")] + synced_lyrics: Option<String>, + #[serde(rename = "plainLyrics")] + plain_lyrics: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LyricsResult { + /// Synced (.lrc-format) lyrics, when lrclib has them - feed straight + /// into the same parseCustomContent('lyrics.lrc') path Custom Text + /// already uses for uploaded .lrc files. + pub lrc: Option<String>, + /// Untimed fallback when only plain lyrics exist - still typeable, just + /// without the sync-to-playback behavior the Lyrics mode is built for. + pub plain: Option<String>, +} + +async fn get_lyrics(State(state): State<Arc<AppState>>, Query(q): Query<LyricsQuery>) -> Result<impl IntoResponse, AppError> { + if q.artist.trim().is_empty() || q.track.trim().is_empty() { + return Err(AppError::InvalidInput("artist and track are required".into())); + } + + let mut req = state + .http + .get("https://lrclib.net/api/get") + .query(&[("artist_name", q.artist.as_str()), ("track_name", q.track.as_str())]); + if let Some(duration) = q.duration { + req = req.query(&[("duration", duration)]); + } + + let res = req.send().await.map_err(|e| AppError::Internal(e.into()))?; + if res.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AppError::NotFound); + } + if !res.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!("lrclib returned {}", res.status()))); + } + + let body: LrclibResponse = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + if body.synced_lyrics.is_none() && body.plain_lyrics.is_none() { + return Err(AppError::NotFound); + } + + Ok(Json(LyricsResult { lrc: body.synced_lyrics, plain: body.plain_lyrics })) +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..7e9e9a8 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,255 @@ +mod anticheat; +mod auth; +mod cosmetics; +mod error; +mod friends; +mod lyrics; +mod multiplayer; +mod rate_limit; +mod spotify; +mod state; +mod stats; + +use axum::http::{HeaderValue, Method}; +use axum::routing::get; +use axum::Router; +use serde::Deserialize; +use state::{AppState, SpotifyConfig}; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +#[derive(Deserialize)] +struct TextEntry { + content: String, +} + +// Every multiplayer room draws from this same pool so every player in a +// room races the identical passage. Reuses the dataset already shared by +// the CLI and web app instead of maintaining a separate word list here -- +// falls back to a couple of plain sentences if the file isn't reachable +// (e.g. the server binary run from somewhere other than the repo root), +// so a room can still start rather than erroring on an empty pool. +fn load_race_texts() -> Vec<String> { + let path = std::env::var("TEXTS_JSON_PATH").unwrap_or_else(|_| "texts.json".to_string()); + match std::fs::read_to_string(&path).ok().and_then(|raw| serde_json::from_str::<Vec<TextEntry>>(&raw).ok()) { + Some(entries) if !entries.is_empty() => entries.into_iter().map(|e| e.content).collect(), + _ => { + tracing::warn!("could not load race texts from {path} - using a small built-in fallback pool"); + vec![ + "The quick brown fox jumps over the lazy dog.".to_string(), + "Pack my box with five dozen liquor jugs.".to_string(), + ] + } + } +} + +// Pulled out of main() so tests can assemble the exact same router against +// an in-memory test database, instead of a parallel hand-maintained copy of +// this list drifting out of sync with the real one. +fn build_app(app_state: Arc<AppState>) -> Router { + Router::new() + .route("/api/health", get(|| async { "ok" })) + .merge(auth::router()) + .merge(stats::router()) + .merge(friends::router()) + .merge(cosmetics::router()) + .merge(multiplayer::router()) + .merge(spotify::router()) + .merge(lyrics::router()) + .with_state(app_state) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Ignored if absent - production deployments are expected to set real + // env vars directly rather than ship a .env file. + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://typerpunk.db".to_string()); + let cookie_secure = std::env::var("COOKIE_SECURE").map(|v| v == "1" || v == "true").unwrap_or(false); + let frontend_origin = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:4173".to_string()); + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8787); + + let connect_options = sqlx::sqlite::SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); + let db = sqlx::sqlite::SqlitePoolOptions::new().max_connections(10).connect_with(connect_options).await?; + sqlx::migrate!("./migrations").run(&db).await?; + + if !cookie_secure { + tracing::warn!("COOKIE_SECURE is off - session cookies will be sent over plain HTTP. Set COOKIE_SECURE=1 behind TLS in production."); + } + + let cors = CorsLayer::new() + .allow_origin(frontend_origin.parse::<HeaderValue>()?) + .allow_credentials(true) + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + + let spotify_config = SpotifyConfig { + client_id: std::env::var("SPOTIFY_CLIENT_ID").unwrap_or_default(), + client_secret: std::env::var("SPOTIFY_CLIENT_SECRET").unwrap_or_default(), + redirect_uri: std::env::var("SPOTIFY_REDIRECT_URI") + .unwrap_or_else(|_| format!("http://localhost:{port}/api/spotify/callback")), + }; + if !spotify_config.is_configured() { + tracing::warn!("SPOTIFY_CLIENT_ID/SECRET not set - the Lyrics mode's Spotify connection will return 501 until configured."); + } + + let race_texts = load_race_texts(); + let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, frontend_origin.clone())); + + let app = build_app(app_state).layer(cors).layer(TraceLayer::new_for_http()); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("typerpunk-server listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?; + + Ok(()) +} + +// Real HTTP integration tests: each spins up the actual router (build_app, +// the same function main() uses) against an in-memory database on a random +// local port, and drives it with a real reqwest client - exercising the +// full stack (routing, extractors, cookies, JSON) rather than calling +// handler functions directly, which only proves the inner logic works and +// not that the wiring around it does too. +#[cfg(test)] +mod tests { + use super::*; + use state::SpotifyConfig; + + async fn spawn_test_server() -> String { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to open in-memory test database"); + sqlx::migrate!("./migrations").run(&db).await.expect("failed to run migrations"); + + let app_state = Arc::new(AppState::new( + db, + false, + vec!["The quick brown fox jumps over the lazy dog.".to_string()], + SpotifyConfig::default(), + "http://localhost:4173".to_string(), + )); + let app = build_app(app_state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("failed to bind test listener"); + let addr = listener.local_addr().expect("test listener has no local addr"); + tokio::spawn(async move { + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.ok(); + }); + format!("http://{addr}") + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder().cookie_store(true).build().expect("failed to build test http client") + } + + async fn register(client: &reqwest::Client, base: &str, username: &str) { + let resp = client + .post(format!("{base}/api/auth/register")) + .json(&serde_json::json!({ "username": username, "password": "correcthorsebattery1" })) + .send() + .await + .expect("register request failed"); + assert!(resp.status().is_success(), "register failed for {username}: {}", resp.status()); + } + + #[tokio::test] + async fn mutual_friend_request_accept() { + let base = spawn_test_server().await; + let alice = test_client(); + let bob = test_client(); + register(&alice, &base, "alice_friend_test").await; + register(&bob, &base, "bob_friend_test").await; + + let resp = alice + .post(format!("{base}/api/friends/request")) + .json(&serde_json::json!({ "username": "bob_friend_test" })) + .send() + .await + .expect("friend request failed"); + assert!(resp.status().is_success(), "friend request should succeed: {}", resp.status()); + + // Bob should see Alice as an incoming request before accepting. + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let incoming = bob_list["incoming_requests"].as_array().expect("incoming_requests missing"); + assert_eq!(incoming.len(), 1, "bob should have exactly one incoming request"); + let friendship_id = incoming[0]["friendship_id"].as_str().expect("friendship_id missing").to_string(); + assert_eq!(incoming[0]["username"], "alice_friend_test"); + + let resp = bob.post(format!("{base}/api/friends/{friendship_id}/accept")).send().await.unwrap(); + assert!(resp.status().is_success(), "accept should succeed: {}", resp.status()); + + // After accepting, both sides should list each other as an + // accepted friend, and neither should have any pending requests + // left over. + let alice_list: serde_json::Value = + alice.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let alice_friends = alice_list["friends"].as_array().unwrap(); + assert_eq!(alice_friends.len(), 1, "alice should have exactly one friend"); + assert_eq!(alice_friends[0]["username"], "bob_friend_test"); + assert!(alice_list["outgoing_requests"].as_array().unwrap().is_empty()); + + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let bob_friends = bob_list["friends"].as_array().unwrap(); + assert_eq!(bob_friends.len(), 1, "bob should have exactly one friend"); + assert_eq!(bob_friends[0]["username"], "alice_friend_test"); + assert!(bob_list["incoming_requests"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn leaderboard_ranks_by_wpm_descending() { + let base = spawn_test_server().await; + let racers = [("leaderboard_low", 40.0), ("leaderboard_high", 95.0), ("leaderboard_mid", 70.0)]; + + for (name, wpm) in racers { + let client = test_client(); + register(&client, &base, name).await; + let resp = client + .post(format!("{base}/api/stats")) + .json(&serde_json::json!({ + "mode_key": "time-30", + "wpm": wpm, + "raw_wpm": wpm, + "accuracy": 98.0, + "time_seconds": 30.0, + "device_type": "desktop", + })) + .send() + .await + .expect("stats submission failed"); + assert!(resp.status().is_success(), "stats submission should succeed for {name}: {}", resp.status()); + } + + let anon = test_client(); + let entries: serde_json::Value = anon + .get(format!("{base}/api/leaderboard?mode=time-30")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let entries = entries.as_array().expect("leaderboard should return an array"); + assert_eq!(entries.len(), 3, "expected all three submitted results to appear"); + + let names: Vec<&str> = entries.iter().map(|e| e["username"].as_str().unwrap()).collect(); + assert_eq!( + names, + vec!["leaderboard_high", "leaderboard_mid", "leaderboard_low"], + "leaderboard should be ordered by wpm descending" + ); + } +} diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs new file mode 100644 index 0000000..54df6b2 --- /dev/null +++ b/crates/server/src/multiplayer.rs @@ -0,0 +1,594 @@ +use crate::state::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures_util::{SinkExt, StreamExt}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Mutex}; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +// A stalled connection (tab closed without a clean disconnect, laptop went +// to sleep mid-race) otherwise holds its room slot and the room's resources +// forever - there is no other cleanup path. This is a hard requirement +// carried over from the original design note on this module, not a nicety: +// every connection is force-closed after this long with no activity. +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(5); +// How many players quick match will pack into one room before opening a new +// one. Joining by code is deliberately not capped by this - a group racing +// each other on a shared code decides its own size; this only bounds the +// rooms quick match hands out to strangers. +const QUICK_MATCH_CAPACITY: usize = 5; +// A brand-new game has nobody in it, and an empty lobby is where a player +// leaves and does not come back. Quick-match rooms fill with bots after a +// short wait so there is always a race to join. They are ordinary room +// members: they appear in the player list, they are typed against, and they +// can be beaten. +const BOT_JOIN_DELAY: Duration = Duration::from_secs(6); +const BOT_TICK: Duration = Duration::from_millis(250); +// Two clearly separated tiers rather than one wide range: a room of bots that +// all land within a few WPM of each other reads as one opponent duplicated, +// and gives a human nothing to place against. A bot takes the tier the room +// does not already have, so two bots are never near each other's pace. +const BOT_SLOW_WPM: (f32, f32) = (34.0, 48.0); +const BOT_FAST_WPM: (f32, f32) = (72.0, 88.0); +// Nobody types perfectly. A bot's accuracy sets both what it reports at the +// finish and how often it stalls mid-race to "correct" itself. +const BOT_MIN_ACCURACY: f32 = 88.0; +const BOT_MAX_ACCURACY: f32 = 99.0; +// How long a correction costs, in ticks. +const BOT_CORRECTION_TICKS: u32 = 2; +const MAX_BOTS_PER_ROOM: usize = 2; +const BOT_NAMES: &[&str] = &[ + "Ghostwire", "NullPointer", "Kanji", "Sable", "Vex", "Orbit", "Static", + "Halcyon", "Nyx", "Drift", "Ember", "Kilo", "Rune", "Zephyr", "Onyx", + "Pixel", "Quartz", "Sigil", "Tessa", "Umbra", +]; + +const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud + +#[derive(Debug, Clone, PartialEq)] +enum RoomStatus { + Lobby, + Countdown, + Racing, + Finished, +} + +pub(crate) struct Player { + name: String, + ready: bool, + sender: mpsc::UnboundedSender<Message>, + /// Bots have no socket behind `sender`; the receiver is dropped the moment + /// they are created, so broadcasts to them fail silently and harmlessly. + is_bot: bool, + /// Only meaningful for bots: the pace they type at. + target_wpm: f32, + /// Only meaningful for bots: the accuracy they finish with. + target_accuracy: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) enum DeviceFilter { + #[default] + Everyone, + DesktopOnly, +} + +pub(crate) struct Room { + players: HashMap<String, Player>, + status: RoomStatus, + text: Option<String>, + finish_order: Vec<String>, + device_filter: DeviceFilter, + /// Whether the room may be filled with bots while it waits. True only for + /// quick match: a room you opened to race specific friends must stay + /// exactly as empty as you left it until they arrive. + allow_bots: bool, +} + +impl Room { + fn new(device_filter: DeviceFilter) -> Self { + Self { players: HashMap::new(), status: RoomStatus::Lobby, text: None, finish_order: Vec::new(), device_filter, allow_bots: false } + } + + fn new_quick_match(device_filter: DeviceFilter) -> Self { + Self { allow_bots: true, ..Self::new(device_filter) } + } + + fn human_count(&self) -> usize { + self.players.values().filter(|p| !p.is_bot).count() + } +} + +pub type RoomRegistry = Arc<dashmap::DashMap<String, Arc<Mutex<Room>>>>; + +pub fn new_registry() -> RoomRegistry { + Arc::new(dashmap::DashMap::new()) +} + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/multiplayer/rooms", post(create_room)) + .route("/api/multiplayer/quickmatch", post(quick_match)) + .route("/api/multiplayer/online", get(online_count)) + .route("/ws/multiplayer/:room_code", get(ws_handler)) +} + +#[derive(Debug, Serialize)] +struct CreateRoomResponse { + room_code: String, +} + +#[derive(Debug, Deserialize, Default)] +struct CreateRoomRequest { + /// "desktop_only" to reject mobile joiners at the door; anything else + /// (including absent) means no restriction. + #[serde(default)] + device_filter: Option<String>, +} + +fn generate_room_code() -> String { + let mut rng = rand::thread_rng(); + (0..5).map(|_| ROOM_CODE_CHARS[rng.gen_range(0..ROOM_CODE_CHARS.len())] as char).collect() +} + +// Body is optional - a plain POST with no JSON at all (or an empty object) +// is a normal "everyone" room, not a client error. +async fn create_room(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +// Auto-matchmaking: drop the caller into whichever room is still open, and +// only create one when none is. This is the path the "Find a Race" button +// uses, so a player never has to see or exchange a room code - codes remain +// for the deliberate "race my friends" case. +// +// try_lock, never lock: a room mid-countdown holds its own lock for the full +// three seconds on purpose (see the Ready handler), and quick match must not +// stall behind that - a room it cannot inspect right now is simply not a +// candidate. Room handles are cloned out of the registry before any locking +// so no DashMap shard guard is ever held while locking a room. +async fn quick_match(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let candidates: Vec<(String, Arc<Mutex<Room>>)> = + state.rooms.iter().map(|e| (e.key().clone(), e.value().clone())).collect(); + + for (code, room) in candidates { + let Ok(room) = room.try_lock() else { continue }; + if room.status == RoomStatus::Lobby + && room.device_filter == device_filter + && room.players.len() < QUICK_MATCH_CAPACITY + { + return Json(CreateRoomResponse { room_code: code }); + } + } + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new_quick_match(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +#[derive(Serialize)] +struct OnlineResponse { + players: usize, + rooms: usize, +} + +// How many people are in multiplayer rooms right now, for the header's live +// counter. Public and unauthenticated - it is a "is anyone around to race" +// signal shown before sign-in, so gating it would defeat the point. +// +// try_lock for the same reason quick_match uses it: a room mid-countdown +// holds its lock, and a counter must never stall a page load. A room that +// cannot be read this instant is skipped rather than waited on, so the figure +// is a lower bound under contention, never a hang. +async fn online_count(State(state): State<Arc<AppState>>) -> impl IntoResponse { + let rooms: Vec<Arc<Mutex<Room>>> = state.rooms.iter().map(|e| e.value().clone()).collect(); + let mut players = 0; + let mut counted_rooms = 0; + for room in rooms { + if let Ok(room) = room.try_lock() { + players += room.players.len(); + if !room.players.is_empty() { + counted_rooms += 1; + } + } + } + Json(OnlineResponse { players, rooms: counted_rooms }) +} + +async fn ws_handler( + ws: WebSocketUpgrade, + Path(room_code): Path<String>, + State(state): State<Arc<AppState>>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, room_code, state)) +} + +async fn broadcast(room: &Room, msg: &ServerMessage) { + let Ok(text) = serde_json::to_string(msg) else { return }; + for player in room.players.values() { + let _ = player.sender.send(Message::Text(text.clone())); + } +} + +fn player_list(room: &Room) -> ServerMessage { + ServerMessage::PlayerList { + players: room.players.iter().map(|(id, p)| PlayerInfo { id: id.clone(), name: p.name.clone(), ready: p.ready }).collect(), + } +} + +async fn handle_socket(socket: WebSocket, room_code: String, state: Arc<AppState>) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let player_id = uuid::Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); + + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_tx.send(msg).await.is_err() { + break; + } + } + }); + + let room = state + .rooms + .entry(room_code.clone()) + .or_insert_with(|| Arc::new(Mutex::new(Room::new(DeviceFilter::default())))) + .clone(); + + let mut last_activity = Instant::now(); + let mut idle_check = tokio::time::interval(IDLE_CHECK_INTERVAL); + + loop { + tokio::select! { + maybe_msg = ws_rx.next() => { + match maybe_msg { + Some(Ok(Message::Text(text))) => { + last_activity = Instant::now(); + if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) { + handle_client_message(&room, &room_code, &state, &player_id, &tx, client_msg).await; + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + _ = idle_check.tick() => { + if last_activity.elapsed() > IDLE_TIMEOUT { + let _ = tx.send(Message::Close(None)); + break; + } + } + } + } + + { + let mut room_guard = room.lock().await; + room_guard.players.remove(&player_id); + if room_guard.players.is_empty() { + state.rooms.remove(&room_code); + } else { + let msg = player_list(&room_guard); + broadcast(&room_guard, &msg).await; + } + } + send_task.abort(); +} + +// Runs the countdown and starts the race. Split out of the Ready handler so a +// bot joining a waiting lobby can trigger a start exactly the way a human +// readying up does, and so the room lock is taken per step rather than held +// across the whole three seconds. +async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + let text = { + let mut room = room_arc.lock().await; + // Another caller may have started it in the gap since the check. + if room.status != RoomStatus::Lobby { + return; + } + room.status = RoomStatus::Countdown; + let text = state + .race_texts + .get(rand::thread_rng().gen_range(0..state.race_texts.len())) + .cloned() + .unwrap_or_default(); + room.text = Some(text.clone()); + text + }; + + for seconds in (1..=3).rev() { + { + let room = room_arc.lock().await; + broadcast(&room, &ServerMessage::Countdown { seconds }).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + let bots: Vec<(String, f32, f32)> = { + let mut room = room_arc.lock().await; + room.status = RoomStatus::Racing; + broadcast(&room, &ServerMessage::Start { text: text.clone() }).await; + room.players + .iter() + .filter(|(_, p)| p.is_bot) + .map(|(id, p)| (id.clone(), p.target_wpm, p.target_accuracy)) + .collect() + }; + + let char_count = text.chars().count().max(1); + for (bot_id, wpm, accuracy) in bots { + tokio::spawn(drive_bot(room_arc.clone(), bot_id, wpm, accuracy, char_count)); + } +} + +// Types on a bot's behalf: converts its target pace into a share of the +// passage completed so far and broadcasts that like any other player's +// progress. Deliberately not perfectly even - a metronome-steady opponent +// reads as obviously fake. +async fn drive_bot( + room_arc: Arc<Mutex<Room>>, + bot_id: String, + target_wpm: f32, + target_accuracy: f32, + char_count: usize, +) { + let started = Instant::now(); + let mut ticker = tokio::time::interval(BOT_TICK); + // tokio's interval fires once immediately. Consumed here, because that + // first tick lands with essentially zero elapsed time and the live-WPM + // division below then reports thousands of words per minute. + ticker.tick().await; + // Progress is accumulated rather than recomputed from elapsed time, so a + // correction genuinely costs the bot ground instead of being erased by the + // next tick's arithmetic. + let mut chars_done: f32 = 0.0; + let mut correcting: u32 = 0; + // A less accurate bot stumbles more often. At 99% this is near zero; at + // 88% it is a stumble every few seconds. + let mistake_chance = ((100.0 - target_accuracy) / 100.0).clamp(0.0, 1.0) * 0.35; + + loop { + ticker.tick().await; + + if correcting > 0 { + // Backing up over a mistake: no forward progress this tick. + correcting -= 1; + } else { + if rand::thread_rng().gen_bool(mistake_chance as f64) { + correcting = BOT_CORRECTION_TICKS; + // Losing a little of what was typed, the way a real correction does. + chars_done = (chars_done - target_wpm * 0.08).max(0.0); + } else { + // Per-tick pace, wobbled so the line is not a ruler. + let wobble = 1.0 + (rand::thread_rng().gen_range(-12..=12) as f32 / 100.0); + chars_done += target_wpm * 5.0 * (BOT_TICK.as_secs_f32() / 60.0) * wobble; + } + } + + let percent = ((chars_done / char_count as f32) * 100.0).min(100.0); + let elapsed = started.elapsed().as_secs_f32(); + // Reported WPM follows what has actually been typed, so a bot that + // stalled reads slower for a while - exactly as a human would. + // Floored as well as offset by the consumed first tick: a burst of + // early progress divided by a near-zero elapsed time is what produced + // four-figure WPM readings. + let live_wpm = (chars_done / 5.0) / (elapsed.max(BOT_TICK.as_secs_f32()) / 60.0); + + let mut room = room_arc.lock().await; + // Stop if the race ended, the room emptied, or this bot was removed. + if room.status != RoomStatus::Racing || !room.players.contains_key(&bot_id) { + return; + } + if percent >= 100.0 { + if !room.finish_order.contains(&bot_id) { + room.finish_order.push(bot_id.clone()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { + player_id: bot_id.clone(), + wpm: live_wpm, + accuracy: target_accuracy, + time: elapsed, + place, + }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + return; + } + let msg = ServerMessage::PlayerProgress { + player_id: bot_id.clone(), + percent, + wpm: live_wpm, + }; + broadcast(&room, &msg).await; + } +} + +// Adds a bot to a quick-match room that is still sitting empty-ish, then +// readies it up - which starts the race through the same path a second human +// would have taken. +async fn maybe_add_bot(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + tokio::time::sleep(BOT_JOIN_DELAY).await; + + let should_start = { + let mut room = room_arc.lock().await; + // Conditions can all have changed during the wait: a real player may + // have arrived, the race may have started, everyone may have left. + if !room.allow_bots || room.status != RoomStatus::Lobby || room.human_count() == 0 { + return; + } + + // Decided in one pass rather than one bot per call: adding a bot + // readies it, which starts the race, so a second bot added afterwards + // would never make it into the lobby. + let room_for = 1 + MAX_BOTS_PER_ROOM; + let free = room_for.saturating_sub(room.players.len()); + if free == 0 { + return; + } + let wanted = rand::thread_rng().gen_range(1..=free); + + for _ in 0..wanted { + let taken: Vec<String> = room.players.values().map(|p| p.name.clone()).collect(); + let available: Vec<&&str> = BOT_NAMES + .iter() + .filter(|n| !taken.iter().any(|t| t == *n)) + .collect(); + if available.is_empty() { + break; + } + let name = available[rand::thread_rng().gen_range(0..available.len())]; + + // Take whichever tier is not already represented, so the room's + // bots never sit at similar speeds. With none present, pick at + // random. + let has_fast = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm >= BOT_FAST_WPM.0); + let has_slow = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm <= BOT_SLOW_WPM.1); + let fast = if has_fast { + false + } else if has_slow { + true + } else { + rand::thread_rng().gen_bool(0.5) + }; + let (lo, hi) = if fast { BOT_FAST_WPM } else { BOT_SLOW_WPM }; + + // The receiver is dropped immediately: a bot has no socket, and + // every send to it fails silently, which is what broadcast expects. + let (tx, _rx) = mpsc::unbounded_channel(); + let bot_id = format!("bot-{}", generate_room_code().to_lowercase()); + room.players.insert( + bot_id, + Player { + name: (*name).to_string(), + ready: true, + sender: tx, + is_bot: true, + target_wpm: rand::thread_rng().gen_range(lo..hi), + target_accuracy: rand::thread_rng().gen_range(BOT_MIN_ACCURACY..BOT_MAX_ACCURACY), + }, + ); + } + + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + room.players.len() >= 2 && room.players.values().all(|p| p.ready) + }; + + if should_start { + start_race(room_arc, state).await; + } +} + +async fn handle_client_message( + room_arc: &Arc<Mutex<Room>>, + room_code: &str, + state: &Arc<AppState>, + player_id: &str, + tx: &mpsc::UnboundedSender<Message>, + msg: ClientMessage, +) { + let mut room = room_arc.lock().await; + match msg { + ClientMessage::Join { name, device_type } => { + if room.device_filter == DeviceFilter::DesktopOnly && device_type != "desktop" { + let err = ServerMessage::Error { + message: "This room is desktop-only - create or join an \"Everyone\" room from a mobile device.".to_string(), + }; + if let Ok(text) = serde_json::to_string(&err) { + let _ = tx.send(Message::Text(text)); + } + let _ = tx.send(Message::Close(None)); + return; + } + room.players.insert(player_id.to_string(), Player { name, ready: false, sender: tx.clone(), is_bot: false, target_wpm: 0.0, target_accuracy: 0.0 }); + if let Ok(text) = serde_json::to_string(&ServerMessage::Joined { player_id: player_id.to_string() }) { + let _ = tx.send(Message::Text(text)); + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + if room.allow_bots && room.status == RoomStatus::Lobby && room.human_count() == 1 { + tokio::spawn(maybe_add_bot(room_arc.clone(), state.clone())); + } + } + ClientMessage::Ready => { + if let Some(p) = room.players.get_mut(player_id) { + p.ready = true; + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + // Requires >=2, not just non-empty: "all ready" is vacuously true + // for a room of one, so without this a player who readies up + // before anyone else has joined immediately starts (and finishes) + // a solo race, and a genuine second player joining moments later + // arrives to a room that already left the lobby. Caught via an + // actual two-client race where connection timing wasn't + // simultaneous - exactly the ordinary case over a real network. + let should_start = room.players.len() >= 2 + && room.players.values().all(|p| p.ready) + && room.status == RoomStatus::Lobby; + // The lock is released before the countdown runs. It used to be + // held for all three seconds as a deliberate synchronisation + // point, but that also blocked every other client's messages for + // the duration - which is exactly what reset the desktop client + // mid-countdown. start_race takes the lock per step instead. + drop(room); + if should_start { + start_race(room_arc.clone(), state.clone()).await; + } + return; + } + ClientMessage::Progress { percent, wpm } => { + let msg = ServerMessage::PlayerProgress { player_id: player_id.to_string(), percent, wpm }; + broadcast(&room, &msg).await; + } + ClientMessage::Finish { wpm, accuracy, time } => { + if !room.finish_order.contains(&player_id.to_string()) { + room.finish_order.push(player_id.to_string()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { player_id: player_id.to_string(), wpm, accuracy, time, place }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + } + } + let _ = room_code; // kept for future room-scoped logging/metrics +} diff --git a/crates/server/src/rate_limit.rs b/crates/server/src/rate_limit.rs new file mode 100644 index 0000000..ae79775 --- /dev/null +++ b/crates/server/src/rate_limit.rs @@ -0,0 +1,34 @@ +use dashmap::DashMap; +use std::hash::Hash; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// Fixed-window limiter, generic over whatever key makes sense for the +// endpoint (client IP for unauthenticated auth attempts, user id for an +// authenticated endpoint like stats submission). Good enough to blunt +// brute force / scripted abuse against a single-instance deployment without +// pulling in a separate crate; a multi-instance deployment would need this +// backed by a shared store (e.g. the DB or Redis) instead. +#[derive(Clone)] +pub struct RateLimiter<K: Eq + Hash + Clone + Send + Sync + 'static = std::net::IpAddr> { + hits: Arc<DashMap<K, (Instant, u32)>>, + max_attempts: u32, + window: Duration, +} + +impl<K: Eq + Hash + Clone + Send + Sync + 'static> RateLimiter<K> { + pub fn new(max_attempts: u32, window: Duration) -> Self { + Self { hits: Arc::new(DashMap::new()), max_attempts, window } + } + + /// Returns true if this key is still within its allowance and records the attempt. + pub fn check(&self, key: K) -> bool { + let now = Instant::now(); + let mut entry = self.hits.entry(key).or_insert((now, 0)); + if now.duration_since(entry.0) > self.window { + *entry = (now, 0); + } + entry.1 += 1; + entry.1 <= self.max_attempts + } +} diff --git a/crates/server/src/spotify.rs b/crates/server/src/spotify.rs new file mode 100644 index 0000000..9076afd --- /dev/null +++ b/crates/server/src/spotify.rs @@ -0,0 +1,237 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Redirect}; +use axum::routing::get; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::Row; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +const STATE_COOKIE: &str = "spotify_oauth_state"; +const SCOPES: &str = "user-read-currently-playing user-read-playback-state"; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/spotify/login", get(login)) + .route("/api/spotify/callback", get(callback)) + .route("/api/spotify/now-playing", get(now_playing)) +} + +fn random_state() -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..32).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect() +} + +async fn login(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.spotify.is_configured() { + return Err(AppError::NotConfigured( + "Spotify isn't configured on this server - SPOTIFY_CLIENT_ID/SECRET are unset.".into(), + )); + } + + let csrf_state = random_state(); + let auth_url = format!( + "https://accounts.spotify.com/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}", + urlencoding_encode(&state.spotify.client_id), + urlencoding_encode(&state.spotify.redirect_uri), + urlencoding_encode(SCOPES), + urlencoding_encode(&csrf_state), + ); + + let cookie = Cookie::build((STATE_COOKIE, csrf_state)) + .http_only(true) + .secure(state.cookie_secure) + .same_site(SameSite::Lax) + .path("/api/spotify") + .max_age(TimeDuration::minutes(5)) + .build(); + + Ok((jar.add(cookie), Redirect::to(&auth_url))) +} + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: Option<String>, + state: Option<String>, + error: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option<String>, + expires_in: i64, +} + +async fn callback( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Query(q): Query<CallbackQuery>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + if let Some(err) = q.error { + return Err(AppError::InvalidInput(format!("Spotify denied the request: {err}"))); + } + let code = q.code.ok_or(AppError::InvalidInput("missing code".into()))?; + let returned_state = q.state.ok_or(AppError::InvalidInput("missing state".into()))?; + let expected_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string()); + if expected_state.as_deref() != Some(returned_state.as_str()) { + return Err(AppError::InvalidInput("state mismatch - possible CSRF, try connecting again".into())); + } + + let token: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", &state.spotify.redirect_uri), + ]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad token response: {e}")))?; + + let Some(refresh_token) = token.refresh_token else { + return Err(AppError::Internal(anyhow::anyhow!("Spotify did not return a refresh token"))); + }; + let expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(token.expires_in)); + + sqlx::query( + "INSERT INTO spotify_tokens (user_id, access_token, refresh_token, expires_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at", + ) + .bind(&user.id) + .bind(&token.access_token) + .bind(&refresh_token) + .bind(&expires_at) + .execute(&state.db) + .await?; + + let jar = jar.remove(Cookie::from(STATE_COOKIE)); + Ok((jar, Redirect::to(&state.frontend_origin))) +} + +async fn get_valid_access_token(state: &AppState, user_id: &str) -> Result<Option<String>, AppError> { + let row = sqlx::query("SELECT access_token, refresh_token, expires_at FROM spotify_tokens WHERE user_id = ?") + .bind(user_id) + .fetch_optional(&state.db) + .await?; + let Some(row) = row else { return Ok(None) }; + + let access_token: String = row.try_get("access_token").map_err(|e| AppError::Internal(e.into()))?; + let refresh_token: String = row.try_get("refresh_token").map_err(|e| AppError::Internal(e.into()))?; + let expires_at: String = row.try_get("expires_at").map_err(|e| AppError::Internal(e.into()))?; + + let expired = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339) + .map(|t| t < OffsetDateTime::now_utc()) + .unwrap_or(true); + + if !expired { + return Ok(Some(access_token)); + } + + // Access tokens are short-lived (Spotify: ~1 hour) - refreshed + // transparently here so the caller never has to think about expiry. + let refreshed: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[("grant_type", "refresh_token"), ("refresh_token", &refresh_token)]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad refresh response: {e}")))?; + + let new_refresh_token = refreshed.refresh_token.unwrap_or(refresh_token); + let new_expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(refreshed.expires_in)); + sqlx::query("UPDATE spotify_tokens SET access_token = ?, refresh_token = ?, expires_at = ? WHERE user_id = ?") + .bind(&refreshed.access_token) + .bind(&new_refresh_token) + .bind(&new_expires_at) + .bind(user_id) + .execute(&state.db) + .await?; + + Ok(Some(refreshed.access_token)) +} + +#[derive(Debug, Serialize)] +struct NowPlaying { + is_playing: bool, + track: Option<String>, + artist: Option<String>, + duration_ms: Option<u64>, + progress_ms: Option<u64>, +} + +impl NowPlaying { + fn nothing() -> Self { + Self { is_playing: false, track: None, artist: None, duration_ms: None, progress_ms: None } + } +} + +async fn now_playing(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let Some(access_token) = get_valid_access_token(&state, &user.id).await? else { + return Err(AppError::NotFound); // not connected to Spotify + }; + + let res = state + .http + .get("https://api.spotify.com/v1/me/player/currently-playing") + .bearer_auth(&access_token) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))?; + + if res.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(Json(NowPlaying::nothing())); + } + if !res.status().is_success() { + return Ok(Json(NowPlaying::nothing())); + } + + let body: Value = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + let is_playing = body.get("is_playing").and_then(Value::as_bool).unwrap_or(false); + let item = body.get("item"); + let track = item.and_then(|i| i.get("name")).and_then(Value::as_str).map(str::to_string); + let artist = item + .and_then(|i| i.get("artists")) + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|a| a.get("name")) + .and_then(Value::as_str) + .map(str::to_string); + let duration_ms = item.and_then(|i| i.get("duration_ms")).and_then(Value::as_u64); + let progress_ms = body.get("progress_ms").and_then(Value::as_u64); + + Ok(Json(NowPlaying { is_playing, track, artist, duration_ms, progress_ms })) +} + +// Minimal percent-encoding - avoids pulling in the `url` crate just to +// build a couple of query string values for the authorize URL. +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 0000000..267b6fb --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,65 @@ +use crate::multiplayer::{new_registry, RoomRegistry}; +use crate::rate_limit::RateLimiter; +use reqwest::Client; +use sqlx::SqlitePool; +use std::time::Duration; + +#[derive(Clone, Default)] +pub struct SpotifyConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, +} + +impl SpotifyConfig { + pub fn is_configured(&self) -> bool { + !self.client_id.is_empty() && !self.client_secret.is_empty() + } +} + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub auth_rate_limiter: RateLimiter, + /// Keyed by user id, not IP - this guards an authenticated endpoint + /// (stats submission) against a single compromised/scripted account + /// hammering it, which an IP-keyed limiter wouldn't catch behind NAT or + /// a VPN and would over-punish for a shared IP. + pub stats_rate_limiter: RateLimiter<String>, + /// Set from COOKIE_SECURE. Off for plain-HTTP local dev, must be on + /// behind TLS in production or browsers silently drop the cookie. + pub cookie_secure: bool, + pub rooms: RoomRegistry, + /// Race passages every multiplayer room draws from, so every player in + /// a room types the identical text - loaded once at startup rather + /// than per-room, since the pool itself never changes at runtime. + pub race_texts: Vec<String>, + pub spotify: SpotifyConfig, + pub frontend_origin: String, + pub http: Client, +} + +impl AppState { + pub fn new( + db: SqlitePool, + cookie_secure: bool, + race_texts: Vec<String>, + spotify: SpotifyConfig, + frontend_origin: String, + ) -> Self { + Self { + db, + auth_rate_limiter: RateLimiter::new(10, Duration::from_secs(5 * 60)), + // A genuine player finishes a test at most every several + // seconds; 60 submissions in 5 minutes is generous headroom for + // rapid Words-10 sessions while still capping scripted spam. + stats_rate_limiter: RateLimiter::new(60, Duration::from_secs(5 * 60)), + cookie_secure, + rooms: new_registry(), + race_texts, + spotify, + frontend_origin, + http: Client::new(), + } + } +} diff --git a/crates/server/src/stats.rs b/crates/server/src/stats.rs new file mode 100644 index 0000000..6e80a3d --- /dev/null +++ b/crates/server/src/stats.rs @@ -0,0 +1,275 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/stats", post(submit_result)) + .route("/api/stats/me", get(my_stats)) + .route("/api/leaderboard", get(leaderboard)) + .route("/api/users/:username/public", get(public_profile)) +} + +#[derive(Debug, Deserialize)] +pub struct SubmitResult { + pub mode_key: String, + pub wpm: f64, + pub raw_wpm: f64, + pub accuracy: f64, + pub time_seconds: f64, + #[serde(default)] + pub device_type: Option<String>, + /// Milliseconds between consecutive keystrokes, in order. Optional -- + /// older/other clients that don't send it just skip the timing-variance + /// half of the anti-cheat check (see anticheat::should_flag). + #[serde(default)] + pub keystroke_intervals_ms: Option<Vec<f64>>, +} + +// Bounds a client could never legitimately produce - rejecting them keeps +// obviously-fabricated rows out of personal bests and the public +// leaderboard without trying to fully police what "legitimate" means. +fn validate_result(r: &SubmitResult) -> Result<(), AppError> { + if r.mode_key.is_empty() || r.mode_key.len() > 64 { + return Err(AppError::InvalidInput("invalid mode_key".into())); + } + if !(0.0..=500.0).contains(&r.wpm) || !(0.0..=500.0).contains(&r.raw_wpm) { + return Err(AppError::InvalidInput("wpm out of range".into())); + } + if !(0.0..=100.0).contains(&r.accuracy) { + return Err(AppError::InvalidInput("accuracy out of range".into())); + } + if !(0.0..3600.0).contains(&r.time_seconds) { + return Err(AppError::InvalidInput("time_seconds out of range".into())); + } + if let Some(d) = &r.device_type { + if d != "desktop" && d != "mobile" { + return Err(AppError::InvalidInput("device_type must be 'desktop' or 'mobile'".into())); + } + } + Ok(()) +} + +async fn submit_result( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Json(body): Json<SubmitResult>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.stats_rate_limiter.check(user.id.clone()) { + return Err(AppError::RateLimited); + } + validate_result(&body)?; + + let device_type = body.device_type.as_deref().unwrap_or("desktop"); + let flagged = crate::anticheat::should_flag(body.wpm, &body.keystroke_intervals_ms); + + sqlx::query( + "INSERT INTO test_results (id, user_id, mode_key, wpm, raw_wpm, accuracy, time_seconds, created_at, device_type, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&body.mode_key) + .bind(body.wpm) + .bind(body.raw_wpm) + .bind(body.accuracy) + .bind(body.time_seconds) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .bind(device_type) + .bind(flagged) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize)] +pub struct PersonalBest { + pub mode_key: String, + pub wpm: f64, + pub date: String, +} + +#[derive(Debug, Serialize)] +pub struct MyStats { + pub tests_completed: i64, + pub total_time_seconds: f64, + pub average_wpm: f64, + pub average_accuracy: f64, + pub best_wpm: f64, + pub personal_bests: Vec<PersonalBest>, +} + +// Shared by /api/stats/me (the signed-in caller's own id) and the public +// profile endpoint (an arbitrary user_id looked up by username) - same +// aggregate, same window-function best-per-mode query either way. +async fn fetch_stats_summary(state: &AppState, user_id: &str) -> Result<MyStats, AppError> { + let summary = sqlx::query( + "SELECT COUNT(*) as tests_completed, + COALESCE(SUM(time_seconds), 0) as total_time_seconds, + COALESCE(AVG(wpm), 0) as average_wpm, + COALESCE(AVG(accuracy), 0) as average_accuracy, + COALESCE(MAX(wpm), 0) as best_wpm + FROM test_results WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(&state.db) + .await?; + + // Window function picks each mode's single highest-wpm row (and that + // row's own date) rather than independently maxing wpm and date, which + // could otherwise pair a best score with the date of a different run. + let bests = sqlx::query( + "SELECT mode_key, wpm, created_at FROM ( + SELECT mode_key, wpm, created_at, + ROW_NUMBER() OVER (PARTITION BY mode_key ORDER BY wpm DESC) as rn + FROM test_results WHERE user_id = ? + ) WHERE rn = 1", + ) + .bind(user_id) + .fetch_all(&state.db) + .await?; + + let personal_bests = bests + .into_iter() + .map(|row| PersonalBest { + mode_key: row.try_get("mode_key").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + }) + .collect(); + + Ok(MyStats { + tests_completed: summary.try_get("tests_completed").unwrap_or_default(), + total_time_seconds: summary.try_get("total_time_seconds").unwrap_or_default(), + average_wpm: summary.try_get("average_wpm").unwrap_or_default(), + average_accuracy: summary.try_get("average_accuracy").unwrap_or_default(), + best_wpm: summary.try_get("best_wpm").unwrap_or_default(), + personal_bests, + }) +} + +async fn my_stats(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + Ok(Json(fetch_stats_summary(&state, &user.id).await?)) +} + +#[derive(Debug, Serialize)] +pub struct PublicProfile { + pub username: String, + pub joined_at: String, + pub flair: Option<String>, + #[serde(flatten)] + pub stats: MyStats, +} + +// Deliberately excludes anything not meant for other people to see (no +// email, no password hash, no session/friendship data) - just the same +// aggregate/personal-bests shape /api/stats/me returns for yourself, plus +// the username, join date, and equipped flair (cosmetics only mean anything +// if other people can actually see them), for anyone looking someone up. +async fn public_profile(State(state): State<Arc<AppState>>, Path(username): Path<String>) -> Result<impl IntoResponse, AppError> { + // LEFT JOIN cosmetics for the flair's *value* ("star"), not the raw + // cosmetic id ("flair-star") stored on the user row - the frontend's + // FLAIR_ICONS map is keyed by value, and hardcoding the id->value + // mapping client-side would silently drift the moment the catalog + // changes. + let row = sqlx::query( + "SELECT users.id as id, users.created_at as created_at, cosmetics.value as flair_value + FROM users LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE users.username = ?", + ) + .bind(&username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let joined_at: String = row.try_get("created_at").map_err(|e| AppError::Internal(e.into()))?; + // See cosmetics.rs's comment on this exact pattern - .ok() alone left + // T ambiguous enough to decode NULL as "" instead of None. + let flair: Option<String> = row.try_get::<Option<String>, _>("flair_value").unwrap_or(None); + let stats = fetch_stats_summary(&state, &user_id).await?; + + Ok(Json(PublicProfile { username, joined_at, flair, stats })) +} + +#[derive(Debug, Deserialize)] +pub struct LeaderboardQuery { + pub mode: String, + pub limit: Option<u32>, + /// "desktop" to show desktop-only results; omitted/anything else means + /// no device filter. Mobile is intentionally still counted by default -- + /// this is an opt-in filter for viewers who want a keyboard-only board, + /// not a default exclusion of mobile players. + pub device: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LeaderboardEntry { + pub username: String, + pub wpm: f64, + pub accuracy: f64, + pub date: String, + pub device_type: String, + pub flair: Option<String>, +} + +async fn leaderboard(State(state): State<Arc<AppState>>, Query(q): Query<LeaderboardQuery>) -> Result<impl IntoResponse, AppError> { + if q.mode.is_empty() || q.mode.len() > 64 { + return Err(AppError::InvalidInput("invalid mode".into())); + } + let limit = q.limit.unwrap_or(50).clamp(1, 100); + let desktop_only = q.device.as_deref() == Some("desktop"); + + // One row per user: their single best NON-FLAGGED run in this mode, + // ranked by wpm. A flagged run doesn't just rank lower - it's excluded + // entirely, since a flagged score sitting on the board (even far down) + // is still a false signal to everyone who sees it. + // Same reasoning as public_profile: join through to the flair's value, + // not the raw cosmetic id. + let rows = sqlx::query( + "SELECT username, wpm, accuracy, created_at, device_type, flair_value FROM ( + SELECT users.username as username, test_results.wpm as wpm, + test_results.accuracy as accuracy, test_results.created_at as created_at, + test_results.device_type as device_type, cosmetics.value as flair_value, + ROW_NUMBER() OVER (PARTITION BY test_results.user_id ORDER BY test_results.wpm DESC) as rn + FROM test_results + JOIN users ON users.id = test_results.user_id + LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE test_results.mode_key = ? AND test_results.flagged = 0 + AND (? = 0 OR test_results.device_type = 'desktop') + ) WHERE rn = 1 + ORDER BY wpm DESC + LIMIT ?", + ) + .bind(&q.mode) + .bind(desktop_only) + .bind(limit) + .fetch_all(&state.db) + .await?; + + let entries: Vec<LeaderboardEntry> = rows + .into_iter() + .map(|row| LeaderboardEntry { + username: row.try_get("username").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + accuracy: row.try_get("accuracy").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + device_type: row.try_get("device_type").unwrap_or_default(), + flair: row.try_get::<Option<String>, _>("flair_value").unwrap_or(None), + }) + .collect(); + + Ok(Json(entries)) +} diff --git a/crates/steam/Cargo.toml b/crates/steam/Cargo.toml new file mode 100644 index 0000000..b6c4851 --- /dev/null +++ b/crates/steam/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "typerpunk-steam" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk-desktop" +path = "src/main.rs" + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["serde", "multiplayer"] } +bevy = { workspace = true } +serde.workspace = true +serde_json.workspace = true +rand.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-tungstenite.workspace = true +futures-util.workspace = true +reqwest.workspace = true diff --git a/crates/steam/assets/sprites/pulse.png b/crates/steam/assets/sprites/pulse.png Binary files differnew file mode 100644 index 0000000..a70d94d --- /dev/null +++ b/crates/steam/assets/sprites/pulse.png diff --git a/crates/steam/assets/sprites/pulse.svg b/crates/steam/assets/sprites/pulse.svg new file mode 100644 index 0000000..de5b98e --- /dev/null +++ b/crates/steam/assets/sprites/pulse.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64"> + <!-- Angular diamond, matching the web icon set's straight-edge language. + Reads as a signal pulse travelling along the track. --> + <path d="M12 2L22 12L12 22L2 12Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/racer_marker.png b/crates/steam/assets/sprites/racer_marker.png Binary files differnew file mode 100644 index 0000000..e7afbc3 --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.png diff --git a/crates/steam/assets/sprites/racer_marker.svg b/crates/steam/assets/sprites/racer_marker.svg new file mode 100644 index 0000000..60e973c --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48"> + <!-- Shared "this one is you" marker used above the local player's lane + in both Signal Run and Breach Protocol. A filled downward chevron, + angular like every other icon in the set. --> + <path d="M2 4H22L12 16Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/warden_layer.png b/crates/steam/assets/sprites/warden_layer.png Binary files differnew file mode 100644 index 0000000..0b4f3f2 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.png diff --git a/crates/steam/assets/sprites/warden_layer.svg b/crates/steam/assets/sprites/warden_layer.svg new file mode 100644 index 0000000..2fdf9a4 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 28" width="280" height="56"> + <!-- A circuit-board-style defensive layer: a solid bar with angular teeth + above and below, echoing the terminal/hacking aesthetic instead of a + flat rectangle. --> + <rect x="4" y="10" width="132" height="8" fill="#ffffff"/> + <rect x="14" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="14" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="18" width="14" height="8" fill="#ffffff"/> +</svg> diff --git a/crates/steam/src/lore.rs b/crates/steam/src/lore.rs new file mode 100644 index 0000000..193587c --- /dev/null +++ b/crates/steam/src/lore.rs @@ -0,0 +1,24 @@ +// In-universe naming for TyperPunk's multiplayer race modes. + +pub const APP_NAME: &str = "TyperPunk"; + +/// A player or bot in a race. "Runner" is our own term for a race participant. +pub const PARTICIPANT_LABEL: &str = "Runner"; + +pub mod modes { + /// Terminal Duel: two runners' text execution shown side by side, like + /// racing shells. Cheapest of the three to build well; on-brand. + pub const TERMINAL_DUEL: &str = "Terminal Duel"; + + /// Breach Protocol: each runner chips through a stack of defensive + /// layers ("Wardens") with correct keystrokes; first to clear them wins. + pub const BREACH_PROTOCOL: &str = "Breach Protocol"; + + /// Signal Run: runners are pulses of light racing along a circuit trace + /// toward a destination node. The animated, particle-heavy visual. + pub const SIGNAL_RUN: &str = "Signal Run"; +} + +/// A single defensive layer in Breach Protocol. Renamed from the generic +/// "firewall layer" to keep the game's own vocabulary distinct. +pub const WARDEN_LABEL: &str = "Warden"; diff --git a/crates/steam/src/main.rs b/crates/steam/src/main.rs new file mode 100644 index 0000000..3fafb61 --- /dev/null +++ b/crates/steam/src/main.rs @@ -0,0 +1,55 @@ +use bevy::prelude::*; + +mod lore; +mod multiplayer_plugin; +mod net; +mod race; +mod racer; +mod typing; + +use multiplayer_plugin::MultiplayerPlugin; +use race::{ + breach::BreachProtocolPlugin, signal::SignalRunPlugin, terminal::TerminalDuelPlugin, RaceVisual, +}; +use racer::DemoProgressPlugin; +use typing::TypingPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: lore::APP_NAME.to_string(), + resolution: (1280, 720).into(), + ..default() + }), + ..default() + })) + .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.03))) + .init_state::<RaceVisual>() + .add_plugins(DemoProgressPlugin) + .add_plugins(MultiplayerPlugin) + .add_plugins(TypingPlugin) + .add_plugins(TerminalDuelPlugin) + .add_plugins(BreachProtocolPlugin) + .add_plugins(SignalRunPlugin) + .add_systems(Startup, spawn_camera) + .add_systems(Update, cycle_visual_on_key) + .run(); +} + +fn spawn_camera(mut commands: Commands) { + commands.spawn(Camera2d); +} + +/// Demo-only: press 1/2/3 to switch which race visual is on screen, so all +/// three can be compared side by side before committing to one for real +/// multiplayer. Remove once there's a real mode-select flow. +fn cycle_visual_on_key(keys: Res<ButtonInput<KeyCode>>, mut next: ResMut<NextState<RaceVisual>>) { + if keys.just_pressed(KeyCode::Digit1) { + next.set(RaceVisual::TerminalDuel); + } else if keys.just_pressed(KeyCode::Digit2) { + next.set(RaceVisual::BreachProtocol); + } else if keys.just_pressed(KeyCode::Digit3) { + next.set(RaceVisual::SignalRun); + } +} diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs new file mode 100644 index 0000000..05c1760 --- /dev/null +++ b/crates/steam/src/multiplayer_plugin.rs @@ -0,0 +1,179 @@ +// Bridges the background WebSocket worker in net.rs into Bevy's ECS: applies +// incoming NetEvents to Racer entities every frame, and forwards the local +// racer's (synthetic, see net.rs's module comment) progress out over the +// same connection. +use crate::net::{LocalUpdate, MultiplayerNet, NetEvent}; +use crate::racer::{DemoRace, Racer}; +use crate::typing::TypingSession; +use bevy::prelude::*; +use std::sync::mpsc::Sender; + +fn server_http_base() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +fn server_ws_base() -> String { + // Same host as the HTTP API, just a different scheme - ws:// unless the + // HTTP base was already secured, in which case wss://. + let http = server_http_base(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} + +#[derive(Resource)] +struct NetHandle(MultiplayerNet); + +#[derive(Resource)] +struct LocalUpdateSender(Sender<LocalUpdate>); + +#[derive(Resource, Default)] +struct LocalPlayerId(Option<String>); + +// Without this, send_local_progress would re-send Finish every single frame +// once the local racer reaches 1.0 progress, flooding the connection +// forever instead of announcing it once. +#[derive(Resource, Default)] +struct LocalFinishSent(bool); + +/// Tags a Racer entity as backed by a specific server-assigned player id, so +/// incoming PlayerProgress/PlayerFinished events know which entity to +/// update, and PlayerList knows which remote players already have one. +#[derive(Component)] +struct RemotePlayer(String); + +pub struct MultiplayerPlugin; + +impl Plugin for MultiplayerPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(LocalPlayerId::default()) + .insert_resource(LocalFinishSent::default()) + .add_systems(Startup, connect) + .add_systems(Update, (poll_events, send_local_progress)); + } +} + +fn connect(mut commands: Commands) { + // A room code passed in means "join this existing room" (e.g. the code + // a web client printed) - otherwise a fresh room is created and its + // code logged so a second client can join it to actually see a live + // race between two real connections. + let room_code = std::env::var("TYPERPUNK_ROOM_CODE").ok(); + let name = std::env::var("TYPERPUNK_PLAYER_NAME").unwrap_or_else(|_| "Desktop".to_string()); + let (net, update_tx) = MultiplayerNet::spawn(server_http_base(), server_ws_base(), room_code, name); + commands.insert_resource(NetHandle(net)); + commands.insert_resource(LocalUpdateSender(update_tx)); +} + +fn poll_events( + mut commands: Commands, + net: Res<NetHandle>, + mut local_id: ResMut<LocalPlayerId>, + mut typing: ResMut<TypingSession>, + mut finish_sent: ResMut<LocalFinishSent>, + mut racers: Query<(&mut Racer, &RemotePlayer)>, + existing: Query<(Entity, &RemotePlayer)>, +) { + while let Some(event) = net.0.try_recv() { + match event { + NetEvent::RoomCreated { code } => { + info!("multiplayer room created - join it from the web app with code: {code}"); + } + NetEvent::Joined { player_id } => { + info!("joined multiplayer room as player {player_id}"); + local_id.0 = Some(player_id); + } + NetEvent::PlayerList(players) => { + for player in &players { + if local_id.0.as_deref() == Some(player.id.as_str()) { + continue; // that's us - our own Racer entity already exists + } + let already_spawned = existing.iter().any(|(_, r)| r.0 == player.id); + if !already_spawned { + commands.spawn(( + Racer { name: player.name.clone(), is_local: false, progress: 0.0, wpm: 0.0, accuracy: 100.0, mistakes: 0 }, + RemotePlayer(player.id.clone()), + DemoRace, + )); + } + } + // A player who left the room stops appearing in PlayerList -- + // their racer entity is stale and would otherwise sit frozen + // on screen forever. + let current_ids: Vec<&str> = players.iter().map(|p| p.id.as_str()).collect(); + for (entity, remote) in &existing { + if !current_ids.contains(&remote.0.as_str()) { + commands.entity(entity).despawn(); + } + } + } + NetEvent::Countdown(seconds) => { + info!("race starting in {seconds}s"); + } + NetEvent::Start(text) => { + info!("race started"); + typing.start(text); + // A fresh race needs its own Finish announcement - without + // resetting this, a second race in the same process would + // never send one, since the guard would still remember the + // first race's finish. + finish_sent.0 = false; + } + NetEvent::PlayerProgress { player_id, percent, wpm } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = (percent / 100.0).clamp(0.0, 1.0); + racer.wpm = wpm; + } + } + } + NetEvent::PlayerFinished { player_id, wpm, .. } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = 1.0; + racer.wpm = wpm; + } + } + } + NetEvent::RoomClosed(reason) => { + warn!("multiplayer room closed: {reason}"); + } + NetEvent::Error(message) => { + warn!("multiplayer error: {message}"); + } + } + } +} + +fn send_local_progress( + sender: Res<LocalUpdateSender>, + mut finish_sent: ResMut<LocalFinishSent>, + typing: Res<TypingSession>, + racers: Query<&Racer, With<DemoRace>>, +) { + // Nothing to report before a race has actually started - sending + // Progress{0%, 0wpm} on a loop while sitting in the lobby is meaningless + // and, worse, floods the connection right as the server's Ready handler + // is mid-countdown holding the room lock (see multiplayer.rs's Ready + // handler comment on why that hold is intentional). + if !typing.has_started() { + return; + } + for racer in &racers { + if !racer.is_local { + continue; + } + if racer.progress >= 1.0 { + if !finish_sent.0 { + let _ = sender.0.send(LocalUpdate::Finish { wpm: racer.wpm, accuracy: racer.accuracy, time: 0.0 }); + finish_sent.0 = true; + } + } else { + let _ = sender.0.send(LocalUpdate::Progress { percent: racer.progress * 100.0, wpm: racer.wpm }); + } + } +} diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs new file mode 100644 index 0000000..4d213b4 --- /dev/null +++ b/crates/steam/src/net.rs @@ -0,0 +1,178 @@ +// Real multiplayer networking for the desktop client, replacing racer.rs's +// synthetic two-racer demo driver. Bevy's render loop is synchronous, so (as +// with the TUI's own net.rs) a dedicated OS thread owns a tokio runtime and +// the actual WebSocket connection, bridging to the ECS over plain +// std::sync::mpsc channels polled once per frame. +// +// The local racer's progress is still produced by a synthetic driver (see +// racer::drive_demo_progress) rather than real typing input - this crate +// has no typing UI yet, that's a separate, much larger feature. What's real +// here is the network path itself: the local racer's synthetic progress is +// sent to typerpunk-server over the same protocol the web client uses, and +// every *other* racer on screen is driven entirely by what the server +// actually broadcasts, so two real clients (this one and a browser, or two +// copies of this one) genuinely race against each other's live state. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +// std::sync::mpsc::Receiver is Send but not Sync, and Bevy's Resource trait +// requires Sync - a single-slot Mutex around it costs nothing here since +// poll_events is the only reader, called from a single system. +pub struct MultiplayerNet { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, +} + +impl MultiplayerNet { + /// `room_code: None` creates a fresh room and reports its code back via + /// `NetEvent::RoomCreated` (printed to the terminal by main.rs so a + /// tester can join it from the web app); `Some(code)` joins an existing + /// one instead. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> (Self, mpsc::Sender<LocalUpdate>) { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start steam network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + (Self { event_rx: std::sync::Mutex::new(event_rx) }, update_tx) + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let url = format!("{http_base}/api/multiplayer/rooms"); + let resp = client.post(&url).send().await.map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, accuracy, time, place } => { + NetEvent::PlayerFinished { player_id, wpm, accuracy, time, place } + } + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + let _ = send_msg(&mut write, &ClientMessage::Ready).await; + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short interval + // instead of a third select branch reading it directly. + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => { + while let Ok(update) = update_rx.try_recv() { + let client_msg = match update { + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + } + } + } +} diff --git a/crates/steam/src/race/breach.rs b/crates/steam/src/race/breach.rs new file mode 100644 index 0000000..9151774 --- /dev/null +++ b/crates/steam/src/race/breach.rs @@ -0,0 +1,114 @@ +use super::RaceVisual; +use crate::lore; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Breach Protocol" - each runner chips through a vertical stack of +/// defensive layers ("Wardens") as they type correctly; first to clear +/// their whole stack wins. Doubles as the visual for a hacking-themed mode. +pub struct BreachProtocolPlugin; + +const LAYER_COUNT: usize = 8; +const LAYER_SIZE: Vec2 = Vec2::new(140.0, 28.0); +const LAYER_GAP: f32 = 6.0; +const LANE_GAP: f32 = 240.0; + +impl Plugin for BreachProtocolPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::BreachProtocol), spawn_lanes) + .add_systems(OnExit(RaceVisual::BreachProtocol), despawn_lanes) + .add_systems( + Update, + update_layers.run_if(in_state(RaceVisual::BreachProtocol)), + ); + } +} + +#[derive(Component)] +struct BreachRoot; + +#[derive(Component)] +struct WardenLayer { + racer: Entity, + index: usize, +} + +fn spawn_lanes( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let warden_texture: Handle<Image> = asset_server.load("sprites/warden_layer.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + let lanes: Vec<_> = racers.iter().collect(); + let start_x = -(LANE_GAP * (lanes.len().max(1) as f32 - 1.0)) / 2.0; + + for (lane_index, (entity, racer)) in lanes.iter().enumerate() { + let x = start_x + lane_index as f32 * LANE_GAP; + let stack_top = -200.0 + LAYER_COUNT as f32 * (LAYER_SIZE.y + LAYER_GAP); + + commands.spawn(( + BreachRoot, + Text2d::new(format!("{} - {}", racer.name, lore::WARDEN_LABEL)), + TextFont { + font_size: bevy::text::FontSize::Px(18.0), + ..default() + }, + TextColor(Color::srgb(0.6, 0.6, 0.65)), + Transform::from_xyz(x, stack_top + 20.0, 0.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's own stack, same asset Signal Run uses for the + // same purpose. + if racer.is_local { + commands.spawn(( + BreachRoot, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(x, stack_top + 44.0, 0.0), + )); + } + + for i in 0..LAYER_COUNT { + let y = -200.0 + i as f32 * (LAYER_SIZE.y + LAYER_GAP); + commands.spawn(( + BreachRoot, + WardenLayer { + racer: *entity, + index: i, + }, + Sprite { + image: warden_texture.clone(), + color: Color::srgb(0.15, 0.15, 0.18), + custom_size: Some(LAYER_SIZE), + ..default() + }, + Transform::from_xyz(x, y, 0.0), + )); + } + } +} + +fn update_layers(racers: Query<&Racer>, mut layers: Query<(&WardenLayer, &mut Sprite)>) { + for (layer, mut sprite) in &mut layers { + if let Ok(racer) = racers.get(layer.racer) { + let cleared = (racer.progress * LAYER_COUNT as f32).floor() as usize; + sprite.color = if layer.index < cleared { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(0.15, 0.15, 0.18) + }; + } + } +} + +fn despawn_lanes(mut commands: Commands, root: Query<Entity, With<BreachRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/mod.rs b/crates/steam/src/race/mod.rs new file mode 100644 index 0000000..15ad5f3 --- /dev/null +++ b/crates/steam/src/race/mod.rs @@ -0,0 +1,16 @@ +use bevy::prelude::*; + +pub mod breach; +pub mod signal; +pub mod terminal; + +/// Which race visualization is currently on screen. Switchable at runtime +/// (keys 1/2/3 in the demo) so all three can be compared side by side +/// during development instead of committing to one before they're built. +#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum RaceVisual { + #[default] + TerminalDuel, + BreachProtocol, + SignalRun, +} diff --git a/crates/steam/src/race/signal.rs b/crates/steam/src/race/signal.rs new file mode 100644 index 0000000..1046fc8 --- /dev/null +++ b/crates/steam/src/race/signal.rs @@ -0,0 +1,110 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Signal Run" - runners are pulses of light traveling along a circuit +/// trace toward a destination node. +pub struct SignalRunPlugin; + +const TRACK_START_X: f32 = -500.0; +const TRACK_END_X: f32 = 500.0; +const TRACK_Y: f32 = 0.0; +const LANE_SPACING: f32 = 60.0; + +impl Plugin for SignalRunPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::SignalRun), spawn_pulses) + .add_systems(OnExit(RaceVisual::SignalRun), despawn_pulses) + .add_systems( + Update, + (draw_track, move_pulses).run_if(in_state(RaceVisual::SignalRun)), + ); + } +} + +#[derive(Component)] +struct SignalEntity; + +#[derive(Component)] +struct Pulse { + racer: Entity, +} + +fn lane_y(lane: usize) -> f32 { + TRACK_Y - lane as f32 * LANE_SPACING +} + +fn spawn_pulses( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let pulse_texture: Handle<Image> = asset_server.load("sprites/pulse.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + + for (lane, (entity, racer)) in racers.iter().enumerate() { + let color = if racer.is_local { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(1.0, 0.3, 0.5) + }; + commands.spawn(( + SignalEntity, + Pulse { racer: entity }, + Sprite { + image: pulse_texture.clone(), + color, + custom_size: Some(Vec2::splat(22.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane), 1.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's lane, on-brand with the same asset the Breach + // Protocol visual uses for the same purpose. + if racer.is_local { + commands.spawn(( + SignalEntity, + // Tagged with Pulse too, purely so move_pulses (below) keeps + // its x position tracking the same racer's progress - its + // spawned y offset above the lane is left untouched since + // that system only ever writes .x. + Pulse { racer: entity }, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane) + 26.0, 1.0), + )); + } + } +} + +fn move_pulses(racers: Query<&Racer>, mut pulses: Query<(&Pulse, &mut Transform)>) { + for (pulse, mut transform) in &mut pulses { + if let Ok(racer) = racers.get(pulse.racer) { + transform.translation.x = + TRACK_START_X + (TRACK_END_X - TRACK_START_X) * racer.progress; + } + } +} + +fn draw_track(mut gizmos: Gizmos, racers: Query<&Racer, With<DemoRace>>) { + for lane in 0..racers.iter().len() { + let y = lane_y(lane); + gizmos.line_2d( + Vec2::new(TRACK_START_X, y), + Vec2::new(TRACK_END_X, y), + Color::srgba(0.0, 1.0, 0.62, 0.25), + ); + } +} + +fn despawn_pulses(mut commands: Commands, pulses: Query<Entity, With<SignalEntity>>) { + for entity in &pulses { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/terminal.rs b/crates/steam/src/race/terminal.rs new file mode 100644 index 0000000..d558e42 --- /dev/null +++ b/crates/steam/src/race/terminal.rs @@ -0,0 +1,81 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Terminal Duel" - each runner's line renders like a shell command +/// executing in real time. Cheapest of the three visuals: no sprites, no +/// physics, just text - and it reads as authentically on-brand for a +/// typing game about hacking rather than racing. +pub struct TerminalDuelPlugin; + +impl Plugin for TerminalDuelPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::TerminalDuel), spawn_ui) + .add_systems(OnExit(RaceVisual::TerminalDuel), despawn_ui) + .add_systems( + Update, + update_lines.run_if(in_state(RaceVisual::TerminalDuel)), + ); + } +} + +#[derive(Component)] +struct TerminalDuelRoot; + +#[derive(Component)] +struct TerminalLine(Entity); + +fn spawn_ui(mut commands: Commands, racers: Query<(Entity, &Racer), With<DemoRace>>) { + commands + .spawn(( + TerminalDuelRoot, + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + row_gap: Val::Px(24.0), + ..default() + }, + )) + .with_children(|parent| { + for (entity, racer) in &racers { + parent.spawn(( + TerminalLine(entity), + Text::new(line_for(racer)), + TextFont { + font_size: bevy::text::FontSize::Px(26.0), + ..default() + }, + TextColor(Color::srgb(0.0, 1.0, 0.62)), + )); + } + }); +} + +fn update_lines(racers: Query<&Racer>, mut lines: Query<(&TerminalLine, &mut Text)>) { + for (link, mut text) in &mut lines { + if let Ok(racer) = racers.get(link.0) { + *text = Text::new(line_for(racer)); + } + } +} + +fn line_for(racer: &Racer) -> String { + let width = 32; + let filled = ((racer.progress * width as f32).round() as usize).min(width); + let bar: String = "#".repeat(filled) + &".".repeat(width - filled); + format!( + "{:<8} [{bar}] {:>3.0}% {:>3.0}wpm", + racer.name, + racer.progress * 100.0, + racer.wpm + ) +} + +fn despawn_ui(mut commands: Commands, root: Query<Entity, With<TerminalDuelRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/racer.rs b/crates/steam/src/racer.rs new file mode 100644 index 0000000..790833e --- /dev/null +++ b/crates/steam/src/racer.rs @@ -0,0 +1,66 @@ +use crate::typing::TypingSession; +use bevy::prelude::*; + +/// A participant in a race visual. Decoupled from networking on purpose -- +/// the three visualizations below only ever read this component, so they +/// work identically whether progress comes from a local typing session, a +/// bot, or the real multiplayer connection. +#[derive(Component, Debug, Clone)] +pub struct Racer { + pub name: String, + pub is_local: bool, + /// 0.0 (start) to 1.0 (finished). + pub progress: f32, + pub wpm: f32, + pub accuracy: f32, + pub mistakes: u32, +} + +/// Marks the set of racer entities that belong to the current demo race, so +/// visualization plugins can query just their own participants. +#[derive(Component)] +pub struct DemoRace; + +pub struct DemoProgressPlugin; + +impl Plugin for DemoProgressPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, spawn_demo_racers) + .add_systems(Update, drive_demo_progress); + } +} + +fn spawn_demo_racers(mut commands: Commands) { + // Every other racer on screen comes from multiplayer_plugin.rs, spawned + // and updated from the actual server. This one is "You" - driven for + // real by typing.rs once a race is underway (see update_local_racer), + // and by drive_demo_progress's idle animation before that. + commands.spawn(( + Racer { + name: "You".into(), + is_local: true, + progress: 0.0, + wpm: 0.0, + accuracy: 100.0, + mistakes: 0, + }, + DemoRace, + )); +} + +// Idle animation only, for the brief window before a real race has started +// (typing.rs's TypingSession isn't active yet) - once real keystrokes are +// driving the local racer, this backs off so it doesn't fight that. +fn drive_demo_progress(time: Res<Time>, session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + if session.is_active() { + return; + } + for mut racer in &mut racers { + if !racer.is_local || racer.progress >= 1.0 { + continue; + } + let base_speed = 0.12; + racer.progress = (racer.progress + base_speed * time.delta_secs()).min(1.0); + racer.wpm = 60.0 + (racer.progress * 40.0); + } +} diff --git a/crates/steam/src/typing.rs b/crates/steam/src/typing.rs new file mode 100644 index 0000000..c64a540 --- /dev/null +++ b/crates/steam/src/typing.rs @@ -0,0 +1,216 @@ +// Real typing input for the desktop client, replacing the local racer's +// synthetic progress with actual keystrokes against the passage the server +// sent for the current race. Reuses typerpunk_core::game::Game - the same +// correctness/WPM/accuracy engine the wasm/web build runs - instead of +// reimplementing typing-test logic a third time. +use crate::racer::{DemoRace, Racer}; +use bevy::input::keyboard::{Key, KeyboardInput}; +use bevy::input::ButtonState; +use bevy::prelude::*; +use typerpunk_core::game::Game; + +// Bounded the same way the TUI windows its own text rendering - a +// multiplayer passage could in principle be long, and this keeps the +// number of spawned per-character entities sane regardless. +const MAX_VISIBLE_CHARS: usize = 400; + +#[derive(Resource, Default)] +pub struct TypingSession { + game: Option<Game>, + spans_built: bool, +} + +impl TypingSession { + pub fn start(&mut self, text: String) { + let mut game = Game::new(); + game.set_text(text); + game.start(); + self.game = Some(game); + self.spans_built = false; + } + + pub fn is_active(&self) -> bool { + self.game.as_ref().is_some_and(|g| !g.is_finished()) + } + + /// True once a race's Start message has actually arrived - distinct + /// from is_active(), which also goes false again once finished. Used to + /// gate outgoing Progress messages: sending them while still sitting in + /// the lobby is meaningless (there's no race to report progress on) and + /// floods the connection during the server's countdown at the worst + /// possible moment (see multiplayer_plugin.rs's send_local_progress). + pub fn has_started(&self) -> bool { + self.game.is_some() + } +} + +#[derive(Component)] +struct TypingTextRoot; + +#[derive(Component)] +struct TypingCharSpan(usize); + +#[derive(Component)] +struct TypingStatsText; + +pub struct TypingPlugin; + +impl Plugin for TypingPlugin { + fn build(&self, app: &mut App) { + app.init_resource::<TypingSession>() + .add_systems(Startup, spawn_typing_overlay) + .add_systems( + Update, + (rebuild_spans_on_session_start, handle_keyboard_input, recolor_spans, update_local_racer, update_stats_text).chain(), + ); + } +} + +const CORRECT_COLOR: Color = Color::srgb(0.0, 1.0, 0.62); +const INCORRECT_COLOR: Color = Color::srgb(1.0, 0.3, 0.3); +const PENDING_COLOR: Color = Color::srgb(0.5, 0.5, 0.55); +const CURSOR_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); + +fn spawn_typing_overlay(mut commands: Commands) { + commands.spawn(( + TypingTextRoot, + Text::new(""), + TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(70.0), + left: Val::Px(40.0), + right: Val::Px(40.0), + ..default() + }, + )); + commands.spawn(( + TypingStatsText, + Text::new(""), + TextColor(CORRECT_COLOR), + TextFont { font_size: bevy::text::FontSize::Px(18.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(24.0), + left: Val::Px(40.0), + ..default() + }, + )); +} + +// Spans are spawned once per race (character identities never change for a +// fixed passage) and only recolored afterward - rebuilding text entities +// every frame would be wasteful for no visual benefit over just updating +// TextColor on the ones that already exist. +fn rebuild_spans_on_session_start( + mut commands: Commands, + mut session: ResMut<TypingSession>, + root: Query<Entity, With<TypingTextRoot>>, + old_spans: Query<Entity, With<TypingCharSpan>>, +) { + let has_game = session.game.is_some(); + if !has_game { + if !old_spans.is_empty() { + for e in &old_spans { + commands.entity(e).despawn(); + } + } + return; + } + if session.spans_built { + return; + } + session.spans_built = true; + + for e in &old_spans { + commands.entity(e).despawn(); + } + + let Ok(root_entity) = root.single() else { return }; + let text = session.game.as_ref().unwrap().get_text(); + let children: Vec<Entity> = text + .chars() + .take(MAX_VISIBLE_CHARS) + .enumerate() + .map(|(i, c)| { + commands + .spawn((TypingCharSpan(i), TextSpan(c.to_string()), TextColor(PENDING_COLOR), TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() })) + .id() + }) + .collect(); + commands.entity(root_entity).add_children(&children); +} + +fn handle_keyboard_input(mut events: MessageReader<KeyboardInput>, mut session: ResMut<TypingSession>) { + let Some(game) = session.game.as_mut() else { + events.clear(); + return; + }; + if game.is_finished() { + events.clear(); + return; + } + for ev in events.read() { + if ev.state != ButtonState::Pressed { + continue; + } + match &ev.logical_key { + Key::Character(s) => { + let mut input = game.get_input(); + input.push_str(s); + let _ = game.handle_input(&input); + } + Key::Space => { + let mut input = game.get_input(); + input.push(' '); + let _ = game.handle_input(&input); + } + Key::Backspace => { + let _ = game.handle_backspace(false); + } + _ => {} + } + } +} + +fn recolor_spans(session: Res<TypingSession>, mut spans: Query<(&TypingCharSpan, &mut TextColor)>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count(); + let errors = game.get_error_positions(); + for (span, mut color) in &mut spans { + let i = span.0; + color.0 = if i < input_len { + if errors.contains(&i) { INCORRECT_COLOR } else { CORRECT_COLOR } + } else if i == input_len { + CURSOR_COLOR + } else { + PENDING_COLOR + }; + } +} + +fn update_local_racer(session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count() as f32; + let text_len = (game.get_text().chars().count() as f32).max(1.0); + let progress = (input_len / text_len).min(1.0); + let wpm = game.get_wpm() as f32; + let accuracy = game.get_accuracy() as f32; + for mut racer in &mut racers { + if racer.is_local { + racer.progress = if game.is_finished() { 1.0 } else { progress }; + racer.wpm = wpm; + racer.accuracy = accuracy; + } + } +} + +fn update_stats_text(session: Res<TypingSession>, mut text_query: Query<&mut Text, With<TypingStatsText>>) { + let Ok(mut text) = text_query.single_mut() else { return }; + let Some(game) = &session.game else { + **text = String::new(); + return; + }; + let status = if game.is_finished() { " - finished" } else { "" }; + **text = format!("{:.0} wpm {:.0}% acc{status}", game.get_wpm(), game.get_accuracy()); +} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..a86d055 --- /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"] + +[dependencies] +typerpunk-core = { path = "../core", features = ["tui", "multiplayer"] } +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 +futures-util.workspace = true +reqwest.workspace = true
\ No newline at end of file diff --git a/crates/tui/src/config_file.rs b/crates/tui/src/config_file.rs new file mode 100644 index 0000000..cc9f05f --- /dev/null +++ b/crates/tui/src/config_file.rs @@ -0,0 +1,51 @@ +// Local config file for the TUI's account state - a saved personal-access +// token, not a browser cookie, since a terminal app has no cookie jar. +// Lives at the OS config dir so it survives between runs the same way any +// other CLI tool's saved credentials would. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + pub token: Option<String>, + pub username: Option<String>, +} + +fn config_path() -> Option<PathBuf> { + let mut dir = dirs::config_dir()?; + dir.push("typerpunk"); + Some(dir.join("cli_auth.json")) +} + +pub fn load() -> AuthConfig { + let Some(path) = config_path() else { return AuthConfig::default() }; + let Ok(raw) = std::fs::read_to_string(&path) else { return AuthConfig::default() }; + serde_json::from_str(&raw).unwrap_or_default() +} + +pub fn save(config: &AuthConfig) { + let Some(path) = config_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(config) { + let _ = std::fs::write(&path, json); + } +} + +pub fn server_url() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +/// Same host as server_url(), just ws:// (or wss:// if the HTTP base was +/// already secured) instead of http(s):// - for the multiplayer WebSocket. +pub fn server_ws_url() -> String { + let http = server_url(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..f5c4a68 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,222 @@ +mod config_file; +mod multiplayer_net; +mod net; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use multiplayer_net::{LocalUpdate, MultiplayerConnection, NetEvent as MpNetEvent}; +use net::{NetClient, NetResponse}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; +use std::{io, error::Error as StdError}; +use typerpunk_core::{ + app::{App, MpPlayer, NetworkAction, State}, + 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 mut app = match App::new() { + Ok(app) => app, + Err(e) => { + cleanup_terminal(&mut terminal)?; + return Err(e); + } + }; + + // A saved token means a returning user doesn't have to log in again -- + // restored eagerly so the main menu's account line reflects it from the + // very first frame. + let saved_auth = config_file::load(); + if let (Some(token), Some(username)) = (saved_auth.token, saved_auth.username) { + app.restore_session(token, username); + } + + let net_client = NetClient::spawn(); + + let mut input_handler = InputHandler::new(app); + let res = run_app(&mut terminal, &mut input_handler, &net_client); + + // 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 apply_net_response(app: &mut App, response: NetResponse) { + match response { + NetResponse::LoginOk { username, token } => { + app.set_login_success(username.clone(), token.clone()); + // Only a real login/register issues a fresh token - persist it + // so the next launch starts already signed in. + if let Some(token) = token { + config_file::save(&config_file::AuthConfig { token: Some(token), username: Some(username) }); + } + } + NetResponse::Error(message) => app.set_net_error(message), + NetResponse::Leaderboard(entries) => app.set_leaderboard(entries), + NetResponse::Friends { list, incoming, outgoing } => app.set_friends(list, incoming, outgoing), + } +} + +// PlayerList arrives every time someone joins/readies/leaves, not just +// once - resetting progress/wpm to zero on every refresh would erase a +// mid-race player's visible position each time the roster is re-sent, so +// existing entries carry theirs forward instead of being rebuilt from +// scratch. +fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multiplayer::PlayerInfo>) -> Vec<MpPlayer> { + incoming + .into_iter() + .map(|p| { + let (progress, wpm) = existing.iter().find(|e| e.id == p.id).map(|e| (e.progress, e.wpm)).unwrap_or((0.0, 0.0)); + MpPlayer { id: p.id, name: p.name, ready: p.ready, progress, wpm } + }) + .collect() +} + +fn apply_mp_event(app: &mut App, event: MpNetEvent) { + match event { + MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code), + MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id), + MpNetEvent::PlayerList(players) => { + let merged = merge_player_list(&app.mp_players, players); + app.set_mp_player_list(merged); + } + MpNetEvent::Countdown(seconds) => app.set_mp_countdown(seconds), + MpNetEvent::Start(text) => app.set_mp_start(text), + MpNetEvent::PlayerProgress { player_id, percent, wpm } => app.set_mp_player_progress(&player_id, percent, wpm), + MpNetEvent::PlayerFinished { player_id, wpm, place } => app.set_mp_player_finished(&player_id, "opponent", wpm, place), + MpNetEvent::RoomClosed(reason) => app.set_mp_room_closed(reason), + MpNetEvent::Error(message) => app.set_mp_error(message), + } +} + +fn run_app<B: ratatui::backend::Backend>( + terminal: &mut Terminal<B>, + input_handler: &mut InputHandler, + net_client: &NetClient, +) -> io::Result<()> { + let mut last_render = std::time::Instant::now(); + let render_interval = std::time::Duration::from_millis(16); // ~60 FPS + let mut mp_connection: Option<MultiplayerConnection> = None; + + loop { + // Update app state to refresh timers and stats + input_handler.app.update(); + + // Drain whatever the network workers have finished since last frame + // - never blocks, since try_recv() only returns what's already + // there. + while let Some(response) = net_client.try_recv() { + apply_net_response(&mut input_handler.app, response); + } + if let Some(conn) = &mp_connection { + while let Some(event) = conn.try_recv() { + apply_mp_event(&mut input_handler.app, event); + } + } + + // Leaving multiplayer entirely (Esc from lobby/race/results resets + // this on the app side) drops the connection here too, so the + // background thread notices its channels are gone and closes the + // socket instead of lingering for the rest of the process. + let in_multiplayer = matches!( + input_handler.app.state, + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults + ); + if !in_multiplayer && mp_connection.is_some() { + mp_connection = None; + } + + // A race that's finished typing (is_finished(), handled inside + // app.rs's own tick) but hasn't yet heard the server confirm it via + // PlayerFinished still needs its Finish message actually sent -- + // driven here every frame rather than per-keypress, since finishing + // is a state the typist arrives at continuously, not a discrete key. + if input_handler.app.state == State::MultiplayerRace && !input_handler.app.mp_finish_sent { + if input_handler.app.is_finished() { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Finish { + wpm: input_handler.app.stats.wpm() as f32, + accuracy: input_handler.app.stats.accuracy() as f32, + time: input_handler.app.stats.elapsed_time().as_secs_f32(), + }); + } + input_handler.app.mp_finish_sent = true; + } else if let Some(conn) = &mp_connection { + let percent = input_handler.app.get_progress() as f32; + let wpm = input_handler.app.stats.wpm() as f32; + conn.send(LocalUpdate::Progress { percent, wpm }); + } + } + + 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 let Some(action) = input_handler.app.pending_network_action.take() { + match action { + NetworkAction::CreateMultiplayerRoom => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), None, name)); + } + NetworkAction::JoinMultiplayerRoom { code } => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), Some(code), name)); + } + NetworkAction::MultiplayerReady => { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Ready); + } + input_handler.app.net_busy = false; + } + other => net_client.send(other, input_handler.app.auth_token.clone()), + } + } + 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/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs new file mode 100644 index 0000000..8c6c6b0 --- /dev/null +++ b/crates/tui/src/multiplayer_net.rs @@ -0,0 +1,183 @@ +// Real-time WebSocket networking for TUI Multiplayer, alongside net.rs's +// one-shot REST calls (login/leaderboard/friends). Same background-thread +// pattern as crates/steam/src/net.rs (which speaks the identical wire +// protocol against the same server) - a dedicated OS thread owns a tokio +// runtime and the live connection, bridging to the sync render loop over +// plain std::sync::mpsc channels polled once per frame. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Ready, + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +pub struct MultiplayerConnection { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, + update_tx: mpsc::Sender<LocalUpdate>, +} + +impl MultiplayerConnection { + /// `room_code: None` creates a fresh room (POSTs to the REST endpoint + /// first, then connects); `Some(code)` connects straight to that + /// existing room's WebSocket. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> Self { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start multiplayer network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + Self { event_rx: std::sync::Mutex::new(event_rx), update_tx } + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } + + pub fn send(&self, update: LocalUpdate) { + let _ = self.update_tx.send(update); + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{http_base}/api/multiplayer/rooms")) + .send() + .await + .map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, place, .. } => NetEvent::PlayerFinished { player_id, wpm, place }, + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short + // interval, same pattern as crates/steam/src/net.rs. Short + // enough that a manual "r: ready" keypress feels responsive. + _ = tokio::time::sleep(std::time::Duration::from_millis(80)) => { + loop { + match update_rx.try_recv() { + Ok(update) => { + let client_msg = match update { + LocalUpdate::Ready => ClientMessage::Ready, + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + Err(mpsc::TryRecvError::Empty) => break, + // main.rs dropped its MultiplayerConnection (left the + // room/app) - nothing left to send to, close out + // instead of holding the socket open indefinitely. + Err(mpsc::TryRecvError::Disconnected) => return, + } + } + } + } + } +} diff --git a/crates/tui/src/net.rs b/crates/tui/src/net.rs new file mode 100644 index 0000000..a0e55da --- /dev/null +++ b/crates/tui/src/net.rs @@ -0,0 +1,193 @@ +// Background network worker for the TUI. The render loop in main.rs is a +// plain synchronous 60fps loop (see run_app) - rather than restructure that +// into async, a dedicated OS thread owns its own tokio runtime and a small +// reqwest client, communicating over plain std::sync::mpsc channels. The +// main loop sends a Job after each keypress that queues one, and polls +// try_recv() once per frame to apply whatever came back. +use crate::config_file::server_url; +use serde::Deserialize; +use std::sync::mpsc; +use typerpunk_core::app::{FriendRow, LeaderboardRow, NetworkAction}; + +pub enum NetResponse { + LoginOk { username: String, token: Option<String> }, + Error(String), + Leaderboard(Vec<LeaderboardRow>), + Friends { list: Vec<FriendRow>, incoming: Vec<FriendRow>, outgoing: Vec<FriendRow> }, +} + +struct Job { + action: NetworkAction, + token: Option<String>, +} + +pub struct NetClient { + tx: mpsc::Sender<Job>, + rx: mpsc::Receiver<NetResponse>, +} + +impl NetClient { + pub fn spawn() -> Self { + let (job_tx, job_rx) = mpsc::channel::<Job>(); + let (resp_tx, resp_rx) = mpsc::channel::<NetResponse>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to start TUI network runtime"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build http client"); + let base = server_url(); + + while let Ok(job) = job_rx.recv() { + let response = rt.block_on(run_job(&client, &base, job)); + if resp_tx.send(response).is_err() { + break; // main loop is gone + } + } + }); + + Self { tx: job_tx, rx: resp_rx } + } + + pub fn send(&self, action: NetworkAction, token: Option<String>) { + let _ = self.tx.send(Job { action, token }); + } + + pub fn try_recv(&self) -> Option<NetResponse> { + self.rx.try_recv().ok() + } +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +async fn error_message(resp: reqwest::Response) -> String { + let status = resp.status(); + match resp.json::<ErrorBody>().await { + Ok(body) => body.error, + Err(_) => format!("request failed ({status})"), + } +} + +#[derive(Deserialize)] +struct UserView { + username: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +async fn run_job(client: &reqwest::Client, base: &str, job: Job) -> NetResponse { + match job.action { + NetworkAction::Login { username, password } => login(client, base, &username, &password).await, + NetworkAction::Register { username, password } => { + let register_url = format!("{base}/api/auth/register"); + match client.post(®ister_url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => login(client, base, &username, &password).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchLeaderboard { mode } => { + let url = format!("{base}/api/leaderboard?mode={mode}"); + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<Vec<LeaderboardRow>>().await { + Ok(rows) => NetResponse::Leaderboard(rows), + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchFriends => fetch_friends(client, base, job.token.as_deref()).await, + NetworkAction::SendFriendRequest { username } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/request"); + match client.post(&url).bearer_auth(token).json(&serde_json::json!({ "username": username })).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::AcceptFriendRequest { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}/accept"); + match client.post(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::RemoveFriendship { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}"); + match client.delete(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + // Handled entirely by main.rs's own MultiplayerConnection instead + // (a persistent WebSocket, not a one-shot REST job) - these never + // actually reach this worker; this arm only exists so the match + // stays exhaustive over the shared NetworkAction enum. + NetworkAction::CreateMultiplayerRoom | NetworkAction::JoinMultiplayerRoom { .. } | NetworkAction::MultiplayerReady => { + NetResponse::Error("multiplayer action sent to the wrong network worker".to_string()) + } + } +} + +async fn login(client: &reqwest::Client, base: &str, username: &str, password: &str) -> NetResponse { + let url = format!("{base}/api/auth/token"); + match client.post(&url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<TokenResponse>().await { + Ok(body) => NetResponse::LoginOk { username: body.user.username, token: Some(body.token) }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} + +#[derive(Deserialize)] +struct FriendEntry { + friendship_id: String, + username: String, +} + +#[derive(Deserialize)] +struct FriendsListBody { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn to_rows(entries: Vec<FriendEntry>) -> Vec<FriendRow> { + entries.into_iter().map(|e| FriendRow { friendship_id: e.friendship_id, username: e.username }).collect() +} + +async fn fetch_friends(client: &reqwest::Client, base: &str, token: Option<&str>) -> NetResponse { + let Some(token) = token else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends"); + match client.get(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<FriendsListBody>().await { + Ok(body) => NetResponse::Friends { + list: to_rows(body.friends), + incoming: to_rows(body.incoming_requests), + outgoing: to_rows(body.outgoing_requests), + }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} 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..abec93b --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,162 @@ +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 { + // Delegates to the core Game's own is_finished() instead of a second, + // independent copy of the completion check - this used to + // re-implement the (then-current) logic by hand, which meant the + // exact-match-required bug fixed in core::game::Game had to be fixed + // here separately too, and wasn't: this copy kept requiring an exact + // match long after the core was fixed to be length-only, silently + // freezing every test with an uncorrected mistake anywhere in it. + self.game.as_ref() + .map(|game| game.is_finished()) + .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 init() { + console_error_panic_hook::set_once(); +}
\ No newline at end of file |