From a726d9f5fb56e1fd7983c5ac806d408ad78daa86 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:53:00 +0200 Subject: 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. --- .gitattributes | 40 + .gitignore | 14 + Cargo.toml | 77 +- LICENSE | 0 README.md | 120 +- TODO-postgres.md | 65 + checklist.md | 272 +++ config.json | 12 + crates/core/Cargo.toml | 48 + crates/core/src/app.rs | 1247 +++++++++++ crates/core/src/config.rs | 42 + crates/core/src/custom_text.rs | 345 +++ crates/core/src/game.rs | 666 ++++++ crates/core/src/input.rs | 182 ++ crates/core/src/lib.rs | 25 + crates/core/src/multiplayer.rs | 43 + crates/core/src/stats.rs | 266 +++ crates/core/src/tests/game_tests.rs | 143 ++ crates/core/src/tests/mod.rs | 3 + crates/core/src/tests/stats_tests.rs | 125 ++ crates/core/src/tests/text_tests.rs | 83 + crates/core/src/text.rs | 72 + crates/core/src/theme.rs | 24 + crates/core/src/types.rs | 148 ++ crates/core/src/ui.rs | 729 +++++++ crates/core/src/wasm.rs | 98 + crates/core/src/words.rs | 81 + crates/server/.env.example | 17 + crates/server/Cargo.toml | 37 + crates/server/migrations/0001_users_sessions.sql | 14 + crates/server/migrations/0002_test_results.sql | 13 + crates/server/migrations/0003_friendships.sql | 11 + crates/server/migrations/0004_spotify_tokens.sql | 6 + crates/server/migrations/0005_fairness.sql | 2 + crates/server/migrations/0006_cosmetics.sql | 33 + crates/server/migrations/0007_api_tokens.sql | 8 + crates/server/src/anticheat.rs | 81 + crates/server/src/auth.rs | 285 +++ crates/server/src/cosmetics.rs | 153 ++ crates/server/src/error.rs | 52 + crates/server/src/friends.rs | 193 ++ crates/server/src/lyrics.rs | 67 + crates/server/src/main.rs | 255 +++ crates/server/src/multiplayer.rs | 594 ++++++ crates/server/src/rate_limit.rs | 34 + crates/server/src/spotify.rs | 237 +++ crates/server/src/state.rs | 65 + crates/server/src/stats.rs | 275 +++ crates/steam/Cargo.toml | 21 + crates/steam/assets/sprites/pulse.png | Bin 0 -> 495 bytes crates/steam/assets/sprites/pulse.svg | 5 + crates/steam/assets/sprites/racer_marker.png | Bin 0 -> 371 bytes crates/steam/assets/sprites/racer_marker.svg | 6 + crates/steam/assets/sprites/warden_layer.png | Bin 0 -> 309 bytes crates/steam/assets/sprites/warden_layer.svg | 14 + crates/steam/src/lore.rs | 24 + crates/steam/src/main.rs | 55 + crates/steam/src/multiplayer_plugin.rs | 179 ++ crates/steam/src/net.rs | 178 ++ crates/steam/src/race/breach.rs | 114 + crates/steam/src/race/mod.rs | 16 + crates/steam/src/race/signal.rs | 110 + crates/steam/src/race/terminal.rs | 81 + crates/steam/src/racer.rs | 66 + crates/steam/src/typing.rs | 216 ++ crates/tui/Cargo.toml | 32 + crates/tui/src/config_file.rs | 51 + crates/tui/src/main.rs | 222 ++ crates/tui/src/multiplayer_net.rs | 183 ++ crates/tui/src/net.rs | 193 ++ crates/wasm/Cargo.toml | 26 + crates/wasm/src/lib.rs | 162 ++ data/packs/business.json | 17 + data/packs/general.json | 22 + data/packs/history.json | 16 + data/packs/literature.json | 25 + data/packs/movies.json | 17 + data/packs/nature.json | 17 + data/packs/philosophy.json | 17 + data/packs/programming.json | 28 + data/packs/quotes.json | 19 + data/packs/science.json | 17 + data/packs/technology.json | 17 + install.sh | 62 + scripts/extract_texts.js | 148 ++ scripts/merge_packs.js | Bin 0 -> 2269 bytes sentences.txt | 3 - src/main.rs | 320 --- src/test.rs | 164 -- texts.json | 978 +++++++++ texts.txt | 13 + web/README.md | 67 + web/index.html | 26 + web/launch.sh | 78 + web/serve.mjs | 76 + web/src/api.js | 43 + web/src/app.js | 362 ++++ web/src/auth.js | 75 + web/src/chart.js | 420 ++++ web/src/cornerRail.js | 125 ++ web/src/counts.js | 61 + web/src/customText.js | 155 ++ web/src/data/texts.json | 978 +++++++++ web/src/deviceDetect.js | 10 + web/src/game.js | 17 + web/src/keySounds.js | 42 + web/src/keyStats.js | 59 + web/src/languages.js | 205 ++ web/src/main.js | 16 + web/src/multiplayer.js | 81 + web/src/pb.js | 56 + web/src/profileStats.js | 62 + web/src/screens/accountScreen.js | 121 ++ web/src/screens/endScreen.js | 226 ++ web/src/screens/friendsScreen.js | 149 ++ web/src/screens/icons.js | 109 + web/src/screens/leaderboardScreen.js | 120 ++ web/src/screens/lyricsScreen.js | 127 ++ web/src/screens/mainMenu.js | 531 +++++ web/src/screens/multiplayerScreen.js | 265 +++ web/src/screens/passiveMode.js | 159 ++ web/src/screens/placeholderScreen.js | 31 + web/src/screens/publicProfileScreen.js | 92 + web/src/screens/statsScreen.js | 85 + web/src/screens/storeScreen.js | 145 ++ web/src/screens/typingGame.js | 309 +++ web/src/settings.js | 40 + web/src/stats.js | 66 + web/src/styles.css | 2440 ++++++++++++++++++++++ web/src/theme.js | 66 + web/src/themeButton.js | 70 + web/src/tooltip.js | 66 + web/src/topRail.js | 114 + web/src/util.js | 5 + web/src/wordGenerator.js | 185 ++ web/tests/README.md | 42 + web/tests/helpers.py | 38 + web/tests/run_all.py | 42 + web/tests/test_account_and_store.py | 56 + web/tests/test_leaderboard_and_friends.py | 60 + web/tests/test_typing_flow.py | 54 + 141 files changed, 19524 insertions(+), 559 deletions(-) create mode 100644 .gitattributes mode change 100644 => 100755 .gitignore mode change 100644 => 100755 Cargo.toml mode change 100644 => 100755 LICENSE mode change 100644 => 100755 README.md create mode 100644 TODO-postgres.md create mode 100644 checklist.md create mode 100644 config.json create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/app.rs create mode 100644 crates/core/src/config.rs create mode 100644 crates/core/src/custom_text.rs create mode 100644 crates/core/src/game.rs create mode 100644 crates/core/src/input.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/multiplayer.rs create mode 100644 crates/core/src/stats.rs create mode 100644 crates/core/src/tests/game_tests.rs create mode 100644 crates/core/src/tests/mod.rs create mode 100644 crates/core/src/tests/stats_tests.rs create mode 100644 crates/core/src/tests/text_tests.rs create mode 100644 crates/core/src/text.rs create mode 100644 crates/core/src/theme.rs create mode 100644 crates/core/src/types.rs create mode 100644 crates/core/src/ui.rs create mode 100644 crates/core/src/wasm.rs create mode 100644 crates/core/src/words.rs create mode 100644 crates/server/.env.example create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/migrations/0001_users_sessions.sql create mode 100644 crates/server/migrations/0002_test_results.sql create mode 100644 crates/server/migrations/0003_friendships.sql create mode 100644 crates/server/migrations/0004_spotify_tokens.sql create mode 100644 crates/server/migrations/0005_fairness.sql create mode 100644 crates/server/migrations/0006_cosmetics.sql create mode 100644 crates/server/migrations/0007_api_tokens.sql create mode 100644 crates/server/src/anticheat.rs create mode 100644 crates/server/src/auth.rs create mode 100644 crates/server/src/cosmetics.rs create mode 100644 crates/server/src/error.rs create mode 100644 crates/server/src/friends.rs create mode 100644 crates/server/src/lyrics.rs create mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/multiplayer.rs create mode 100644 crates/server/src/rate_limit.rs create mode 100644 crates/server/src/spotify.rs create mode 100644 crates/server/src/state.rs create mode 100644 crates/server/src/stats.rs create mode 100644 crates/steam/Cargo.toml create mode 100644 crates/steam/assets/sprites/pulse.png create mode 100644 crates/steam/assets/sprites/pulse.svg create mode 100644 crates/steam/assets/sprites/racer_marker.png create mode 100644 crates/steam/assets/sprites/racer_marker.svg create mode 100644 crates/steam/assets/sprites/warden_layer.png create mode 100644 crates/steam/assets/sprites/warden_layer.svg create mode 100644 crates/steam/src/lore.rs create mode 100644 crates/steam/src/main.rs create mode 100644 crates/steam/src/multiplayer_plugin.rs create mode 100644 crates/steam/src/net.rs create mode 100644 crates/steam/src/race/breach.rs create mode 100644 crates/steam/src/race/mod.rs create mode 100644 crates/steam/src/race/signal.rs create mode 100644 crates/steam/src/race/terminal.rs create mode 100644 crates/steam/src/racer.rs create mode 100644 crates/steam/src/typing.rs create mode 100644 crates/tui/Cargo.toml create mode 100644 crates/tui/src/config_file.rs create mode 100644 crates/tui/src/main.rs create mode 100644 crates/tui/src/multiplayer_net.rs create mode 100644 crates/tui/src/net.rs create mode 100644 crates/wasm/Cargo.toml create mode 100644 crates/wasm/src/lib.rs create mode 100644 data/packs/business.json create mode 100644 data/packs/general.json create mode 100644 data/packs/history.json create mode 100644 data/packs/literature.json create mode 100644 data/packs/movies.json create mode 100644 data/packs/nature.json create mode 100644 data/packs/philosophy.json create mode 100644 data/packs/programming.json create mode 100644 data/packs/quotes.json create mode 100644 data/packs/science.json create mode 100644 data/packs/technology.json create mode 100755 install.sh create mode 100644 scripts/extract_texts.js create mode 100644 scripts/merge_packs.js delete mode 100644 sentences.txt delete mode 100644 src/main.rs delete mode 100644 src/test.rs create mode 100644 texts.json create mode 100644 texts.txt create mode 100644 web/README.md create mode 100644 web/index.html create mode 100755 web/launch.sh create mode 100644 web/serve.mjs create mode 100644 web/src/api.js create mode 100644 web/src/app.js create mode 100644 web/src/auth.js create mode 100644 web/src/chart.js create mode 100644 web/src/cornerRail.js create mode 100644 web/src/counts.js create mode 100644 web/src/customText.js create mode 100644 web/src/data/texts.json create mode 100644 web/src/deviceDetect.js create mode 100644 web/src/game.js create mode 100644 web/src/keySounds.js create mode 100644 web/src/keyStats.js create mode 100644 web/src/languages.js create mode 100644 web/src/main.js create mode 100644 web/src/multiplayer.js create mode 100644 web/src/pb.js create mode 100644 web/src/profileStats.js create mode 100644 web/src/screens/accountScreen.js create mode 100644 web/src/screens/endScreen.js create mode 100644 web/src/screens/friendsScreen.js create mode 100644 web/src/screens/icons.js create mode 100644 web/src/screens/leaderboardScreen.js create mode 100644 web/src/screens/lyricsScreen.js create mode 100644 web/src/screens/mainMenu.js create mode 100644 web/src/screens/multiplayerScreen.js create mode 100644 web/src/screens/passiveMode.js create mode 100644 web/src/screens/placeholderScreen.js create mode 100644 web/src/screens/publicProfileScreen.js create mode 100644 web/src/screens/statsScreen.js create mode 100644 web/src/screens/storeScreen.js create mode 100644 web/src/screens/typingGame.js create mode 100644 web/src/settings.js create mode 100644 web/src/stats.js create mode 100644 web/src/styles.css create mode 100644 web/src/theme.js create mode 100644 web/src/themeButton.js create mode 100644 web/src/tooltip.js create mode 100644 web/src/topRail.js create mode 100644 web/src/util.js create mode 100644 web/src/wordGenerator.js create mode 100644 web/tests/README.md create mode 100644 web/tests/helpers.py create mode 100644 web/tests/run_all.py create mode 100644 web/tests/test_account_and_store.py create mode 100644 web/tests/test_leaderboard_and_friends.py create mode 100644 web/tests/test_typing_flow.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..497a7b6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,40 @@ +# Core crate +crates/core/ export-ignore +crates/core/** export-ignore + +# TUI crate +crates/tui/ export-ignore +crates/tui/** export-ignore + +# Web crate +crates/web/ export-ignore +crates/web/** export-ignore + +# Documentation +docs/ export-ignore +docs/** export-ignore + +# Web interface +web/ export-ignore +web/** export-ignore + +# Configuration +config/ export-ignore +config/** export-ignore + +# Text content +texts/ export-ignore +texts/** export-ignore + +# Build artifacts +target/ export-ignore +target/** export-ignore + +# IDE files +.idea/ export-ignore +.vscode/ export-ignore +*.iml export-ignore + +# OS files +.DS_Store export-ignore +Thumbs.db export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 96ef6c0..830459a --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,16 @@ /target Cargo.lock +/archives +/web/wasm +/typerpunk.db +/typerpunk.db-journal +/crates/server/*.db +/crates/server/*.db-journal +/crates/server/.env + +# Browser console dump kept for debugging, not part of the project +/console-export-*.txt + +# Python bytecode from the Playwright test suite +__pycache__/ +*.pyc diff --git a/Cargo.toml b/Cargo.toml old mode 100644 new mode 100755 index b2209a5..15205f9 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,64 @@ -[package] -name = "typerpunk" +[workspace] +members = [ + "crates/core", + "crates/tui", + "crates/wasm", + "crates/steam", + "crates/server" +] +resolver = "2" + +[workspace.package] version = "0.1.0" edition = "2021" +authors = ["Your Name "] +license = "MIT" +description = "A modern typing test application with TUI and web interfaces" + +[workspace.dependencies] +# Core dependencies +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +thiserror = "1.0" +config = "0.13" +dirs = "5.0" +rlua = "0.19" +rand = "0.8" + +# Terminal UI +crossterm = "0.27" +ratatui = "0.24" + +# Web/WASM +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = "0.3" +wasm-bindgen-futures = "0.4" +wasm-timer = "0.2" + +# Networking +tokio-tungstenite = { version = "0.20", features = ["native-tls"] } +futures-util = "0.3" -[dependencies] -crossterm = "0.27.0" -tui = { version = "0.19.0", features = ["crossterm"], default-features = false } -termion = "3.0.0" -rand = "0.8.5" -paragraph = "0.0.2" -console = "0.15.8" -ansi_term = "0.12.1" -serde_json = "1.0.114" -regex = "1.6.1" -ureq = "2.9.6" -tokio = { version = "1.36.0", features = ["full"] } +# Backend server +axum = { version = "0.7", features = ["ws"] } +axum-extra = { version = "0.9", features = ["cookie"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "time", "uuid"] } +argon2 = "0.5" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +uuid = { version = "1.10", features = ["v4", "serde"] } +time = { version = "0.3", features = ["serde"] } +dashmap = "6.1" +dotenvy = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } -[[bin]] -name = "typerpunk" -path = "src/main.rs" +# Steam desktop client +# Using full default features for now (guarantees DefaultPlugins resolves). +# Trimming to a minimal feature set is a later optimization once this builds +# cleanly - don't fight feature-flag mismatches without a compiler to check against. +bevy = "0.19" diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index aad3b85..991d2e5 --- a/README.md +++ b/README.md @@ -1,77 +1,87 @@ -# _Typerpunk_ +# TyperPunk -**_Typerpunk_** is a simple typing game written in Rust, where players are challenged to type sentences as quickly as possible. The game calculates the player's typing speed in Words Per Minute (WPM) and time taken. +A modern typing test in your terminal (CLI) and in the browser (Web). -> NOTE: Game is still in early stages of development. Plenty of features will be implemented such as programming related minigames, difficulty/custom settings and multiplayer to name a few. There are also plans to make this into not just a "cmdline" game but also have a fully fledged website and desktop gui client as well. +## Quick Start -## Features (beta) +- **CLI (Terminal UI)** + ```bash + # Clone and enter + git clone https://github.com/yourusername/typerpunk.git + cd typerpunk -- Randomly selects sentences from a provided list for the player to type. -- Calculates typing speed in Words Per Minute (WPM). -- Color-coded feedback on typed characters (green for correct, red for incorrect, gray for untyped). + # Install for CLI (builds TUI and optionally merges dataset packs) + ./install.sh -## Installation + # Run CLI + cargo run --package typerpunk-tui + ``` -To play **_Typerpunk_**, make sure you have Rust installed on your system. You can install Rust from [rustup.rs](https://rustup.rs/). +- **Web** + ```bash + # From repo root: builds WASM and starts the static dev server + ./web/launch.sh + ``` + Opens http://localhost:4173 -- Can also use this to quickly download rust: +## Dataset (shared by CLI and Web) -```bash -curl https://sh.rustup.rs -sSf | sh -s -``` +- **Offline (recommended)** + - Add texts to `data/packs/*.json` with fields: + ```json + { "category": "programming", "content": "80-400 chars…", "attribution": "Author" } + ``` + - Merge packs into the shared `texts.json` at repo root: + ```bash + node scripts/merge_packs.js + ``` -- Clone this repository: +- **Online (optional, web only)** + - Host a `texts.json` and set a URL in the page (e.g., `web/index.html`): + ```html + + ``` + - The web app uses the online dataset if reachable; otherwise it falls back to the bundled file. -```bash -git clone https://github.com/srdusr/typerpunk.git -``` +Notes: +- `web/launch.sh` copies the root `texts.json` into `web/src/data/texts.json` for local dev. +- A small fallback dataset is kept in `web/src/data/texts.json`. -- Navigate to the project directory: +## CLI Keys -```bash -cd typerpunk -``` +- Start: Enter +- Quit: Esc +- Change category: Left/Right +- Delete word: Ctrl+Backspace / Alt+Backspace / Ctrl+H / Ctrl+W -- Build and run the game: +## Scripts Scope -```bash -cargo run --release -``` +- `install.sh`: CLI-focused (Rust toolchain, dataset merge via Node, builds TUI) +- `web/launch.sh`: Web dev workflow (WASM build + zero-dependency static server) -## How to Play +No npm packages are used anywhere in this repo. Node.js is used only as a +runtime for small built-in-module-only scripts (`scripts/merge_packs.js`, +`web/serve.mjs`); nothing is ever installed from the npm registry. -- Run the executable: +## Repo Layout -```bash -./target/release/typerpunk ``` - -- Or put the executable into your path. Example: - -```bash -sudo cp target/release/typerpunk /usr/local/bin +typerpunk/ +├── Cargo.toml # Workspace configuration +├── crates/ +│ ├── core/ # Shared core functionality +│ └── tui/ # Terminal UI implementation +├── data/ +│ └── packs/ # Offline dataset packs +├── web/ # Web app (plain HTML/CSS/JS, no build step) +│ ├── src/ +│ ├── index.html +│ └── serve.mjs +├── scripts/ +│ └── merge_packs.js # Merge packs into texts.json +└── README.md ``` -### Gameplay: - -When the game starts, you will see a main menu. -Press `Enter` to begin the typing challenge. -Random text will be shown and game will only start as soon as you start typing. -Press `Enter` when you have finished typing the sentence. -The game will display your Words Per Minute (WPM) and time taken. -To play again, press `Enter` at the End screen. -To quit the game, press `Esc` at any time. - -### Controls: - -`Enter`: Submit typed sentence or proceed in menus. -`Backspace`: Delete the last character. -`Esc`: Quit the game or go back to the main menu. - -## Contributing - -Contributions are welcome! If you have any ideas, bug fixes, or improvements, feel free to open an issue or submit a pull request. - ## License -This project is licensed under the MIT License - see the LICENSE file for details. +MIT diff --git a/TODO-postgres.md b/TODO-postgres.md new file mode 100644 index 0000000..5353ac1 --- /dev/null +++ b/TODO-postgres.md @@ -0,0 +1,65 @@ +# Migrate the server from SQLite to PostgreSQL + +## Why + +The server is a multi-user network service. It handles auth, sessions, +friendships, leaderboards, live multiplayer races, and rate limiting. SQLite +allows only one writer at a time. Races, leaderboard writes, and rate-limit +counters all write at the same time, so the single writer is the wrong shape +for this workload. + +PostgreSQL gives three things the server needs: + +- MVCC. Concurrent writers do not block each other. +- Real timestamp types. `created_at` and `expires_at` are `TEXT` today. +- `LISTEN`/`NOTIFY`. This maps onto multiplayer room pub/sub, and lets the + server run as more than one instance. + +Keep `mitmux` on SQLite. It is a single-user local tool, and its pure-Go +SQLite driver is what keeps cross-compilation free of CGO. + +## Scope + +Server only (`crates/server`). The TUI, WASM, and Steam builds do not touch +the database. + +## What makes this cheap + +- The migrations use no SQLite-only syntax. There is no `AUTOINCREMENT`, no + `PRAGMA`, no `strftime`, and no `WITHOUT ROWID`. +- Primary keys are `TEXT` UUIDs, not integer rowids. +- All 33 call sites use the runtime `sqlx::query` function, not the + `sqlx::query!` macro. There is no compile-time schema metadata, so no + `cargo sqlx prepare` step and no offline query cache to regenerate. + +## Steps + +1. Change the sqlx feature in `crates/server/Cargo.toml` from `sqlite` to + `postgres`. Keep `runtime-tokio-rustls`, `time`, and `uuid`. +2. Change the pool type from `SqlitePool` to `PgPool` in `state.rs`, and + change the connection string handling in `main.rs`. +3. Convert every placeholder from `?` to `$1`, `$2`, and so on. Postgres + numbers its placeholders. This is the largest mechanical change: 33 + queries across `auth.rs`, `stats.rs`, `friends.rs`, `multiplayer.rs`, + `cosmetics.rs`, `anticheat.rs`, `rate_limit.rs`, and `spotify.rs`. +4. Change `created_at TEXT` and `expires_at TEXT` to `TIMESTAMPTZ` in the + migrations. Update the Rust structs to `time::OffsetDateTime`. +5. Replace any `INSERT OR REPLACE` or `INSERT OR IGNORE` with + `INSERT ... ON CONFLICT`. Check `cosmetics.rs` and `rate_limit.rs` first. +6. Add a `docker-compose.yml` with a `postgres` service, so a contributor can + start a database with one command. +7. Run the full test suite against the new backend. + +## Verify + +- `cargo test --workspace` passes. +- Two clients can finish a multiplayer race at the same time, and both + results are written. +- A session expires at the correct time. This confirms the `TIMESTAMPTZ` + conversion. +- Rate limiting still rejects a burst from one user. + +## Follow-up + +Move multiplayer room pub/sub from in-process state to `LISTEN`/`NOTIFY`. +Only after that can the server run more than one instance. diff --git a/checklist.md b/checklist.md new file mode 100644 index 0000000..1744135 --- /dev/null +++ b/checklist.md @@ -0,0 +1,272 @@ +# TyperPunk Project Checklist + +## Directory Structure +``` +typerpunk/ +├── Cargo.toml # Main workspace configuration +├── Cargo.lock # Dependency lock file +├── install.sh # Installation script +├── README.md # Project documentation +├── LICENSE # License file +├── .gitignore # Git ignore rules +├── .gitattributes # Git attributes +├── texts.txt # Sample typing texts +├── sentences.txt # Sample sentences +├── config.json # Configuration file +├── crates/ # Crate modules +│ ├── core/ # Core functionality +│ │ ├── Cargo.toml # Core crate configuration +│ │ └── src/ # Core source code +│ │ ├── app.rs # Application state management +│ │ ├── config.rs # Configuration management +│ │ ├── input.rs # Input handling +│ │ ├── stats.rs # Statistics tracking +│ │ ├── text.rs # Text management +│ │ ├── theme.rs # Theme management +│ │ └── ui.rs # User interface +│ └── tui/ # Terminal UI +│ ├── Cargo.toml # TUI crate configuration +│ └── src/ # TUI source code +│ └── main.rs # TUI entry point +├── target/ # Build output directory +├── web/ # web interface for typerpunk.com +└── original_working_tui_code/ # Reference implementation +``` + +## Project Status + +### Core Features +- [x] Basic typing test functionality +- [x] WPM calculation +- [x] Accuracy tracking +- [x] Time tracking +- [x] Error highlighting +- [x] Multiple text support +- [x] Theme system +- [x] Configuration management + +### Game Modes +- [x] Normal mode (singe player) +- [ ] Programming mode +- [ ] Security mode +- [ ] Multiplayer mode + +### tui +- [x] Normal mode +- [x] stats +- [] +- [] +- [] + +### web +- [] normal mode +- [ ] Programming mode +- [ ] Security mode +- [ ] Multiplayer mode +- [] stats +- [ ] +- [ ] + +### UI/UX +- [x] Terminal UI +- [ ] Web interface +- [x] Theme customization +- [x] Responsive design +- [x] Keyboard shortcuts +- [x] Progress indicators +- [x] Statistics display + +### Multiplayer +- [ ] Real-time competition +- [ ] Leaderboards +- [ ] User profiles +- [ ] Matchmaking +- [ ] Chat system + +### Configuration +- [x] User preferences +- [x] Theme settings +- [ ] Game mode settings +- [ ] Multiplayer settings +- [ ] Key bindings + +### Build & Deployment +- [x] Cross-platform support +- [ ] WebAssembly build +- [x] Installation script +- [ ] CI/CD pipeline +- [x] Documentation + +### Testing +- [ ] Unit tests +- [ ] Integration tests +- [ ] Performance tests +- [ ] UI tests +- [ ] Multiplayer tests + +## Common Core Features +- [x] Basic typing mechanics +- [x] Text loading and management +- [x] Statistics tracking + - [x] WPM calculation + - [x] Accuracy tracking + - [x] Error counting +- [x] Theme management +- [x] Configuration system +- [x] Basic game modes + - [x] Normal mode + + +## TUI Features (Keep Simple) +- [x] Terminal-based interface +- [x] Real-time typing feedback +- [x] Color-coded character matching +- [x] Basic statistics display + - [x] Current WPM + - [x] Time elapsed + - [x] Accuracy +- [x] Simple menu system +- [x] Local configuration +- [x] Original UI layout +- [x] Stats updating without input + +## Future Web Features (Separate Development) +- [ ] Web interface +- [ ] WebSocket-based multiplayer +- [ ] Real-time opponent tracking +- [ ] Web-based configuration +- [ ] Custom themes +- [ ] Advanced game modes + - [ ] Programming mode + - [ ] Security mode + - [ ] Custom mode + - [ ] Multiplayer mode +- [ ] Quote mode with different lengths +- [ ] Topic-based challenges +- [ ] Difficulty levels +- [ ] Guest mode support + +## Dependencies + +### Common Dependencies (Current) +- [x] serde = "1.0" +- [x] serde_json = "1.0" +- [x] anyhow = "1.0" +- [x] thiserror = "1.0" +- [x] config = "0.13" +- [x] dirs = "5.0" +- [x] rlua = "0.19" +- [x] rand = "0.8" + +### TUI Dependencies (Current) +- [x] crossterm = "0.27" +- [x] ratatui = "0.24" + +### Web Dependencies (Future) +- [ ] wasm-bindgen = "0.2" +- [ ] web-sys = "0.3" +- [ ] js-sys = "0.3" +- [ ] tokio-tungstenite = "0.20" +- [ ] futures-util = "0.3" + +## Build Instructions + +### Current +- [x] Install Rust +- [x] Build TUI version +- [x] Basic install script + +### Future +- [ ] Web build setup +- [ ] Development server +- [ ] Production deployment +- [ ] Cross-platform installers + +# TyperPunk Fix Checklist + +## Text Display and Cursor Issues +- [x] Fix cursor making characters invisible/overwriting them +- [x] Fix cursor not following properly to next lines +- [x] Fix text wrapping problems causing words to shift up unexpectedly +- [x] Fix space handling logic for incorrect inputs + +## End Screen Problems +- [x] Remove unwanted green backgrounds from stats +- [x] Add hover functionality to graph to show WPM/raw values +- [x] Fix stats and content fitting on one screen +- [x] Add TyperPunk logo to top left +- [x] Fix time not restarting when clicking "Play Again" + +## Layout and Styling Issues +- [ ] Remove unnecessary border from theme toggle button +- [ ] Fix time stat sometimes being out of view +- [ ] Remove unwanted scrollbar +- [ ] Improve mobile responsiveness +- [x] Move WPM and ACC stats to extreme sides in game state +- [x] Fix end screen stats layout: + - [x] Keep WPM in same position as game state + - [x] Replace ACC with errors in same position + - [x] Center graph between stats + - [x] Keep time in same position +- [x] Fix raw WPM calculation to show peak typing speed +- [x] Fix text display issues: + - [x] Prevent word breaking across lines + - [x] Fix left alignment + - [x] Center text container properly in x-axis + +## Implementation Notes + +### Text Display and Cursor Issues +- Cursor visibility is controlled by CSS in `.text-display span.current::after` +- Text wrapping is handled in `.text-display` class with `white-space: pre-wrap` +- Space handling logic is in `TypingGame.tsx` renderText function + +### End Screen Improvements +- Stats styling is in `.end-screen-stat` classes +- Graph hover functionality needs to be added to `.graph-container` +- Layout is controlled by `.end-screen` and `.end-screen-stats` classes +- Logo placement is in `.logo` class + +### Layout and Styling +- Theme toggle styling is in `.theme-toggle` class +- Time stat positioning is in `.time-stat` class +- Scrollbar and mobile responsiveness are in media queries + +### Stats Layout +- WPM and ACC stats should be at extreme edges of the screen +- End screen should maintain same positions as game state +- Graph should be centered between stats + +### Raw WPM Calculation +- Should show peak typing speed +- Graph should show speed variations over time +- Current implementation incorrectly uses total chars instead of peak speed + +### Text Display +- Words should not break across lines +- Text container needs proper x-axis centering +- Left alignment needs adjustment + +## Progress Tracking +- [x] Phase 1: Text Display and Cursor Issues +- [ ] Phase 2: End Screen Problems +- [ ] Phase 3: Layout and Styling Issues + +## UI/Gameplay Improvements +- [ ] Prevent backspace on correct characters (except for ctrl+backspace for word deletion) +- [ ] Fix/prevent reset accuracy to 100% when correcting misspellings +- [ ] Other theme toggle button in top right corner +- [ ] Reposition text/typing paragraph to north position +- [ ] Add empty space below typing area for future game modes +- [ ] In endscreen: + - [ ] Replace empty space with graph + - [ ] Keep text visible at top + - [ ] Position WPM on left side of graph + - [ ] Position errors on right side of graph + - [ ] Position time at bottom + - [ ] Add graph axes with: + - [ ] 0 in bottom left corner + - [ ] WPM on y-axis + - [ ] Time in whole seconds on x-axis +- [ ] Fix raw WPM calculation (different from regular WPM) +- [ ] Update end screen buttons to match main menu button style (border/colors) \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..e28a596 --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "texts_path": "texts.json", + "default_text": "The quick brown fox jumps over the lazy dog.", + "theme": { + "name": "default", + "background": "Black", + "foreground": "White", + "accent": "Blue", + "error": "Red", + "success": "Green" + } +} \ No newline at end of file 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, + pub categories: Vec, + pub selected_category: Option, // 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, + pub stats: Stats, + pub input: String, + pub current_text_index: usize, + pub should_exit: bool, + pub state: State, + pub wpm_history: Vec, + + pub custom_mode_selected: bool, + pub custom_text: Option, + pub custom_index: usize, + pub using_custom: bool, + pub current_custom: Option, + pub path_input: String, + pub path_error: Option, + + pub passive_active_index: usize, + pub passive_typed: String, + pub passive_correct: u32, + pub passive_total: u32, + pub passive_start: Option, + pub passive_schedule: Vec, + 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, + pub logged_in_username: Option, + 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, + pub pending_network_action: Option, + pub leaderboard_mode: String, + pub leaderboard_entries: Vec, + pub friends_list: Vec, + pub friends_incoming: Vec, + pub friends_outgoing: Vec, + 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, + pub mp_players: Vec, + pub mp_local_player_id: Option, + pub mp_countdown: Option, + pub mp_race_text: Option, + pub mp_results: Vec<(String, String, f32, u32)>, // (player_id, name, wpm, place) + pub mp_status: Option, + pub mp_finish_sent: bool, +} + +impl App { + pub fn new() -> Result> { + let config = Config::new(); + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + // texts.json is stored at repository root; this file is at crates/core/src/app.rs + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + // 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::>() + }; + 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) { + 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) { + self.net_busy = false; + self.net_status = None; + self.leaderboard_entries = entries; + } + + pub fn set_friends(&mut self, list: Vec, incoming: Vec, outgoing: Vec) { + 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) { + self.mp_players = players; + } + + pub fn set_mp_countdown(&mut self, seconds: u32) { + self.mp_countdown = Some(seconds); + } + + /// The server hands every racer the identical passage (see + /// crates/server's room lifecycle) - this is what makes current_text() + /// pick it up for the race the same way TypingGame reads its own pool. + pub fn set_mp_start(&mut self, text: String) { + self.mp_countdown = None; + self.mp_race_text = Some(Text { content: text, source: "Multiplayer".to_string(), language: String::new(), category: String::new() }); + self.mp_finish_sent = false; + self.input.clear(); + self.stats = Stats::new(); + self.stats.start(); + self.state = State::MultiplayerRace; + } + + pub fn set_mp_player_progress(&mut self, player_id: &str, percent: f32, wpm: f32) { + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = percent; + p.wpm = wpm; + } + } + + pub fn set_mp_player_finished(&mut self, player_id: &str, name_fallback: &str, wpm: f32, place: u32) { + let name = self.mp_players.iter().find(|p| p.id == player_id).map(|p| p.name.clone()).unwrap_or_else(|| name_fallback.to_string()); + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = 100.0; + p.wpm = wpm; + } + // Deduped by player id, not name - two unauthenticated players both + // default to "Guest", and keying on name silently dropped the + // second Guest's own finish as an apparent duplicate of the first's. + if !self.mp_results.iter().any(|(id, _, _, _)| id == player_id) { + self.mp_results.push((player_id.to_string(), name, wpm, place)); + self.mp_results.sort_by_key(|(_, _, _, place)| *place); + } + // Only the local player's own finish should leave the race screen -- + // the room may keep racing after we're done, and other players' + // PlayerFinished messages arrive well before that happens. + if self.mp_local_player_id.as_deref() == Some(player_id) { + self.state = State::MultiplayerResults; + } + } + + pub fn set_mp_room_closed(&mut self, reason: String) { + self.mp_status = Some(format!("Room closed: {reason}")); + self.state = State::MainMenu; + } + + pub fn reset_multiplayer(&mut self) { + self.mp_room_code_input.clear(); + self.mp_own_room_code = None; + self.mp_players.clear(); + self.mp_local_player_id = None; + self.mp_countdown = None; + self.mp_race_text = None; + self.mp_results.clear(); + self.mp_status = None; + self.mp_finish_sent = false; + self.net_busy = false; + } + + fn handle_backspace_with_rules(&mut self, ctrl: bool) { + if self.input.is_empty() { return; } + let current_text = self.current_text().content.clone(); + let current_text = ¤t_text; + if ctrl { + // Standard word-backward delete: skip any trailing whitespace + // first (e.g. cursor sitting right after a space you just typed), + // then skip back over the word before it. + let chars: Vec = 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> { + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + // 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::>() + }; + 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 = 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 = chunk.content.chars().collect(); + let typed_chars: Vec = self.passive_typed.chars().collect(); + for (i, tc) in typed_chars.iter().enumerate() { + self.passive_total += 1; + if target_chars.get(i) == Some(tc) { + self.passive_correct += 1; + } + } + } + } + self.passive_active_index = target; + self.passive_typed.clear(); + } + + fn update_passive(&mut self) { + if self.passive_done { + return; + } + let Some(start) = self.passive_start else { return }; + let elapsed = start.elapsed().as_secs_f64(); + + let mut target = self.passive_active_index; + while target + 1 < self.passive_schedule.len() && elapsed >= self.passive_schedule[target + 1] { + target += 1; + } + if target != self.passive_active_index { + self.advance_passive_to(target); + } + + let total_duration = self.passive_schedule.last().copied().unwrap_or(0.0) + 4.0; + if elapsed >= total_duration { + self.passive_done = true; + } + } + + pub fn passive_accuracy(&self) -> f64 { + if self.passive_total == 0 { + 100.0 + } else { + (self.passive_correct as f64 / self.passive_total as f64) * 100.0 + } + } + + pub fn handle_input(&mut self, key: KeyEvent) { + match self.state { + State::MainMenu => { + match key.code { + crossterm::event::KeyCode::Enter => { + if self.custom_mode_selected { + if let Some(ct) = &self.custom_text { + if !ct.chunks.is_empty() { + self.using_custom = true; + self.custom_index = 0; + self.state = State::TypingGame; + self.start_custom_chunk(); + } + } else { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Char('p') | crossterm::event::KeyCode::Char('P') + if self.custom_mode_selected => + { + if let Some(ct) = &self.custom_text { + if ct.timed && !ct.chunks.is_empty() { + self.start_passive(); + } + } + } + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') => { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + crossterm::event::KeyCode::Char('x') | crossterm::event::KeyCode::Char('X') + if self.custom_mode_selected => + { + self.custom_text = None; + self.custom_mode_selected = false; + } + crossterm::event::KeyCode::Char('l') | crossterm::event::KeyCode::Char('L') => { + self.net_status = None; + self.state = State::Leaderboard; + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + crossterm::event::KeyCode::Char('a') | crossterm::event::KeyCode::Char('A') => { + self.net_status = None; + self.state = State::Login; + } + crossterm::event::KeyCode::Char('f') | crossterm::event::KeyCode::Char('F') => { + self.net_status = None; + self.state = State::Friends; + if self.logged_in_username.is_some() { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchFriends); + } + } + crossterm::event::KeyCode::Char('m') | crossterm::event::KeyCode::Char('M') => { + self.reset_multiplayer(); + self.state = State::MultiplayerLobby; + } + crossterm::event::KeyCode::Left => self.cycle_mode(false), + crossterm::event::KeyCode::Right => self.cycle_mode(true), + crossterm::event::KeyCode::Up if self.words_mode_selected => { + self.cycle_word_count(true) + } + crossterm::event::KeyCode::Down if self.words_mode_selected => { + self.cycle_word_count(false) + } + crossterm::event::KeyCode::Up if self.time_mode_selected => { + self.cycle_time_duration(true) + } + crossterm::event::KeyCode::Down if self.time_mode_selected => { + self.cycle_time_duration(false) + } + crossterm::event::KeyCode::Esc => { + self.should_exit = true; + } + _ => {} + } + } + State::CustomTextPrompt => match key.code { + crossterm::event::KeyCode::Enter => self.load_custom_text(), + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.path_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + self.path_input.push(c); + } + _ => {} + }, + State::TypingGame => { + match key.code { + crossterm::event::KeyCode::Char(c) => { + // Handle control-word delete (Ctrl+W, or Ctrl+H on terminals + // that send it in place of Ctrl+Backspace). + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && (c == 'w' || c == 'W' || c == 'h' || c == 'H') + { + self.handle_backspace_with_rules(true); + return; + } + // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok) + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + // Record keystroke correctness before mutating input + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + if pos < current_text.len() { + // Compare with target at this position + current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + } else { + false // extra chars are considered incorrect + } + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms + let ctrl_or_alt = key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, + ); + self.handle_backspace_with_rules(ctrl_or_alt); + } + crossterm::event::KeyCode::Tab => { + // Quick restart: abandon the current attempt and start + // a fresh one of the same mode, without leaving the + // typing screen. + self.reset(); + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::PassiveMode => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Enter if self.passive_done => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.passive_typed.pop(); + } + crossterm::event::KeyCode::Char(c) if !self.passive_done => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.passive_typed.push(c); + } + } + _ => {} + }, + State::EndScreen => { + match key.code { + // Tab is the keyboard-shortcut equivalent of Enter here -- + // both play again / advance to the next custom segment. + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Tab => { + if self.using_custom { + if self.advance_custom_index() { + self.state = State::TypingGame; + self.start_custom_chunk(); + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + } + } else { + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::Login => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Tab => { + self.login_field = match self.login_field { + LoginField::Username => LoginField::Password, + LoginField::Password => LoginField::Username, + }; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + self.login_register_mode = !self.login_register_mode; + } + crossterm::event::KeyCode::Enter => { + if self.logged_in_username.is_some() { + // Already signed in - Enter here just backs out, + // there's no form to submit. + self.state = State::MainMenu; + return; + } + if self.login_username.is_empty() || self.login_password.is_empty() { + self.net_status = Some("Enter a username and password.".to_string()); + return; + } + self.net_busy = true; + self.net_status = None; + let username = self.login_username.clone(); + let password = self.login_password.clone(); + self.pending_network_action = Some(if self.login_register_mode { + NetworkAction::Register { username, password } + } else { + NetworkAction::Login { username, password } + }); + } + crossterm::event::KeyCode::Backspace => { + match self.login_field { + LoginField::Username => { self.login_username.pop(); } + LoginField::Password => { self.login_password.pop(); } + } + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + match self.login_field { + LoginField::Username => self.login_username.push(c), + LoginField::Password => self.login_password.push(c), + } + } + } + _ => {} + }, + State::Leaderboard => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') => { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + _ => {} + }, + State::Friends => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Up => { + if self.friends_selected > 0 { self.friends_selected -= 1; } + } + crossterm::event::KeyCode::Down => { + if self.friends_selected + 1 < self.friends_incoming.len() { + self.friends_selected += 1; + } + } + // Guarded on the add-username field being empty - otherwise + // typing a 'y' or 'd' into a username (both letters are legal + // in one, per validate_username) would get eaten as an + // accept/decline instead of landing in the text field. + crossterm::event::KeyCode::Char('y') | crossterm::event::KeyCode::Char('Y') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::AcceptFriendRequest { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Char('d') | crossterm::event::KeyCode::Char('D') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::RemoveFriendship { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Enter => { + if !self.friends_add_input.is_empty() { + self.net_busy = true; + self.net_status = None; + let username = self.friends_add_input.clone(); + self.friends_add_input.clear(); + self.pending_network_action = Some(NetworkAction::SendFriendRequest { username }); + } + } + crossterm::event::KeyCode::Backspace => { + self.friends_add_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.friends_add_input.push(c); + } + } + _ => {} + }, + State::MultiplayerLobby => match key.code { + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + // Only before a room exists - once connected (own room + // created, or joined and appearing in the player list), + // 'c'/'r' below take over instead of these editing the + // now-irrelevant join-code field. + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') + if self.mp_own_room_code.is_none() && self.mp_local_player_id.is_none() => + { + self.net_busy = true; + self.mp_status = None; + self.pending_network_action = Some(NetworkAction::CreateMultiplayerRoom); + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if self.mp_local_player_id.is_some() => + { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::MultiplayerReady); + } + crossterm::event::KeyCode::Enter + if self.mp_local_player_id.is_none() && !self.mp_room_code_input.trim().is_empty() => + { + self.net_busy = true; + self.mp_status = None; + let code = self.mp_room_code_input.trim().to_uppercase(); + self.pending_network_action = Some(NetworkAction::JoinMultiplayerRoom { code }); + } + crossterm::event::KeyCode::Backspace if self.mp_local_player_id.is_none() => { + self.mp_room_code_input.pop(); + } + crossterm::event::KeyCode::Char(c) if self.mp_local_player_id.is_none() => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.mp_room_code_input.push(c); + } + } + _ => {} + }, + State::MultiplayerRace => match key.code { + crossterm::event::KeyCode::Char(c) => { + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + pos < current_text.len() && current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + if !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + State::MultiplayerResults => match key.code { + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + } + + // Check if the current text is finished + if self.state == State::TypingGame && self.is_finished() { + self.state = State::EndScreen; + self.stats.stop(); + } + // Multiplayer doesn't jump to a results screen the instant typing + // finishes - the tui binary's main loop notices is_finished() here, + // sends Finish over the connection, and set_mp_player_finished (see + // above) is what actually transitions to MultiplayerResults, once + // the server confirms it rather than the client assuming it. + if self.state == State::MultiplayerRace && self.is_finished() && self.stats.is_running() { + self.stats.stop(); + } + } + + pub fn update_stats(&mut self) { + if self.state == State::TypingGame || self.state == State::MultiplayerRace { + let current_text = self.current_text().content.clone(); + self.stats.update(&self.input, ¤t_text); + } + } + + // Length-only, matching the web engine's game.rs - an uncorrected + // mistake anywhere in the input must still let the test end once you've + // typed as many characters as the text has. An exact-match requirement + // meant one uncorrected typo anywhere (not just the last character) made + // the test unfinishable, since nothing here caps input at the text's + // length the way the web frontend does. + pub fn is_finished(&self) -> bool { + !self.input.is_empty() && self.input.len() >= self.current_text().content.len() + } + + pub fn current_text(&self) -> &Text { + self.mp_race_text.as_ref() + .or(self.current_custom.as_ref()) + .or(self.current_words.as_ref()) + .unwrap_or(&self.texts[self.current_text_index]) + } + + pub fn get_input(&self) -> &str { + self.input.as_str() + } + + pub fn handle_backspace(&mut self) { + if self.state == State::TypingGame && !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + + pub fn handle_enter(&mut self) { + match self.state { + State::MainMenu => { + self.state = State::TypingGame; + self.reset(); + } + State::EndScreen => { + self.state = State::TypingGame; + self.reset(); + } + _ => {} + } + } + + pub fn handle_escape(&mut self) { + match self.state { + State::TypingGame => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::EndScreen => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::CustomTextPrompt | State::PassiveMode | State::Login | State::Leaderboard | State::Friends => { + self.state = State::MainMenu; + } + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + State::MainMenu => { + self.should_exit = true; + } + } + } + + pub fn get_progress(&self) -> f64 { + if self.input.is_empty() { + 0.0 + } else { + let total_chars = self.current_text().content.chars().count(); + let current_chars = self.input.chars().count(); + (current_chars as f64 / total_chars as f64) * 100.0 + } + } + + pub fn update(&mut self) { + if self.state == State::TypingGame { + self.update_stats(); + // Time mode ends on the clock rather than at the end of the + // text, so it needs its own check here - is_finished() only + // fires from a keystroke, and the clock keeps running even if + // the typist pauses. + if self.time_mode_selected + && self.stats.is_running() + && self.stats.elapsed_time().as_secs_f64() >= self.time_duration as f64 + { + self.state = State::EndScreen; + self.stats.stop(); + } + // Sample WPM once per elapsed second to build a compact sparkline + let secs = self.stats.elapsed_time().as_secs() as usize; + while self.wpm_history.len() < secs { + self.wpm_history.push(self.stats.wpm().round() as u64); + } + } else if self.state == State::PassiveMode { + self.update_passive(); + } else if self.state == State::MultiplayerRace { + self.update_stats(); + } + } +} + +const PASSIVE_PACE_WPM: f64 = 130.0; +const PASSIVE_LINE_GAP_SECONDS: f64 = 0.6; + +fn build_passive_schedule(chunks: &[crate::custom_text::CustomChunk]) -> Vec { + 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, +} + +#[derive(Debug, Clone)] +pub struct CustomText { + pub name: String, + pub chunks: Vec, + pub language: Option, + 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> { + let chars: Vec = 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 = 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 { + if s.chars().count() <= max_len { + return vec![s.to_string()]; + } + let chars: Vec = 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::()); + start += cut; + } + parts +} + +pub fn chunk_plain_text(raw: &str) -> Vec { + 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 { + let normalized = s.replace(',', "."); + let parts: Vec<&str> = normalized.split(':').collect(); + let nums: Result, _> = parts.iter().map(|p| p.parse::()).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 { + 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 { + 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 { + 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, + pub word_count: Option, + pub custom_text: Option, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::Normal, + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 50, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option; + fn get_word_count(&self) -> Option; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option { + self.time_limit + } + + fn get_word_count(&self) -> Option { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Game { + text: String, + input: String, + #[serde(skip)] + start_time: Option, + is_started: bool, + is_finished: bool, + error_positions: Vec, + current_streak: u32, + best_streak: u32, + theme: Theme, + correct_positions: Vec, + pub total_mistakes: u32, + total_errors_made: u32, + last_input_length: usize, + total_characters_typed: u32, + total_correct_characters: u32, +} + +impl Game { + pub fn new() -> Self { + Self { + text: String::new(), + input: String::new(), + start_time: None, + is_started: false, + is_finished: false, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + theme: Theme::default(), + correct_positions: Vec::new(), + total_mistakes: 0, + total_errors_made: 0, + last_input_length: 0, + total_characters_typed: 0, + total_correct_characters: 0, + } + } + + pub fn set_text(&mut self, text: String) { + self.text = text; + self.reset(); + } + + pub fn get_text(&self) -> String { + self.text.clone() + } + + pub fn get_input(&self) -> String { + self.input.clone() + } + + pub fn start(&mut self) { + self.is_started = true; + self.start_time = Some(Instant::now()); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + if self.is_finished() { + return Ok(()); + } + + // Validate UTF-8 only + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err("Invalid UTF-8 input".to_string()), + }; + + // Update input + self.input = input_str; + + // Update game state + self.update_game_state(); + Ok(()) + } + + pub fn is_finished(&self) -> bool { + self.is_finished + } + + pub fn get_error_positions(&self) -> Vec { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> u32 { + self.current_streak + } + + pub fn get_best_streak(&self) -> u32 { + self.best_streak + } + + pub fn get_theme(&self) -> Theme { + self.theme + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + pub fn get_wpm(&self) -> f64 { + if let Some(start_time) = self.start_time { + let elapsed = start_time.elapsed().as_secs_f64(); + if elapsed > 0.0 { + let words = self.input.len() as f64 / 5.0; + return (words * 60.0) / elapsed; + } + } + 0.0 + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + let accuracy = self.get_accuracy(); + let mistakes = self.get_total_mistakes(); + Ok((accuracy, mistakes)) + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + let (accuracy, mistakes) = self.get_stats()?; + Ok((self.input.clone(), accuracy, mistakes)) + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_characters_typed == 0 { + return 100.0; + } + + let accuracy = (self.total_correct_characters as f64 / self.total_characters_typed as f64) * 100.0; + accuracy.max(0.0).min(100.0) + } + + pub fn get_total_mistakes(&self) -> u32 { + // Return total errors made, not current mistakes + self.total_errors_made + } + + pub fn get_time_elapsed(&self) -> f64 { + if let Some(start_time) = self.start_time { + start_time.elapsed().as_secs_f64() + } else { + 0.0 + } + } + + fn reset(&mut self) { + self.input.clear(); + self.start_time = None; + self.is_started = false; + self.is_finished = false; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.correct_positions = vec![false; self.text.len()]; + self.total_mistakes = 0; + self.total_errors_made = 0; + self.last_input_length = 0; + self.total_characters_typed = 0; + self.total_correct_characters = 0; + } + + pub fn can_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn can_ctrl_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result { + if !self.can_backspace() { + return Ok(false); + } + + let mut new_input = self.input.clone(); + + if ctrl { + // 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 = 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 = self.input.chars().collect(); + let text_chars: Vec = 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, + 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 }, + 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, + end_time: Option, + error_positions: Vec, + current_streak: usize, + best_streak: usize, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, + // 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, + 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 { + 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::::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::::new()); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + } + + #[test] + fn test_stats_word_counting() { + let mut stats = Stats::new(); + stats.total_words = 2; + stats.correct_words = 2; + + assert_eq!(stats.total_words, 2); + assert_eq!(stats.correct_words, 2); + } +} \ No newline at end of file diff --git a/crates/core/src/tests/game_tests.rs b/crates/core/src/tests/game_tests.rs new file mode 100644 index 0000000..6bc67ed --- /dev/null +++ b/crates/core/src/tests/game_tests.rs @@ -0,0 +1,143 @@ +use crate::game::{Game, GameMode}; +use crate::text::Text; + +#[test] +fn test_game_initialization() { + let game = Game::new(GameMode::Time(60)); + assert_eq!(game.mode(), GameMode::Time(60)); + assert_eq!(game.is_finished(), false); +} + +#[test] +fn test_game_time_mode() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + // Simulate some typing + game.update("Hello"); + assert_eq!(game.is_finished(), false); + + // Simulate time running out + game.update_time(61); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_words_mode() { + let mut game = Game::new(GameMode::Words(10)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + // Simulate typing some words + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + + // Simulate completing all words + game.update("Hello world! This is a test."); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_reset() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + game.reset(); + + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), ""); +} + +#[test] +fn test_game_mode_transition() { + let mut game = Game::new(GameMode::Time(60)); + game.set_mode(GameMode::Words(10)); + assert_eq!(game.mode(), GameMode::Words(10)); +} + +#[test] +fn test_game_text_update() { + let mut game = Game::new(GameMode::Time(60)); + let text1 = Text::new("First text"); + let text2 = Text::new("Second text"); + + game.set_text(text1); + assert_eq!(game.current_text().content(), "First text"); + + game.set_text(text2); + assert_eq!(game.current_text().content(), "Second text"); +} + +#[test] +fn test_game_partial_completion() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Hello world!"); +} + +#[test] +fn test_game_error_handling() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Helo world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Helo world!"); +} + +#[test] +fn test_game_time_remaining() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update_time(30); + assert_eq!(game.time_remaining(), 30); +} + +#[test] +fn test_game_words_remaining() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.words_remaining(), 3); +} + +#[test] +fn test_game_progress() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.progress(), 0.4); // 2 out of 5 words completed +} + +#[test] +fn test_game_state_persistence() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + let input = game.current_input(); + let is_finished = game.is_finished(); + + game.reset(); + assert_eq!(game.current_input(), ""); + assert_eq!(game.is_finished(), false); + + game.update(input); + assert_eq!(game.current_input(), input); + assert_eq!(game.is_finished(), is_finished); +} \ No newline at end of file diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs new file mode 100644 index 0000000..f39330c --- /dev/null +++ b/crates/core/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod stats_tests; +mod text_tests; +mod game_tests; \ No newline at end of file diff --git a/crates/core/src/tests/stats_tests.rs b/crates/core/src/tests/stats_tests.rs new file mode 100644 index 0000000..1b210c8 --- /dev/null +++ b/crates/core/src/tests/stats_tests.rs @@ -0,0 +1,125 @@ +use crate::stats::Stats; +use std::time::Duration; + +#[test] +fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_start_stop() { + let mut stats = Stats::new(); + assert!(!stats.is_running()); + + stats.start(); + assert!(stats.is_running()); + + stats.stop(); + assert!(!stats.is_running()); +} + +#[test] +fn test_stats_update() { + let mut stats = Stats::new(); + stats.start(); + + // Test perfect typing + stats.update("hello", "hello"); + assert_eq!(stats.correct_chars, 5); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_chars, 5); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + // Test with errors + stats.update("helo", "hello"); + assert_eq!(stats.correct_chars, 3); + assert_eq!(stats.incorrect_chars, 1); + assert_eq!(stats.total_chars, 4); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_reset() { + let mut stats = Stats::new(); + stats.start(); + stats.update("hello", "hello"); + stats.reset(); + + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_wpm_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 60 characters (12 words) in 1 minute + stats.update("hello world hello world hello world", "hello world hello world hello world"); + assert_eq!(stats.wpm, 12.0); +} + +#[test] +fn test_stats_accuracy_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 10 characters with 2 errors + stats.update("hello wrld", "hello world"); + assert_eq!(stats.accuracy, 80.0); +} + +#[test] +fn test_stats_streak_tracking() { + let mut stats = Stats::new(); + stats.start(); + + // Test streak building and breaking + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + stats.update("helo", "hello"); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); + + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_error_positions() { + let mut stats = Stats::new(); + stats.start(); + + stats.update("helo", "hello"); + assert_eq!(stats.error_positions, vec![3]); + + stats.update("hllo", "hello"); + assert_eq!(stats.error_positions, vec![1]); +} \ No newline at end of file diff --git a/crates/core/src/tests/text_tests.rs b/crates/core/src/tests/text_tests.rs new file mode 100644 index 0000000..2964e2a --- /dev/null +++ b/crates/core/src/tests/text_tests.rs @@ -0,0 +1,83 @@ +use crate::text::Text; + +#[test] +fn test_text_creation() { + let content = "Hello, world!"; + let text = Text::new(content); + assert_eq!(text.content(), content); + assert_eq!(text.words().len(), 2); +} + +#[test] +fn test_text_word_count() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_empty() { + let text = Text::new(""); + assert_eq!(text.content(), ""); + assert_eq!(text.words().len(), 0); +} + +#[test] +fn test_text_with_special_chars() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); + assert_eq!(text.content(), "Hello, world! This is a test..."); +} + +#[test] +fn test_text_word_boundaries() { + let text = Text::new("Hello-world! This_is_a_test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_multiple_spaces() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_numbers() { + let text = Text::new("Hello 123 world! 456 test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_with_punctuation() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_mixed_case() { + let text = Text::new("Hello WORLD! This IS a TEST."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_unicode() { + let text = Text::new("Hello 世界! This is a テスト."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_emojis() { + let text = Text::new("Hello 👋 world! This is a test 🎯."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_tabs() { + let text = Text::new("Hello\tworld!\tThis\tis\ta\ttest."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_newlines() { + let text = Text::new("Hello\nworld!\nThis\nis\na\ntest."); + assert_eq!(text.words().len(), 6); +} \ No newline at end of file diff --git a/crates/core/src/text.rs b/crates/core/src/text.rs new file mode 100644 index 0000000..0e0e248 --- /dev/null +++ b/crates/core/src/text.rs @@ -0,0 +1,72 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub struct Text { + pub content: String, + pub source: String, + pub language: String, + pub category: String, +} + +impl Text { + pub fn new() -> Self { + Self { + content: String::new(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str(content: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_source(content: &str, source: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_language(content: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_str_with_source_and_language(content: &str, source: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_all(content: &str, source: &str, language: &str, category: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: category.to_string(), + } + } +} + +impl fmt::Display for Text { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.content) + } +} + \ No newline at end of file diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..8ebad89 --- /dev/null +++ b/crates/core/src/theme.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "tui")] +use ratatui::style::Color; + +#[cfg(feature = "tui")] +pub struct Theme { + pub background: Color, + pub foreground: Color, + pub accent: Color, + pub error: Color, + pub success: Color, +} + +#[cfg(feature = "tui")] +impl Default for Theme { + fn default() -> Self { + Self { + background: Color::Black, + foreground: Color::White, + accent: Color::Cyan, + error: Color::Red, + success: Color::Green, + } + } +} \ No newline at end of file diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs new file mode 100644 index 0000000..dc57c9c --- /dev/null +++ b/crates/core/src/types.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option, + pub word_count: Option, + pub custom_text: Option, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::default(), + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 1, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option; + fn get_word_count(&self) -> Option; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option { + self.time_limit + } + + fn get_word_count(&self) -> Option { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Dark + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Theme::Light => write!(f, "light"), + Theme::Dark => write!(f, "dark"), + } + } +} \ No newline at end of file diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs new file mode 100644 index 0000000..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 = 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 = 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 = 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 = 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 = 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> { + let current_text = app.current_text(); + let text_chars: Vec = current_text.content.chars().collect(); + let input_chars: Vec = app.input.chars().collect(); + let language = if current_text.language.is_empty() { None } else { Some(current_text.language.as_str()) }; + let syntax = highlight_classes(¤t_text.content, language); + let mut colored_text: Vec = 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> { + let target_chars: Vec = target.chars().collect(); + let typed_chars: Vec = 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 = 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 = 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 = 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 { + self.game.get_error_positions() + } + + pub fn get_current_streak(&self) -> u32 { + self.game.get_current_streak() + } + + pub fn get_best_streak(&self) -> u32 { + self.game.get_best_streak() + } + + pub fn get_theme(&self) -> String { + self.game.get_theme().to_string() + } + + pub fn set_theme(&mut self, theme: String) { + let theme = match theme.as_str() { + "light" => Theme::Light, + _ => Theme::Dark, + }; + self.game.set_theme(theme); + } + + pub fn get_wpm(&self) -> f64 { + self.game.get_wpm() + } + + pub fn get_accuracy(&self) -> f64 { + self.game.get_accuracy() + } + + pub fn get_time_elapsed(&self) -> f64 { + self.game.get_time_elapsed() + } + + pub fn can_backspace(&self) -> bool { + self.game.can_backspace() + } + + pub fn can_ctrl_backspace(&self) -> bool { + self.game.can_ctrl_backspace() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result { + self.game.handle_backspace(ctrl) + } + + pub fn get_total_mistakes(&self) -> u32 { + self.game.get_total_mistakes() + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + self.game.get_stats() + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + self.game.get_stats_and_input() + } +} \ No newline at end of file diff --git a/crates/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 = 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::() + 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>) -> 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::() / intervals.len() as f64; + if mean > 0.0 { + let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::() / 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 = (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 = (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> { + 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 { + 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 { + 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 { + 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>, + ConnectInfo(addr): ConnectInfo, + jar: CookieJar, + Json(creds): Json, +) -> Result { + 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 { + 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>, + ConnectInfo(addr): ConnectInfo, + jar: CookieJar, + Json(creds): Json, +) -> Result { + 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 `. +async fn issue_token( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + Json(creds): Json, +) -> Result { + 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 { + 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 ` 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 { + 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>, jar: CookieJar) -> Result { + 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>, jar: CookieJar) -> Result { + 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> { + 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>) -> Result { + 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 = 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, + equipped_caret: Option, + equipped_flair: Option, +} + +async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Result { + 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 = 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 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 correctly does. Found via a real NULL column + // round-tripping as "" instead of JSON null. + equipped_caret: equip_row.try_get::, _>("equipped_caret").unwrap_or(None), + equipped_flair: equip_row.try_get::, _>("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>, jar: CookieJar, Path(cosmetic_id): Path) -> Result { + 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>, jar: CookieJar, Path(cosmetic_id): Path) -> Result { + 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>, jar: CookieJar, Json(body): Json) -> Result { + 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 for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err) + } +} + +impl From 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> { + 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, + incoming_requests: Vec, + outgoing_requests: Vec, +} + +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>, jar: CookieJar, headers: HeaderMap) -> Result { + 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>, + jar: CookieJar, + headers: HeaderMap, + Json(body): Json, +) -> Result { + 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>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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> { + Router::new().route("/api/lyrics", get(get_lyrics)) +} + +#[derive(Debug, Deserialize)] +pub struct LyricsQuery { + pub artist: String, + pub track: String, + pub duration: Option, +} + +#[derive(Debug, Deserialize)] +struct LrclibResponse { + #[serde(rename = "syncedLyrics")] + synced_lyrics: Option, + #[serde(rename = "plainLyrics")] + plain_lyrics: Option, +} + +#[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, + /// 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, +} + +async fn get_lyrics(State(state): State>, Query(q): Query) -> Result { + 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 { + 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::>(&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) -> 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::()?) + .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::()).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::()).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, + /// 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, + status: RoomStatus, + text: Option, + finish_order: Vec, + 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>>>; + +pub fn new_registry() -> RoomRegistry { + Arc::new(dashmap::DashMap::new()) +} + +pub fn router() -> Router> { + 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, +} + +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>, body: Option>) -> 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>, body: Option>) -> 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>)> = + 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>) -> impl IntoResponse { + let rooms: Vec>> = 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, + State(state): State>, +) -> 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) { + 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::(); + + 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::(&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>, state: Arc) { + 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>, + 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>, state: Arc) { + 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 = 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>, + room_code: &str, + state: &Arc, + player_id: &str, + tx: &mpsc::UnboundedSender, + 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 { + hits: Arc>, + max_attempts: u32, + window: Duration, +} + +impl RateLimiter { + 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> { + 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>, jar: CookieJar) -> Result { + 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, + state: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: i64, +} + +async fn callback( + State(state): State>, + jar: CookieJar, + Query(q): Query, +) -> Result { + 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, 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, + artist: Option, + duration_ms: Option, + progress_ms: Option, +} + +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>, jar: CookieJar) -> Result { + 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, + /// 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, + pub spotify: SpotifyConfig, + pub frontend_origin: String, + pub http: Client, +} + +impl AppState { + pub fn new( + db: SqlitePool, + cookie_secure: bool, + race_texts: Vec, + 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> { + 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, + /// 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>, +} + +// 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>, + jar: CookieJar, + Json(body): Json, +) -> Result { + 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, +} + +// 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 { + 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>, jar: CookieJar) -> Result { + 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, + #[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>, Path(username): Path) -> Result { + // 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 = row.try_get::, _>("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, + /// "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, +} + +#[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, +} + +async fn leaderboard(State(state): State>, Query(q): Query) -> Result { + 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 = 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::, _>("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 Binary files /dev/null and b/crates/steam/assets/sprites/pulse.png 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 @@ + + + + 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 Binary files /dev/null and b/crates/steam/assets/sprites/racer_marker.png 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 @@ + + + + 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 Binary files /dev/null and b/crates/steam/assets/sprites/warden_layer.png 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 @@ + + + + + + + + + + + + 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::() + .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>, mut next: ResMut>) { + 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); + +#[derive(Resource, Default)] +struct LocalPlayerId(Option); + +// 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, + mut local_id: ResMut, + mut typing: ResMut, + mut finish_sent: ResMut, + 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, + mut finish_sent: ResMut, + typing: Res, + racers: Query<&Racer, With>, +) { + // 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), + 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>, +} + +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, name: String) -> (Self, mpsc::Sender) { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::(); + + 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 { + self.event_rx.lock().ok()?.try_recv().ok() + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result { + 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::().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(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink + 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, + name: String, + event_tx: mpsc::Sender, + update_rx: mpsc::Receiver, +) { + 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::(&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, + racers: Query<(Entity, &Racer), With>, +) { + let warden_texture: Handle = asset_server.load("sprites/warden_layer.png"); + let marker_texture: Handle = 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>) { + 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, + racers: Query<(Entity, &Racer), With>, +) { + let pulse_texture: Handle = asset_server.load("sprites/pulse.png"); + let marker_texture: Handle = 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>) { + 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>) { + 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>) { + 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>) { + 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