srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-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')
-rw-r--r--crates/core/Cargo.toml48
-rw-r--r--crates/core/src/app.rs1247
-rw-r--r--crates/core/src/config.rs42
-rw-r--r--crates/core/src/custom_text.rs345
-rw-r--r--crates/core/src/game.rs666
-rw-r--r--crates/core/src/input.rs182
-rw-r--r--crates/core/src/lib.rs25
-rw-r--r--crates/core/src/multiplayer.rs43
-rw-r--r--crates/core/src/stats.rs266
-rw-r--r--crates/core/src/tests/game_tests.rs143
-rw-r--r--crates/core/src/tests/mod.rs3
-rw-r--r--crates/core/src/tests/stats_tests.rs125
-rw-r--r--crates/core/src/tests/text_tests.rs83
-rw-r--r--crates/core/src/text.rs72
-rw-r--r--crates/core/src/theme.rs24
-rw-r--r--crates/core/src/types.rs148
-rw-r--r--crates/core/src/ui.rs729
-rw-r--r--crates/core/src/wasm.rs98
-rw-r--r--crates/core/src/words.rs81
-rw-r--r--crates/server/.env.example17
-rw-r--r--crates/server/Cargo.toml37
-rw-r--r--crates/server/migrations/0001_users_sessions.sql14
-rw-r--r--crates/server/migrations/0002_test_results.sql13
-rw-r--r--crates/server/migrations/0003_friendships.sql11
-rw-r--r--crates/server/migrations/0004_spotify_tokens.sql6
-rw-r--r--crates/server/migrations/0005_fairness.sql2
-rw-r--r--crates/server/migrations/0006_cosmetics.sql33
-rw-r--r--crates/server/migrations/0007_api_tokens.sql8
-rw-r--r--crates/server/src/anticheat.rs81
-rw-r--r--crates/server/src/auth.rs285
-rw-r--r--crates/server/src/cosmetics.rs153
-rw-r--r--crates/server/src/error.rs52
-rw-r--r--crates/server/src/friends.rs193
-rw-r--r--crates/server/src/lyrics.rs67
-rw-r--r--crates/server/src/main.rs255
-rw-r--r--crates/server/src/multiplayer.rs594
-rw-r--r--crates/server/src/rate_limit.rs34
-rw-r--r--crates/server/src/spotify.rs237
-rw-r--r--crates/server/src/state.rs65
-rw-r--r--crates/server/src/stats.rs275
-rw-r--r--crates/steam/Cargo.toml21
-rw-r--r--crates/steam/assets/sprites/pulse.pngbin0 -> 495 bytes
-rw-r--r--crates/steam/assets/sprites/pulse.svg5
-rw-r--r--crates/steam/assets/sprites/racer_marker.pngbin0 -> 371 bytes
-rw-r--r--crates/steam/assets/sprites/racer_marker.svg6
-rw-r--r--crates/steam/assets/sprites/warden_layer.pngbin0 -> 309 bytes
-rw-r--r--crates/steam/assets/sprites/warden_layer.svg14
-rw-r--r--crates/steam/src/lore.rs24
-rw-r--r--crates/steam/src/main.rs55
-rw-r--r--crates/steam/src/multiplayer_plugin.rs179
-rw-r--r--crates/steam/src/net.rs178
-rw-r--r--crates/steam/src/race/breach.rs114
-rw-r--r--crates/steam/src/race/mod.rs16
-rw-r--r--crates/steam/src/race/signal.rs110
-rw-r--r--crates/steam/src/race/terminal.rs81
-rw-r--r--crates/steam/src/racer.rs66
-rw-r--r--crates/steam/src/typing.rs216
-rw-r--r--crates/tui/Cargo.toml32
-rw-r--r--crates/tui/src/config_file.rs51
-rw-r--r--crates/tui/src/main.rs222
-rw-r--r--crates/tui/src/multiplayer_net.rs183
-rw-r--r--crates/tui/src/net.rs193
-rw-r--r--crates/wasm/Cargo.toml26
-rw-r--r--crates/wasm/src/lib.rs162
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 = &current_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, &current_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(&current_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
new file mode 100644
index 0000000..a70d94d
--- /dev/null
+++ b/crates/steam/assets/sprites/pulse.png
Binary files differ
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
new file mode 100644
index 0000000..e7afbc3
--- /dev/null
+++ b/crates/steam/assets/sprites/racer_marker.png
Binary files differ
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
new file mode 100644
index 0000000..0b4f3f2
--- /dev/null
+++ b/crates/steam/assets/sprites/warden_layer.png
Binary files differ
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(&register_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