diff options
141 files changed, 19524 insertions, 559 deletions
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 index 96ef6c0..830459a 100644..100755 --- 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 index b2209a5..15205f9 100644..100755 --- 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 <[email protected]>"] +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/README.md b/README.md index aad3b85..991d2e5 100644..100755 --- 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 + <script>window.TYPERPUNK_TEXTS_URL = "https://your.cdn/path/to/texts.json";</script> + ``` + - 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<Text>, + pub categories: Vec<String>, + pub selected_category: Option<String>, // None = Random + pub words_mode_selected: bool, + pub word_count: usize, + pub words_punctuation: bool, + pub words_numbers: bool, + pub time_mode_selected: bool, + pub time_duration: u64, + pub current_words: Option<Text>, + pub stats: Stats, + pub input: String, + pub current_text_index: usize, + pub should_exit: bool, + pub state: State, + pub wpm_history: Vec<u64>, + + pub custom_mode_selected: bool, + pub custom_text: Option<CustomText>, + pub custom_index: usize, + pub using_custom: bool, + pub current_custom: Option<Text>, + pub path_input: String, + pub path_error: Option<String>, + + pub passive_active_index: usize, + pub passive_typed: String, + pub passive_correct: u32, + pub passive_total: u32, + pub passive_start: Option<Instant>, + pub passive_schedule: Vec<f64>, + pub passive_done: bool, + + // Online: account, leaderboard, friends - see NetworkAction above for + // how this crate hands network work off to the tui binary. + pub auth_token: Option<String>, + pub logged_in_username: Option<String>, + pub login_register_mode: bool, // false = Login, true = Register + pub login_field: LoginField, + pub login_username: String, + pub login_password: String, + pub net_busy: bool, + pub net_status: Option<String>, + pub pending_network_action: Option<NetworkAction>, + pub leaderboard_mode: String, + pub leaderboard_entries: Vec<LeaderboardRow>, + pub friends_list: Vec<FriendRow>, + pub friends_incoming: Vec<FriendRow>, + pub friends_outgoing: Vec<FriendRow>, + pub friends_add_input: String, + pub friends_selected: usize, + + // Multiplayer: lobby (create/join by code, ready check), the live race + // itself, and results. current_text() prefers mp_race_text over every + // other source while a race is active, so State::MultiplayerRace reuses + // TypingGame's exact keystroke handling and rendering unchanged. + pub mp_room_code_input: String, + pub mp_own_room_code: Option<String>, + pub mp_players: Vec<MpPlayer>, + pub mp_local_player_id: Option<String>, + pub mp_countdown: Option<u32>, + pub mp_race_text: Option<Text>, + pub mp_results: Vec<(String, String, f32, u32)>, // (player_id, name, wpm, place) + pub mp_status: Option<String>, + pub mp_finish_sent: bool, +} + +impl App { + pub fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { + let config = Config::new(); + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + // texts.json is stored at repository root; this file is at crates/core/src/app.rs + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec<RawText> = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec<Text> = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + // Plain-language prose, not code - leave language empty so + // highlight_classes() doesn't run its string-literal scan + // over apostrophes in contractions and possessives. + language: String::new(), + category: t.category, + }) + .collect(); + let stats = Stats::new(); + let input = String::new(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::<Vec<_>>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + let should_exit = false; + let state = State::MainMenu; + + Ok(App { + config, + texts, + categories, + selected_category: None, + words_mode_selected: false, + word_count: 25, + words_punctuation: false, + words_numbers: false, + time_mode_selected: false, + time_duration: 30, + current_words: None, + stats, + input, + current_text_index, + should_exit, + state, + wpm_history: Vec::new(), + custom_mode_selected: false, + custom_text: None, + custom_index: 0, + using_custom: false, + current_custom: None, + path_input: String::new(), + path_error: None, + passive_active_index: 0, + passive_typed: String::new(), + passive_correct: 0, + passive_total: 0, + passive_start: None, + passive_schedule: Vec::new(), + passive_done: false, + auth_token: None, + logged_in_username: None, + login_register_mode: false, + login_field: LoginField::Username, + login_username: String::new(), + login_password: String::new(), + net_busy: false, + net_status: None, + pending_network_action: None, + leaderboard_mode: "time-30".to_string(), + leaderboard_entries: Vec::new(), + friends_list: Vec::new(), + friends_incoming: Vec::new(), + friends_outgoing: Vec::new(), + friends_add_input: String::new(), + friends_selected: 0, + mp_room_code_input: String::new(), + mp_own_room_code: None, + mp_players: Vec::new(), + mp_local_player_id: None, + mp_countdown: None, + mp_race_text: None, + mp_results: Vec::new(), + mp_status: None, + mp_finish_sent: false, + }) + } + + // Called by the tui binary once its network worker restores a saved + // token from the local config file at startup, so a returning user + // doesn't have to log in again every session. + pub fn restore_session(&mut self, token: String, username: String) { + self.auth_token = Some(token); + self.logged_in_username = Some(username); + } + + pub fn set_net_busy(&mut self, busy: bool) { + self.net_busy = busy; + } + + pub fn set_net_error(&mut self, message: String) { + self.net_busy = false; + self.net_status = Some(message); + } + + pub fn set_login_success(&mut self, username: String, token: Option<String>) { + self.net_busy = false; + self.logged_in_username = Some(username); + if let Some(token) = token { + self.auth_token = Some(token); + } + self.login_password.clear(); + self.net_status = Some("Signed in.".to_string()); + self.state = State::MainMenu; + } + + pub fn set_leaderboard(&mut self, entries: Vec<LeaderboardRow>) { + self.net_busy = false; + self.net_status = None; + self.leaderboard_entries = entries; + } + + pub fn set_friends(&mut self, list: Vec<FriendRow>, incoming: Vec<FriendRow>, outgoing: Vec<FriendRow>) { + self.net_busy = false; + self.net_status = None; + self.friends_list = list; + self.friends_incoming = incoming; + self.friends_outgoing = outgoing; + self.friends_selected = 0; + } + + pub fn set_mp_error(&mut self, message: String) { + self.net_busy = false; + self.mp_status = Some(message); + } + + pub fn set_mp_room_created(&mut self, code: String) { + self.net_busy = false; + self.mp_own_room_code = Some(code); + self.mp_status = None; + } + + pub fn set_mp_joined(&mut self, player_id: String) { + self.net_busy = false; + self.mp_local_player_id = Some(player_id); + self.mp_status = None; + } + + pub fn set_mp_player_list(&mut self, players: Vec<MpPlayer>) { + self.mp_players = players; + } + + pub fn set_mp_countdown(&mut self, seconds: u32) { + self.mp_countdown = Some(seconds); + } + + /// The server hands every racer the identical passage (see + /// crates/server's room lifecycle) - this is what makes current_text() + /// pick it up for the race the same way TypingGame reads its own pool. + pub fn set_mp_start(&mut self, text: String) { + self.mp_countdown = None; + self.mp_race_text = Some(Text { content: text, source: "Multiplayer".to_string(), language: String::new(), category: String::new() }); + self.mp_finish_sent = false; + self.input.clear(); + self.stats = Stats::new(); + self.stats.start(); + self.state = State::MultiplayerRace; + } + + pub fn set_mp_player_progress(&mut self, player_id: &str, percent: f32, wpm: f32) { + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = percent; + p.wpm = wpm; + } + } + + pub fn set_mp_player_finished(&mut self, player_id: &str, name_fallback: &str, wpm: f32, place: u32) { + let name = self.mp_players.iter().find(|p| p.id == player_id).map(|p| p.name.clone()).unwrap_or_else(|| name_fallback.to_string()); + if let Some(p) = self.mp_players.iter_mut().find(|p| p.id == player_id) { + p.progress = 100.0; + p.wpm = wpm; + } + // Deduped by player id, not name - two unauthenticated players both + // default to "Guest", and keying on name silently dropped the + // second Guest's own finish as an apparent duplicate of the first's. + if !self.mp_results.iter().any(|(id, _, _, _)| id == player_id) { + self.mp_results.push((player_id.to_string(), name, wpm, place)); + self.mp_results.sort_by_key(|(_, _, _, place)| *place); + } + // Only the local player's own finish should leave the race screen -- + // the room may keep racing after we're done, and other players' + // PlayerFinished messages arrive well before that happens. + if self.mp_local_player_id.as_deref() == Some(player_id) { + self.state = State::MultiplayerResults; + } + } + + pub fn set_mp_room_closed(&mut self, reason: String) { + self.mp_status = Some(format!("Room closed: {reason}")); + self.state = State::MainMenu; + } + + pub fn reset_multiplayer(&mut self) { + self.mp_room_code_input.clear(); + self.mp_own_room_code = None; + self.mp_players.clear(); + self.mp_local_player_id = None; + self.mp_countdown = None; + self.mp_race_text = None; + self.mp_results.clear(); + self.mp_status = None; + self.mp_finish_sent = false; + self.net_busy = false; + } + + fn handle_backspace_with_rules(&mut self, ctrl: bool) { + if self.input.is_empty() { return; } + let current_text = self.current_text().content.clone(); + let current_text = ¤t_text; + if ctrl { + // Standard word-backward delete: skip any trailing whitespace + // first (e.g. cursor sitting right after a space you just typed), + // then skip back over the word before it. + let chars: Vec<char> = self.input.chars().collect(); + let mut end = chars.len(); + while end > 0 && chars[end - 1].is_whitespace() { + end -= 1; + } + let mut word_start = end; + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + if word_start < self.input.len() { + self.input = chars[..word_start].iter().collect(); + self.update_stats(); + } + return; + } + + // Deleting one character. Only allow crossing into previous word if there are errors before. + let target_pos = self.input.len().saturating_sub(1); + let current_word_start = self.get_current_word_start(); + if target_pos < current_word_start { + if !self.has_errors_before_position(current_text, current_word_start) { + // No errors before; do not allow moving back into previous words + return; + } + } + self.input.pop(); + self.update_stats(); + } + + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { word_start = i + 1; } + in_word = false; + } else { + in_word = true; + } + } + word_start + } + + fn has_errors_before_position(&self, text: &str, position: usize) -> bool { + let compare_len = self.input.len().min(text.len()); + for (i, (ic, tc)) in self.input.chars().zip(text.chars()).take(compare_len).enumerate() { + if i >= position { break; } + if ic != tc { return true; } + } + false + } + + pub fn new_with_config(config: Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { + #[derive(Deserialize)] + struct RawText { category: String, content: String, attribution: String } + const RAW_TEXTS: &str = include_str!("../../../texts.json"); + let parsed: Vec<RawText> = serde_json::from_str(RAW_TEXTS)?; + let texts: Vec<Text> = parsed + .into_iter() + .map(|t| Text { + content: t.content, + source: t.attribution, + // Plain-language prose, not code - leave language empty so + // highlight_classes() doesn't run its string-literal scan + // over apostrophes in contractions and possessives. + language: String::new(), + category: t.category, + }) + .collect(); + let categories = { + let mut set = std::collections::BTreeSet::new(); + for t in &texts { if !t.category.is_empty() { set.insert(t.category.clone()); } } + set.into_iter().collect::<Vec<_>>() + }; + let current_text_index = if texts.is_empty() { 0 } else { rand::thread_rng().gen_range(0..texts.len()) }; + Ok(Self { + state: State::MainMenu, + should_exit: false, + input: String::new(), + texts, + categories, + selected_category: None, + words_mode_selected: false, + word_count: 25, + words_punctuation: false, + words_numbers: false, + time_mode_selected: false, + time_duration: 30, + current_words: None, + current_text_index, + stats: Stats::new(), + config, + wpm_history: Vec::new(), + custom_mode_selected: false, + custom_text: None, + custom_index: 0, + using_custom: false, + current_custom: None, + path_input: String::new(), + path_error: None, + passive_active_index: 0, + passive_typed: String::new(), + passive_correct: 0, + passive_total: 0, + passive_start: None, + passive_schedule: Vec::new(), + passive_done: false, + auth_token: None, + logged_in_username: None, + login_register_mode: false, + login_field: LoginField::Username, + login_username: String::new(), + login_password: String::new(), + net_busy: false, + net_status: None, + pending_network_action: None, + leaderboard_mode: "time-30".to_string(), + leaderboard_entries: Vec::new(), + friends_list: Vec::new(), + friends_incoming: Vec::new(), + friends_outgoing: Vec::new(), + friends_add_input: String::new(), + friends_selected: 0, + mp_room_code_input: String::new(), + mp_own_room_code: None, + mp_players: Vec::new(), + mp_local_player_id: None, + mp_countdown: None, + mp_race_text: None, + mp_results: Vec::new(), + mp_status: None, + mp_finish_sent: false, + }) + } + + pub fn reset(&mut self) { + if self.using_custom { + self.start_custom_chunk(); + return; + } + self.input.clear(); + self.stats.reset(); + self.wpm_history.clear(); + if self.words_mode_selected { + let text = generate_words(self.word_count, self.words_punctuation, self.words_numbers); + self.current_words = Some(Text::from_all(&text, "", "en", "words")); + } else if self.time_mode_selected { + let count = word_count_for_duration(self.time_duration); + let text = generate_words(count, self.words_punctuation, self.words_numbers); + self.current_words = Some(Text::from_all(&text, "", "en", "time")); + } else { + self.current_words = None; + self.current_text_index = self.pick_random_index(); + } + } + + const WORD_COUNTS: [usize; 4] = [10, 25, 50, 100]; + const TIME_DURATIONS: [u64; 4] = [15, 30, 60, 120]; + + fn cycle_word_count(&mut self, forward: bool) { + let pos = Self::WORD_COUNTS.iter().position(|&c| c == self.word_count).unwrap_or(1); + let len = Self::WORD_COUNTS.len(); + let next = if forward { (pos + 1) % len } else { (pos + len - 1) % len }; + self.word_count = Self::WORD_COUNTS[next]; + } + + fn cycle_time_duration(&mut self, forward: bool) { + let pos = Self::TIME_DURATIONS.iter().position(|&d| d == self.time_duration).unwrap_or(1); + let len = Self::TIME_DURATIONS.len(); + let next = if forward { (pos + 1) % len } else { (pos + len - 1) % len }; + self.time_duration = Self::TIME_DURATIONS[next]; + } + + fn pick_random_index(&self) -> usize { + if self.texts.is_empty() { return 0; } + let pool: Vec<usize> = match &self.selected_category { + Some(cat) => self.texts.iter().enumerate().filter(|(_, t)| &t.category == cat).map(|(i, _)| i).collect(), + None => (0..self.texts.len()).collect(), + }; + if pool.is_empty() { return 0; } + let idx = rand::thread_rng().gen_range(0..pool.len()); + pool[idx] + } + + /// Left/Right in the main menu cycle through: Random -> each category -> + /// Custom -> back to Random. Custom is a distinct slot, not a category. + /// Slot order matches the web menu: Random -> Words -> Time -> each + /// category -> Custom -> back to Random. + fn cycle_mode(&mut self, forward: bool) { + let total = self.categories.len() + 4; // Random + Words + Time + categories + Custom + let current_pos = if self.custom_mode_selected { + total - 1 + } else if self.time_mode_selected { + 2 + } else if self.words_mode_selected { + 1 + } else { + match &self.selected_category { + None => 0, + Some(cur) => self.categories.iter().position(|c| c == cur).map(|p| p + 3).unwrap_or(0), + } + }; + let next_pos = if forward { + (current_pos + 1) % total + } else { + (current_pos + total - 1) % total + }; + self.custom_mode_selected = false; + self.words_mode_selected = false; + self.time_mode_selected = false; + self.selected_category = None; + if next_pos == total - 1 { + self.custom_mode_selected = true; + } else if next_pos == 1 { + self.words_mode_selected = true; + } else if next_pos == 2 { + self.time_mode_selected = true; + } else if next_pos != 0 { + self.selected_category = Some(self.categories[next_pos - 3].clone()); + } + } + + fn start_custom_chunk(&mut self) { + if let Some(ct) = &self.custom_text { + if let Some(chunk) = ct.chunks.get(self.custom_index) { + self.current_custom = Some(Text::from_all( + &chunk.content, + &ct.name, + ct.language.as_deref().unwrap_or(""), + "custom", + )); + } + } + self.input.clear(); + self.stats.reset(); + self.wpm_history.clear(); + } + + fn advance_custom_index(&mut self) -> bool { + if let Some(ct) = &self.custom_text { + if self.custom_index + 1 < ct.chunks.len() { + self.custom_index += 1; + return true; + } + } + false + } + + fn load_custom_text(&mut self) { + let path = self.path_input.trim().to_string(); + if path.is_empty() { + self.path_error = Some("Enter a file path.".to_string()); + return; + } + match std::fs::read_to_string(&path) { + Ok(raw) => { + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(&path) + .to_string(); + let ct = parse_custom_content(&raw, &filename); + if ct.chunks.is_empty() { + self.path_error = Some("No typeable text found in that file.".to_string()); + } else { + self.custom_text = Some(ct); + self.custom_mode_selected = true; + self.path_error = None; + self.state = State::MainMenu; + } + } + Err(e) => { + self.path_error = Some(format!("Could not read file: {}", e)); + } + } + } + + fn start_passive(&mut self) { + let Some(ct) = &self.custom_text else { return }; + self.passive_schedule = build_passive_schedule(&ct.chunks); + self.passive_active_index = 0; + self.passive_typed.clear(); + self.passive_correct = 0; + self.passive_total = 0; + self.passive_done = false; + self.passive_start = Some(Instant::now()); + self.state = State::PassiveMode; + } + + fn advance_passive_to(&mut self, target: usize) { + if let Some(ct) = &self.custom_text { + if let Some(chunk) = ct.chunks.get(self.passive_active_index) { + let target_chars: Vec<char> = chunk.content.chars().collect(); + let typed_chars: Vec<char> = self.passive_typed.chars().collect(); + for (i, tc) in typed_chars.iter().enumerate() { + self.passive_total += 1; + if target_chars.get(i) == Some(tc) { + self.passive_correct += 1; + } + } + } + } + self.passive_active_index = target; + self.passive_typed.clear(); + } + + fn update_passive(&mut self) { + if self.passive_done { + return; + } + let Some(start) = self.passive_start else { return }; + let elapsed = start.elapsed().as_secs_f64(); + + let mut target = self.passive_active_index; + while target + 1 < self.passive_schedule.len() && elapsed >= self.passive_schedule[target + 1] { + target += 1; + } + if target != self.passive_active_index { + self.advance_passive_to(target); + } + + let total_duration = self.passive_schedule.last().copied().unwrap_or(0.0) + 4.0; + if elapsed >= total_duration { + self.passive_done = true; + } + } + + pub fn passive_accuracy(&self) -> f64 { + if self.passive_total == 0 { + 100.0 + } else { + (self.passive_correct as f64 / self.passive_total as f64) * 100.0 + } + } + + pub fn handle_input(&mut self, key: KeyEvent) { + match self.state { + State::MainMenu => { + match key.code { + crossterm::event::KeyCode::Enter => { + if self.custom_mode_selected { + if let Some(ct) = &self.custom_text { + if !ct.chunks.is_empty() { + self.using_custom = true; + self.custom_index = 0; + self.state = State::TypingGame; + self.start_custom_chunk(); + } + } else { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Char('p') | crossterm::event::KeyCode::Char('P') + if self.custom_mode_selected => + { + if let Some(ct) = &self.custom_text { + if ct.timed && !ct.chunks.is_empty() { + self.start_passive(); + } + } + } + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') => { + self.state = State::CustomTextPrompt; + self.path_input.clear(); + self.path_error = None; + } + crossterm::event::KeyCode::Char('x') | crossterm::event::KeyCode::Char('X') + if self.custom_mode_selected => + { + self.custom_text = None; + self.custom_mode_selected = false; + } + crossterm::event::KeyCode::Char('l') | crossterm::event::KeyCode::Char('L') => { + self.net_status = None; + self.state = State::Leaderboard; + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + crossterm::event::KeyCode::Char('a') | crossterm::event::KeyCode::Char('A') => { + self.net_status = None; + self.state = State::Login; + } + crossterm::event::KeyCode::Char('f') | crossterm::event::KeyCode::Char('F') => { + self.net_status = None; + self.state = State::Friends; + if self.logged_in_username.is_some() { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchFriends); + } + } + crossterm::event::KeyCode::Char('m') | crossterm::event::KeyCode::Char('M') => { + self.reset_multiplayer(); + self.state = State::MultiplayerLobby; + } + crossterm::event::KeyCode::Left => self.cycle_mode(false), + crossterm::event::KeyCode::Right => self.cycle_mode(true), + crossterm::event::KeyCode::Up if self.words_mode_selected => { + self.cycle_word_count(true) + } + crossterm::event::KeyCode::Down if self.words_mode_selected => { + self.cycle_word_count(false) + } + crossterm::event::KeyCode::Up if self.time_mode_selected => { + self.cycle_time_duration(true) + } + crossterm::event::KeyCode::Down if self.time_mode_selected => { + self.cycle_time_duration(false) + } + crossterm::event::KeyCode::Esc => { + self.should_exit = true; + } + _ => {} + } + } + State::CustomTextPrompt => match key.code { + crossterm::event::KeyCode::Enter => self.load_custom_text(), + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.path_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + self.path_input.push(c); + } + _ => {} + }, + State::TypingGame => { + match key.code { + crossterm::event::KeyCode::Char(c) => { + // Handle control-word delete (Ctrl+W, or Ctrl+H on terminals + // that send it in place of Ctrl+Backspace). + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && (c == 'w' || c == 'W' || c == 'h' || c == 'H') + { + self.handle_backspace_with_rules(true); + return; + } + // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok) + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + // Record keystroke correctness before mutating input + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + if pos < current_text.len() { + // Compare with target at this position + current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + } else { + false // extra chars are considered incorrect + } + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms + let ctrl_or_alt = key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, + ); + self.handle_backspace_with_rules(ctrl_or_alt); + } + crossterm::event::KeyCode::Tab => { + // Quick restart: abandon the current attempt and start + // a fresh one of the same mode, without leaving the + // typing screen. + self.reset(); + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::PassiveMode => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Enter if self.passive_done => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Backspace => { + self.passive_typed.pop(); + } + crossterm::event::KeyCode::Char(c) if !self.passive_done => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.passive_typed.push(c); + } + } + _ => {} + }, + State::EndScreen => { + match key.code { + // Tab is the keyboard-shortcut equivalent of Enter here -- + // both play again / advance to the next custom segment. + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Tab => { + if self.using_custom { + if self.advance_custom_index() { + self.state = State::TypingGame; + self.start_custom_chunk(); + } else { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + } + } else { + self.state = State::TypingGame; + self.reset(); + } + } + crossterm::event::KeyCode::Esc => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + _ => {} + } + } + State::Login => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Tab => { + self.login_field = match self.login_field { + LoginField::Username => LoginField::Password, + LoginField::Password => LoginField::Username, + }; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + self.login_register_mode = !self.login_register_mode; + } + crossterm::event::KeyCode::Enter => { + if self.logged_in_username.is_some() { + // Already signed in - Enter here just backs out, + // there's no form to submit. + self.state = State::MainMenu; + return; + } + if self.login_username.is_empty() || self.login_password.is_empty() { + self.net_status = Some("Enter a username and password.".to_string()); + return; + } + self.net_busy = true; + self.net_status = None; + let username = self.login_username.clone(); + let password = self.login_password.clone(); + self.pending_network_action = Some(if self.login_register_mode { + NetworkAction::Register { username, password } + } else { + NetworkAction::Login { username, password } + }); + } + crossterm::event::KeyCode::Backspace => { + match self.login_field { + LoginField::Username => { self.login_username.pop(); } + LoginField::Password => { self.login_password.pop(); } + } + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + match self.login_field { + LoginField::Username => self.login_username.push(c), + LoginField::Password => self.login_password.push(c), + } + } + } + _ => {} + }, + State::Leaderboard => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') => { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::FetchLeaderboard { mode: self.leaderboard_mode.clone() }); + } + _ => {} + }, + State::Friends => match key.code { + crossterm::event::KeyCode::Esc => { + self.state = State::MainMenu; + } + crossterm::event::KeyCode::Up => { + if self.friends_selected > 0 { self.friends_selected -= 1; } + } + crossterm::event::KeyCode::Down => { + if self.friends_selected + 1 < self.friends_incoming.len() { + self.friends_selected += 1; + } + } + // Guarded on the add-username field being empty - otherwise + // typing a 'y' or 'd' into a username (both letters are legal + // in one, per validate_username) would get eaten as an + // accept/decline instead of landing in the text field. + crossterm::event::KeyCode::Char('y') | crossterm::event::KeyCode::Char('Y') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::AcceptFriendRequest { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Char('d') | crossterm::event::KeyCode::Char('D') + if self.friends_add_input.is_empty() => + { + if let Some(row) = self.friends_incoming.get(self.friends_selected) { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::RemoveFriendship { id: row.friendship_id.clone() }); + } + } + crossterm::event::KeyCode::Enter => { + if !self.friends_add_input.is_empty() { + self.net_busy = true; + self.net_status = None; + let username = self.friends_add_input.clone(); + self.friends_add_input.clear(); + self.pending_network_action = Some(NetworkAction::SendFriendRequest { username }); + } + } + crossterm::event::KeyCode::Backspace => { + self.friends_add_input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.friends_add_input.push(c); + } + } + _ => {} + }, + State::MultiplayerLobby => match key.code { + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + // Only before a room exists - once connected (own room + // created, or joined and appearing in the player list), + // 'c'/'r' below take over instead of these editing the + // now-irrelevant join-code field. + crossterm::event::KeyCode::Char('c') | crossterm::event::KeyCode::Char('C') + if self.mp_own_room_code.is_none() && self.mp_local_player_id.is_none() => + { + self.net_busy = true; + self.mp_status = None; + self.pending_network_action = Some(NetworkAction::CreateMultiplayerRoom); + } + crossterm::event::KeyCode::Char('r') | crossterm::event::KeyCode::Char('R') + if self.mp_local_player_id.is_some() => + { + self.net_busy = true; + self.pending_network_action = Some(NetworkAction::MultiplayerReady); + } + crossterm::event::KeyCode::Enter + if self.mp_local_player_id.is_none() && !self.mp_room_code_input.trim().is_empty() => + { + self.net_busy = true; + self.mp_status = None; + let code = self.mp_room_code_input.trim().to_uppercase(); + self.pending_network_action = Some(NetworkAction::JoinMultiplayerRoom { code }); + } + crossterm::event::KeyCode::Backspace if self.mp_local_player_id.is_none() => { + self.mp_room_code_input.pop(); + } + crossterm::event::KeyCode::Char(c) if self.mp_local_player_id.is_none() => { + if !key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + self.mp_room_code_input.push(c); + } + } + _ => {} + }, + State::MultiplayerRace => match key.code { + crossterm::event::KeyCode::Char(c) => { + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; + } + if !self.stats.is_running() { self.stats.start(); } + let was_correct = { + let pos = self.input.len(); + let current_text = self.current_text().content.clone(); + pos < current_text.len() && current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + }; + self.stats.note_keypress(was_correct); + self.input.push(c); + self.update_stats(); + } + crossterm::event::KeyCode::Backspace => { + if !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + State::MultiplayerResults => match key.code { + crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + _ => {} + }, + } + + // Check if the current text is finished + if self.state == State::TypingGame && self.is_finished() { + self.state = State::EndScreen; + self.stats.stop(); + } + // Multiplayer doesn't jump to a results screen the instant typing + // finishes - the tui binary's main loop notices is_finished() here, + // sends Finish over the connection, and set_mp_player_finished (see + // above) is what actually transitions to MultiplayerResults, once + // the server confirms it rather than the client assuming it. + if self.state == State::MultiplayerRace && self.is_finished() && self.stats.is_running() { + self.stats.stop(); + } + } + + pub fn update_stats(&mut self) { + if self.state == State::TypingGame || self.state == State::MultiplayerRace { + let current_text = self.current_text().content.clone(); + self.stats.update(&self.input, ¤t_text); + } + } + + // Length-only, matching the web engine's game.rs - an uncorrected + // mistake anywhere in the input must still let the test end once you've + // typed as many characters as the text has. An exact-match requirement + // meant one uncorrected typo anywhere (not just the last character) made + // the test unfinishable, since nothing here caps input at the text's + // length the way the web frontend does. + pub fn is_finished(&self) -> bool { + !self.input.is_empty() && self.input.len() >= self.current_text().content.len() + } + + pub fn current_text(&self) -> &Text { + self.mp_race_text.as_ref() + .or(self.current_custom.as_ref()) + .or(self.current_words.as_ref()) + .unwrap_or(&self.texts[self.current_text_index]) + } + + pub fn get_input(&self) -> &str { + self.input.as_str() + } + + pub fn handle_backspace(&mut self) { + if self.state == State::TypingGame && !self.input.is_empty() { + self.input.pop(); + self.update_stats(); + } + } + + pub fn handle_enter(&mut self) { + match self.state { + State::MainMenu => { + self.state = State::TypingGame; + self.reset(); + } + State::EndScreen => { + self.state = State::TypingGame; + self.reset(); + } + _ => {} + } + } + + pub fn handle_escape(&mut self) { + match self.state { + State::TypingGame => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::EndScreen => { + self.using_custom = false; + self.current_custom = None; + self.state = State::MainMenu; + self.reset(); + } + State::CustomTextPrompt | State::PassiveMode | State::Login | State::Leaderboard | State::Friends => { + self.state = State::MainMenu; + } + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults => { + self.reset_multiplayer(); + self.state = State::MainMenu; + } + State::MainMenu => { + self.should_exit = true; + } + } + } + + pub fn get_progress(&self) -> f64 { + if self.input.is_empty() { + 0.0 + } else { + let total_chars = self.current_text().content.chars().count(); + let current_chars = self.input.chars().count(); + (current_chars as f64 / total_chars as f64) * 100.0 + } + } + + pub fn update(&mut self) { + if self.state == State::TypingGame { + self.update_stats(); + // Time mode ends on the clock rather than at the end of the + // text, so it needs its own check here - is_finished() only + // fires from a keystroke, and the clock keeps running even if + // the typist pauses. + if self.time_mode_selected + && self.stats.is_running() + && self.stats.elapsed_time().as_secs_f64() >= self.time_duration as f64 + { + self.state = State::EndScreen; + self.stats.stop(); + } + // Sample WPM once per elapsed second to build a compact sparkline + let secs = self.stats.elapsed_time().as_secs() as usize; + while self.wpm_history.len() < secs { + self.wpm_history.push(self.stats.wpm().round() as u64); + } + } else if self.state == State::PassiveMode { + self.update_passive(); + } else if self.state == State::MultiplayerRace { + self.update_stats(); + } + } +} + +const PASSIVE_PACE_WPM: f64 = 130.0; +const PASSIVE_LINE_GAP_SECONDS: f64 = 0.6; + +fn build_passive_schedule(chunks: &[crate::custom_text::CustomChunk]) -> Vec<f64> { + let all_timed = chunks.iter().all(|c| c.time.is_some()); + if all_timed && !chunks.is_empty() { + let base = chunks[0].time.unwrap_or(0.0); + return chunks.iter().map(|c| c.time.unwrap_or(base) - base).collect(); + } + let mut t = 0.0; + let mut offsets = Vec::with_capacity(chunks.len()); + for c in chunks { + offsets.push(t); + let duration = (c.content.chars().count() as f64 / 5.0) / (PASSIVE_PACE_WPM / 60.0); + t += duration + PASSIVE_LINE_GAP_SECONDS; + } + offsets +}
\ No newline at end of file diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..3072438 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + pub mode: String, + pub time: u64, + pub words: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "dark".to_string(), + mode: "time".to_string(), + time: 60, + words: 50, + } + } +} + +impl Config { + pub fn new() -> Self { + Self::default() + } + + pub fn get_theme(&self) -> String { + self.theme.clone() + } + + pub fn get_mode(&self) -> String { + self.mode.clone() + } + + pub fn get_time(&self) -> u64 { + self.time + } + + pub fn get_words(&self) -> usize { + self.words + } +} diff --git a/crates/core/src/custom_text.rs b/crates/core/src/custom_text.rs new file mode 100644 index 0000000..0aff569 --- /dev/null +++ b/crates/core/src/custom_text.rs @@ -0,0 +1,345 @@ +#[derive(Debug, Clone)] +pub struct CustomChunk { + pub content: String, + pub time: Option<f64>, +} + +#[derive(Debug, Clone)] +pub struct CustomText { + pub name: String, + pub chunks: Vec<CustomChunk>, + pub language: Option<String>, + pub timed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SynClass { + Keyword, + StringLit, + Comment, + Number, +} + +fn language_for_filename(filename: &str) -> Option<&'static str> { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "js" | "jsx" | "mjs" | "ts" | "tsx" => Some("javascript"), + "py" => Some("python"), + "rs" => Some("rust"), + "c" | "h" | "cpp" | "hpp" | "cc" | "java" | "go" | "cs" => Some("clike"), + "sh" | "bash" | "zsh" => Some("shell"), + _ => None, + } +} + +fn keywords_for(language: &str) -> &'static [&'static str] { + match language { + "javascript" => &[ + "const", "let", "var", "function", "return", "if", "else", "for", "while", "class", + "import", "export", "from", "new", "this", "async", "await", "try", "catch", + "switch", "case", "break", "continue", "typeof", "null", "undefined", "true", "false", + ], + "python" => &[ + "def", "return", "if", "elif", "else", "for", "while", "class", "import", "from", + "as", "try", "except", "with", "lambda", "None", "True", "False", "pass", "break", + "continue", "yield", "self", + ], + "rust" => &[ + "fn", "let", "mut", "return", "if", "else", "for", "while", "loop", "match", + "struct", "enum", "impl", "trait", "pub", "use", "mod", "self", "Self", "true", + "false", "const", "static", + ], + "clike" => &[ + "int", "float", "double", "char", "void", "if", "else", "for", "while", "return", + "struct", "class", "public", "private", "static", "const", "new", "true", "false", + "null", + ], + "shell" => &[ + "if", "then", "else", "fi", "for", "do", "done", "while", "function", "echo", + "export", "local", + ], + _ => &[], + } +} + +fn line_comment_for(language: &str) -> &'static str { + match language { + "javascript" | "rust" | "clike" => "//", + "shell" | "python" => "#", + _ => "", + } +} + +/// Per-character syntax class for the neutral (untyped) portion of a line of +/// code. Best-effort: a small hand-rolled tokenizer, not a real parser. +pub fn highlight_classes(text: &str, language: Option<&str>) -> Vec<Option<SynClass>> { + let chars: Vec<char> = text.chars().collect(); + let mut classes = vec![None; chars.len()]; + let Some(language) = language else { return classes }; + let keywords = keywords_for(language); + let comment_marker = line_comment_for(language); + + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + + if c == '"' || c == '\'' || c == '`' { + let quote = c; + let start = i; + i += 1; + while i < chars.len() && chars[i] != quote { + if chars[i] == '\\' && i + 1 < chars.len() { + i += 1; + } + i += 1; + } + if i < chars.len() { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::StringLit); + } + continue; + } + + if !comment_marker.is_empty() && matches_at(&chars, i, comment_marker) { + let start = i; + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::Comment); + } + continue; + } + + if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + for j in start..i { + classes[j] = Some(SynClass::Number); + } + continue; + } + + if c.is_alphabetic() || c == '_' { + let start = i; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + if keywords.contains(&word.as_str()) { + for j in start..i { + classes[j] = Some(SynClass::Keyword); + } + } + continue; + } + + i += 1; + } + + classes +} + +fn matches_at(chars: &[char], pos: usize, marker: &str) -> bool { + let marker_chars: Vec<char> = marker.chars().collect(); + if pos + marker_chars.len() > chars.len() { + return false; + } + chars[pos..pos + marker_chars.len()] == marker_chars[..] +} + +fn split_long(s: &str, max_len: usize) -> Vec<String> { + if s.chars().count() <= max_len { + return vec![s.to_string()]; + } + let chars: Vec<char> = s.chars().collect(); + let mut parts = Vec::new(); + let mut start = 0; + while start < chars.len() { + let end = (start + max_len).min(chars.len()); + let slice = &chars[start..end]; + let min_cut = (max_len as f64 * 0.4) as usize; + let last_newline = slice.iter().rposition(|&c| c == '\n'); + let last_space = slice.iter().rposition(|&c| c == ' '); + let cut = match (last_newline, last_space) { + (Some(n), _) if n > min_cut => n + 1, + (_, Some(sp)) if sp > min_cut => sp + 1, + _ => slice.len(), + }; + parts.push(chars[start..start + cut].iter().collect::<String>()); + start += cut; + } + parts +} + +pub fn chunk_plain_text(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + let paragraphs: Vec<&str> = trimmed + .split("\n\n") + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .collect(); + let source: Vec<&str> = if paragraphs.len() > 1 { + paragraphs + } else { + trimmed + .split('\n') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect() + }; + + let mut chunks = Vec::new(); + for block in source { + for piece in split_long(block, 400) { + let piece = piece.trim(); + if !piece.is_empty() { + chunks.push(CustomChunk { content: piece.to_string(), time: None }); + } + } + } + chunks +} + +fn time_to_seconds(s: &str) -> Option<f64> { + let normalized = s.replace(',', "."); + let parts: Vec<&str> = normalized.split(':').collect(); + let nums: Result<Vec<f64>, _> = parts.iter().map(|p| p.parse::<f64>()).collect(); + let nums = nums.ok()?; + match nums.len() { + 3 => Some(nums[0] * 3600.0 + nums[1] * 60.0 + nums[2]), + 2 => Some(nums[0] * 60.0 + nums[1]), + 1 => Some(nums[0]), + _ => None, + } +} + +fn parse_srt(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let mut chunks = Vec::new(); + for block in normalized.trim().split("\n\n") { + let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + continue; + } + let mut idx = 0; + if lines[idx].trim().chars().all(|c| c.is_ascii_digit()) { + idx += 1; + } + let mut time = None; + if idx < lines.len() && lines[idx].contains("-->") { + if let Some(start) = lines[idx].split("-->").next() { + time = time_to_seconds(start.trim()); + } + idx += 1; + } + let text = lines[idx..].join(" "); + let text = strip_tags(&text); + if !text.trim().is_empty() { + chunks.push(CustomChunk { content: text.trim().to_string(), time }); + } + } + chunks +} + +fn parse_vtt(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let body = normalized.trim(); + let body = body.strip_prefix("WEBVTT").map(|s| s.trim_start()).unwrap_or(body); + let mut chunks = Vec::new(); + for block in body.split("\n\n") { + let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + continue; + } + let mut idx = 0; + if !lines[idx].contains("-->") { + idx += 1; + } + let mut time = None; + if idx < lines.len() && lines[idx].contains("-->") { + if let Some(start) = lines[idx].split("-->").next() { + time = time_to_seconds(start.trim()); + } + idx += 1; + } + if idx > lines.len() { + continue; + } + let text = lines[idx.min(lines.len())..].join(" "); + let text = strip_tags(&text); + if !text.trim().is_empty() { + chunks.push(CustomChunk { content: text.trim().to_string(), time }); + } + } + chunks +} + +fn parse_lrc(raw: &str) -> Vec<CustomChunk> { + let normalized = raw.replace("\r\n", "\n"); + let mut chunks = Vec::new(); + for line in normalized.lines() { + let line = line.trim(); + if !line.starts_with('[') { + continue; + } + let mut rest = line; + let mut first_time = None; + while let Some(close) = rest.find(']') { + if !rest.starts_with('[') { + break; + } + let stamp = &rest[1..close]; + if stamp.len() >= 5 && stamp.chars().next().unwrap().is_ascii_digit() { + if first_time.is_none() { + first_time = time_to_seconds(stamp); + } + rest = &rest[close + 1..]; + } else { + break; + } + } + let text = rest.trim(); + if !text.is_empty() { + chunks.push(CustomChunk { content: text.to_string(), time: first_time }); + } + } + chunks +} + +fn strip_tags(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_tag = false; + for c in s.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + out +} + +pub fn parse_custom_content(raw: &str, filename: &str) -> CustomText { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + let name = filename.to_string(); + match ext.as_str() { + "srt" => CustomText { name, chunks: parse_srt(raw), language: None, timed: true }, + "vtt" => CustomText { name, chunks: parse_vtt(raw), language: None, timed: true }, + "lrc" => CustomText { name, chunks: parse_lrc(raw), language: None, timed: true }, + _ => { + let language = language_for_filename(filename).map(|s| s.to_string()); + CustomText { name, chunks: chunk_plain_text(raw), language, timed: false } + } + } +} diff --git a/crates/core/src/game.rs b/crates/core/src/game.rs new file mode 100644 index 0000000..1362189 --- /dev/null +++ b/crates/core/src/game.rs @@ -0,0 +1,666 @@ +use std::time::Instant; +use serde::{Deserialize, Serialize}; +use crate::types::Theme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option<u64>, + pub word_count: Option<usize>, + pub custom_text: Option<String>, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::Normal, + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 50, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option<u64>; + fn get_word_count(&self) -> Option<usize>; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option<u64> { + self.time_limit + } + + fn get_word_count(&self) -> Option<usize> { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Game { + text: String, + input: String, + #[serde(skip)] + start_time: Option<Instant>, + is_started: bool, + is_finished: bool, + error_positions: Vec<usize>, + current_streak: u32, + best_streak: u32, + theme: Theme, + correct_positions: Vec<bool>, + pub total_mistakes: u32, + total_errors_made: u32, + last_input_length: usize, + total_characters_typed: u32, + total_correct_characters: u32, +} + +impl Game { + pub fn new() -> Self { + Self { + text: String::new(), + input: String::new(), + start_time: None, + is_started: false, + is_finished: false, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + theme: Theme::default(), + correct_positions: Vec::new(), + total_mistakes: 0, + total_errors_made: 0, + last_input_length: 0, + total_characters_typed: 0, + total_correct_characters: 0, + } + } + + pub fn set_text(&mut self, text: String) { + self.text = text; + self.reset(); + } + + pub fn get_text(&self) -> String { + self.text.clone() + } + + pub fn get_input(&self) -> String { + self.input.clone() + } + + pub fn start(&mut self) { + self.is_started = true; + self.start_time = Some(Instant::now()); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + if self.is_finished() { + return Ok(()); + } + + // Validate UTF-8 only + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err("Invalid UTF-8 input".to_string()), + }; + + // Update input + self.input = input_str; + + // Update game state + self.update_game_state(); + Ok(()) + } + + pub fn is_finished(&self) -> bool { + self.is_finished + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> u32 { + self.current_streak + } + + pub fn get_best_streak(&self) -> u32 { + self.best_streak + } + + pub fn get_theme(&self) -> Theme { + self.theme + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + pub fn get_wpm(&self) -> f64 { + if let Some(start_time) = self.start_time { + let elapsed = start_time.elapsed().as_secs_f64(); + if elapsed > 0.0 { + let words = self.input.len() as f64 / 5.0; + return (words * 60.0) / elapsed; + } + } + 0.0 + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + let accuracy = self.get_accuracy(); + let mistakes = self.get_total_mistakes(); + Ok((accuracy, mistakes)) + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + let (accuracy, mistakes) = self.get_stats()?; + Ok((self.input.clone(), accuracy, mistakes)) + } + + pub fn get_accuracy(&self) -> f64 { + if self.total_characters_typed == 0 { + return 100.0; + } + + let accuracy = (self.total_correct_characters as f64 / self.total_characters_typed as f64) * 100.0; + accuracy.max(0.0).min(100.0) + } + + pub fn get_total_mistakes(&self) -> u32 { + // Return total errors made, not current mistakes + self.total_errors_made + } + + pub fn get_time_elapsed(&self) -> f64 { + if let Some(start_time) = self.start_time { + start_time.elapsed().as_secs_f64() + } else { + 0.0 + } + } + + fn reset(&mut self) { + self.input.clear(); + self.start_time = None; + self.is_started = false; + self.is_finished = false; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.correct_positions = vec![false; self.text.len()]; + self.total_mistakes = 0; + self.total_errors_made = 0; + self.last_input_length = 0; + self.total_characters_typed = 0; + self.total_correct_characters = 0; + } + + pub fn can_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn can_ctrl_backspace(&self) -> bool { + !self.is_finished && !self.input.is_empty() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, String> { + if !self.can_backspace() { + return Ok(false); + } + + let mut new_input = self.input.clone(); + + if ctrl { + // Standard word-backward delete: skip any trailing whitespace + // first (e.g. cursor sitting right after a space you just typed), + // then skip back over the word before it. + let chars: Vec<char> = new_input.chars().collect(); + let mut end = chars.len(); + while end > 0 && chars[end - 1].is_whitespace() { + end -= 1; + } + let mut word_start = end; + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + + // Same restriction plain Backspace enforces via + // can_backspace_to_position, applied here too: only reach back + // into an already-completed word when there's an actual + // uncorrected mistake somewhere to fix. Otherwise clamp to the + // start of the word currently being typed. + let current_word_start = self.get_current_word_start(); + if word_start < current_word_start && !self.has_errors_before_position(chars.len()) { + word_start = current_word_start; + } + if word_start >= chars.len() { + return Ok(false); + } + new_input = chars[..word_start].iter().collect(); + } else { + // Regular backspace: delete one character + if !self.can_backspace_to_position(new_input.len() - 1) { + return Ok(false); + } + new_input.pop(); + } + + self.input = new_input; + self.update_game_state(); + Ok(true) + } + + /// Check if there are any errors before a specific position + fn has_errors_before_position(&self, position: usize) -> bool { + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(position) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error + } + } + } + false + } + + /// Check if backspace is allowed to a specific position + /// Returns true if: + /// 1. We're in the current word (can always backspace within current word) + /// 2. There are errors in previous words (can backspace to fix them) + fn can_backspace_to_position(&self, target_pos: usize) -> bool { + if target_pos >= self.input.len() { + return false; + } + + // Always allow backspace within the current word + let current_word_start = self.get_current_word_start(); + if target_pos >= current_word_start { + return true; + } + + // Check if there are any errors in the text before the target position + let text_len = self.text.len().min(self.input.len()); + for i in 0..text_len.min(target_pos + 1) { + if i < self.input.len() { + let input_char = self.input.chars().nth(i); + let text_char = self.text.chars().nth(i); + if input_char != text_char { + return true; // Found an error, allow backspace + } + } + } + + false // No errors found, don't allow backspace to previous words + } + + /// Get the start position of the current word + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { + word_start = i + 1; + } + in_word = false; + } else { + in_word = true; + } + } + + word_start + } + + fn update_game_state(&mut self) { + self.error_positions.clear(); + + // Everything here counts CHARACTERS, never bytes. It used to mix the + // two: `total_characters_typed` accumulated `self.input.len()` deltas + // (a byte count) while `total_correct_characters` only incremented + // when a char index reached `last_input_length` (that same byte + // count). On ASCII the two are equal and it worked. On any text with + // multi-byte characters the byte count outruns the char index, so the + // correct-character counter never advanced and accuracy collapsed -- + // a Spanish passage reported 6% while every character matched. The + // old byte slicing (`&self.input[..n]`) would also panic outright if + // `n` ever landed inside a multi-byte character. + let input_chars: Vec<char> = self.input.chars().collect(); + let text_chars: Vec<char> = self.text.chars().collect(); + + let mut current_mistakes = 0; + let mut current_streak = 0; + let mut best_streak = 0; + + let new_chars_typed = input_chars.len().saturating_sub(self.last_input_length); + self.total_characters_typed += new_chars_typed as u32; + + let compared = input_chars.len().min(text_chars.len()); + for i in 0..compared { + if input_chars[i] == text_chars[i] { + current_streak += 1; + best_streak = best_streak.max(current_streak); + // Only positions newly reached this update count toward the + // lifetime totals, or every keystroke would re-score the + // whole input from the start. + if i >= self.last_input_length { + self.total_correct_characters += 1; + } + } else { + current_mistakes += 1; + current_streak = 0; + self.error_positions.push(i); + if i >= self.last_input_length { + self.total_errors_made += 1; + } + } + } + + // Anything typed past the end of the passage is a mistake too. + if input_chars.len() > text_chars.len() { + current_mistakes += (input_chars.len() - text_chars.len()) as u32; + if input_chars.len() > self.last_input_length { + let prev_extra = self.last_input_length.saturating_sub(text_chars.len()); + let curr_extra = input_chars.len() - text_chars.len(); + let new_extra = curr_extra.saturating_sub(prev_extra); + if new_extra > 0 { + self.total_errors_made += new_extra as u32; + } + } + } + + self.total_mistakes = current_mistakes; + self.current_streak = current_streak as u32; + self.best_streak = best_streak as u32; + self.last_input_length = input_chars.len(); + + // Length-only: an uncorrected mistake anywhere in the input must + // still let the test end once you've reached the end of the text, + // exactly like every other typing-test engine (errors are scored via + // accuracy, not something that blocks completion). Requiring an + // exact match here meant a single uncorrected typo anywhere in the + // text - not just the last character - made the test unfinishable: + // the frontend caps input at text.len(), so once you hit that length + // with any mismatch left in place, you can't type further and + // is_finished() would never become true. + let is_complete = !input_chars.is_empty() && input_chars.len() >= text_chars.len(); + self.is_finished = is_complete; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Regression: accuracy used to be computed by comparing a character index + // against a byte-length counter, so any multi-byte character sent it to + // near zero even when every keystroke was correct. + #[test] + fn accuracy_is_correct_for_multibyte_text() { + let mut game = Game::new(); + game.set_text("años después".to_string()); + game.start(); + game.handle_input("años después").unwrap(); + assert_eq!(game.get_accuracy(), 100.0, "every character matched"); + assert_eq!(game.get_total_mistakes(), 0); + assert!(game.is_finished(), "reaching the end must finish the test"); + } + + #[test] + fn multibyte_mistakes_are_counted_by_character() { + let mut game = Game::new(); + game.set_text("años".to_string()); + game.start(); + // Four characters typed, one of them wrong -> 75%, not a byte-derived + // figure (the passage is five bytes long). + game.handle_input("anos").unwrap(); + assert_eq!(game.get_accuracy(), 75.0); + } + + #[test] + fn test_backspace_within_current_word() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello").unwrap(); + + // Should be able to backspace within current word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hell"); + } + + #[test] + fn test_backspace_to_previous_word_with_error() { + let mut game = Game::new(); + // Longer than the typed input - typing exactly text.len() characters + // finishes the game (see test_game_completion_detection), which + // blocks backspace entirely. Mid-test, not-yet-complete input is also + // the only way this scenario happens for a real user. + game.set_text("Hello world today".to_string()); + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + + // Should be able to backspace to fix the error in previous word + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo worl"); + + // Should be able to backspace more to fix the error + assert!(game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), "Hallo wor"); + } + + #[test] + fn test_backspace_to_previous_word_without_error() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + game.handle_input("Hello world").unwrap(); // No errors + + // Should not be able to backspace to previous word when no errors + let initial_input = game.get_input(); + assert!(!game.handle_backspace(false).unwrap()); + assert_eq!(game.get_input(), initial_input); + } + + #[test] + fn test_ctrl_backspace() { + // Longer than the typed input in each case below - see the comment + // on test_backspace_to_previous_word_with_error for why. + let mut game = Game::new(); + game.set_text("Hello world test today".to_string()); + game.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Ctrl+backspace should delete the current word since there are errors before it + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "Hallo world "); + + // Test with no errors - should not allow ctrl+backspace to cross + // into the previous (already-completed) word. Trailing space puts + // the cursor right at the word boundary rather than mid-word -- + // mid-word, ctrl+backspace deleting the current word in progress is + // always allowed regardless of errors, since that's not "reaching + // back" into anything. + let mut game2 = Game::new(); + game2.set_text("Hello world test today".to_string()); + game2.handle_input("Hello world test ").unwrap(); // No errors + + assert!(!game2.handle_backspace(true).unwrap()); + + // Test going back to previous word with errors + let mut game3 = Game::new(); + game3.set_text("Hello world test today".to_string()); + game3.handle_input("Hallo world test").unwrap(); // "Hallo" has an error + + // Should be able to ctrl+backspace to go back to the word with error + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo world "); + + // Should be able to ctrl+backspace again to go back further, since + // the error in "Hallo" is still uncorrected - this was asserting + // the same "Hallo world " value as the press above it (a copy-paste + // leftover), which would mean this second press did nothing despite + // reporting success. + assert!(game3.handle_backspace(true).unwrap()); + assert_eq!(game3.get_input(), "Hallo "); + } + + #[test] + fn test_game_completion_detection() { + let mut game = Game::new(); + game.set_text("Hello world".to_string()); + + // Should not be finished initially + assert!(!game.is_finished()); + + // Should be finished when text is completed + game.handle_input("Hello world").unwrap(); + assert!(game.is_finished()); + + // Should be finished even with extra spaces (but we can't input more than text length) + let mut game2 = Game::new(); + game2.set_text("Hello world".to_string()); + game2.handle_input("Hello world").unwrap(); + assert!(game2.is_finished()); + } + + #[test] + fn test_error_counting() { + let mut game = Game::new(); + // Longer than every input below - reaching text.len() characters + // finishes the game (see test_game_completion_detection), which + // stops handle_input from processing anything further. + game.set_text("Hello world today".to_string()); + + // Type with errors + game.handle_input("Hallo world").unwrap(); // "Hallo" has an error + println!("After first error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should count the error + + // Correct the error + game.handle_input("Hello world").unwrap(); // Corrected + println!("After correction: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 1); // Should still show 1 error (total made) + + // Make another error by typing extra characters + game.handle_input("Hello worldx").unwrap(); // Extra character + println!("After second error: total_errors_made = {}", game.total_errors_made); + assert_eq!(game.get_total_mistakes(), 2); // Should now show 2 total errors + } + + #[test] + fn test_ctrl_backspace_one_error_word_at_a_time() { + let mut game = Game::new(); + // Longer than the typed input - see test_backspace_to_previous_word_with_error. + game.set_text("foo bar baz qux end".to_string()); + game.handle_input("fao bar bzz qux").unwrap(); // errors in 'fao' and 'bzz' + + // First press deletes 'qux' - the word currently being typed, always + // allowed regardless of errors since it's not "reaching back" into a + // completed word. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar bzz "); + + // Second press deletes 'bzz' - now the current word, same as above. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao bar "); + + // Third press crosses from 'bar' back into 'fao' - allowed because + // 'fao' has an uncorrected error. + assert!(game.handle_backspace(true).unwrap()); + assert_eq!(game.get_input(), "fao "); + } +} diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs new file mode 100644 index 0000000..803ddbd --- /dev/null +++ b/crates/core/src/input.rs @@ -0,0 +1,182 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; +use crate::app::App; + +#[derive(Debug, Clone)] +pub struct Input { + pub content: String, + pub cursor_position: usize, + pub history: Vec<String>, + pub history_index: usize, +} + +impl Input { + pub fn new() -> Self { + Self { + content: String::new(), + cursor_position: 0, + history: Vec::new(), + history_index: 0, + } + } + + pub fn handle_event(&mut self, event: Event) -> bool { + match event { + Event::Key(KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE, + .. + }) => { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + true + } + Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Delete, + .. + }) => { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Left, + .. + }) => { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Right, + .. + }) => { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + true + } + Event::Key(KeyEvent { + code: KeyCode::Home, + .. + }) => { + self.cursor_position = 0; + true + } + Event::Key(KeyEvent { + code: KeyCode::End, + .. + }) => { + self.cursor_position = self.content.len(); + true + } + _ => false, + } + } + + pub fn clear(&mut self) { + if !self.content.is_empty() { + self.history.push(self.content.clone()); + if self.history.len() > 100 { + self.history.remove(0); + } + } + self.content.clear(); + self.cursor_position = 0; + self.history_index = self.history.len(); + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn get_cursor_position(&self) -> usize { + self.cursor_position + } + + pub fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor_position < self.content.len() { + self.cursor_position += 1; + } + } + + pub fn move_cursor_to_start(&mut self) { + self.cursor_position = 0; + } + + pub fn move_cursor_to_end(&mut self) { + self.cursor_position = self.content.len(); + } + + pub fn insert_char(&mut self, c: char) { + if self.cursor_position == self.content.len() { + self.content.push(c); + } else { + self.content.insert(self.cursor_position, c); + } + self.cursor_position += 1; + } + + pub fn delete_char(&mut self) -> bool { + if self.cursor_position < self.content.len() { + self.content.remove(self.cursor_position); + true + } else { + false + } + } + + pub fn backspace(&mut self) -> bool { + if self.cursor_position > 0 { + self.cursor_position -= 1; + self.content.remove(self.cursor_position); + true + } else { + false + } + } +} + +impl Default for Input { + fn default() -> Self { + Self::new() + } +} + +pub struct InputHandler { + pub app: App, + last_tick: Instant, + tick_rate: Duration, +} + +impl InputHandler { + pub fn new(app: App) -> Self { + Self { + app, + last_tick: Instant::now(), + tick_rate: Duration::from_millis(100), + } + } +}
\ No newline at end of file diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..5f06405 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,25 @@ +pub mod config; +pub mod custom_text; +pub mod game; +pub mod stats; +pub mod text; +pub mod types; +pub mod words; + +#[cfg(feature = "tui")] +pub mod app; +#[cfg(feature = "tui")] +pub mod input; +#[cfg(feature = "tui")] +pub mod ui; +#[cfg(feature = "tui")] +pub mod theme; + +#[cfg(feature = "multiplayer")] +pub mod multiplayer; + +#[cfg(feature = "wasm")] +pub mod wasm; + +#[cfg(target_arch = "wasm32")] +pub use wasm::TyperPunkGame;
\ No newline at end of file diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs new file mode 100644 index 0000000..a25a659 --- /dev/null +++ b/crates/core/src/multiplayer.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +// Wire protocol for the multiplayer WebSocket, shared between the server +// (crates/server) and any Rust client that ends up speaking it. The actual +// room/connection logic (registry, matchmaking, idle timeout) lives in +// crates/server - this file only defines the message shapes, so it has no +// tokio/network dependency of its own. + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ClientMessage { + /// `device_type` lets the server enforce a room's device-matching + /// preference (see ServerMessage::Error and the room's device_filter) -- + /// a room created "desktop only" rejects a mobile joiner right here, + /// before they ever appear in a PlayerList. + Join { name: String, device_type: String }, + Ready, + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerInfo { + pub id: String, + pub name: String, + pub ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ServerMessage { + /// Sent once, only to the connection that just joined - the only way a + /// client can tell which entry in PlayerList is itself, since ids are + /// assigned server-side per connection. + Joined { player_id: String }, + PlayerList { players: Vec<PlayerInfo> }, + Countdown { seconds: u32 }, + Start { text: String }, + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 }, + RoomClosed { reason: String }, + Error { message: String }, +} diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs new file mode 100644 index 0000000..fe94468 --- /dev/null +++ b/crates/core/src/stats.rs @@ -0,0 +1,266 @@ +use std::time::{Duration, Instant}; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone)] +pub struct Stats { + start_time: Option<Instant>, + end_time: Option<Instant>, + error_positions: Vec<usize>, + current_streak: usize, + best_streak: usize, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, + // Persistent keystroke-level tracking (CLI): + // counts every typed character (excluding control sequences) and how many were incorrect at time of keypress + keystrokes_total: usize, + keystrokes_incorrect: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedStats { + wpm: f64, + accuracy: f64, + total_chars: usize, + correct_chars: usize, + incorrect_chars: usize, + total_words: usize, + correct_words: usize, + errors: usize, + time_elapsed_secs: u64, + error_positions: Vec<usize>, + current_streak: usize, + best_streak: usize, +} + +impl From<&Stats> for SerializedStats { + fn from(stats: &Stats) -> Self { + let time_elapsed = stats.start_time + .and_then(|start| stats.end_time.map(|end| end.duration_since(start))) + .unwrap_or(Duration::from_secs(0)); + + let wpm = if time_elapsed.as_secs() > 0 { + (stats.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + }; + + let accuracy = if stats.total_chars > 0 { + (stats.correct_chars as f64 / stats.total_chars as f64) * 100.0 + } else { + 0.0 + }; + + Self { + wpm, + accuracy, + total_chars: stats.total_chars, + correct_chars: stats.correct_chars, + incorrect_chars: stats.incorrect_chars, + total_words: stats.total_words, + correct_words: stats.correct_words, + errors: stats.errors, + time_elapsed_secs: time_elapsed.as_secs(), + error_positions: stats.error_positions.clone(), + current_streak: stats.current_streak, + best_streak: stats.best_streak, + } + } +} + +impl Stats { + pub fn new() -> Self { + Self { + start_time: None, + end_time: None, + error_positions: Vec::new(), + current_streak: 0, + best_streak: 0, + total_chars: 0, + correct_chars: 0, + incorrect_chars: 0, + total_words: 0, + correct_words: 0, + errors: 0, + keystrokes_total: 0, + keystrokes_incorrect: 0, + } + } + + pub fn reset(&mut self) { + self.start_time = None; + self.end_time = None; + self.error_positions.clear(); + self.current_streak = 0; + self.best_streak = 0; + self.total_chars = 0; + self.correct_chars = 0; + self.incorrect_chars = 0; + self.total_words = 0; + self.correct_words = 0; + self.errors = 0; + self.keystrokes_total = 0; + self.keystrokes_incorrect = 0; + } + + pub fn start(&mut self) { + self.start_time = Some(Instant::now()); + } + + pub fn update(&mut self, input: &str, target: &str) { + // Recompute everything from scratch for current input + self.error_positions.clear(); + let mut streak = 0; + let mut best_streak_local = 0; + let mut correct_chars = 0usize; + let mut incorrect_chars = 0usize; + let mut total_words = 0usize; + let mut correct_words = 0usize; + + // Tokenize by whitespace to count words + let input_words: Vec<&str> = input.split_whitespace().collect(); + let target_words: Vec<&str> = target.split_whitespace().collect(); + total_words = input_words.len(); + for (iw, tw) in input_words.iter().zip(target_words.iter()) { + if *iw == *tw { correct_words += 1; } + } + + for (i, (input_char, target_char)) in input.chars().zip(target.chars()).enumerate() { + if input_char == target_char { + streak += 1; + correct_chars += 1; + if streak > best_streak_local { best_streak_local = streak; } + } else { + self.error_positions.push(i); + streak = 0; + incorrect_chars += 1; + } + } + + // Extra characters beyond target count as incorrect + if input.len() > target.len() { + incorrect_chars += input.len() - target.len(); + } + + self.total_chars = input.len(); + self.correct_chars = correct_chars; + self.incorrect_chars = incorrect_chars; + self.total_words = total_words; + self.correct_words = correct_words; + self.errors = self.error_positions.len(); + self.current_streak = streak; + self.best_streak = self.best_streak.max(best_streak_local); + } + + // Record a single keypress for persistent accuracy tracking (CLI only). + // If the typed char at the time of keypress was incorrect, mark it as incorrect permanently. + pub fn note_keypress(&mut self, was_correct: bool) { + self.keystrokes_total = self.keystrokes_total.saturating_add(1); + if !was_correct { + self.keystrokes_incorrect = self.keystrokes_incorrect.saturating_add(1); + } + } + + pub fn finish(&mut self) { + self.end_time = Some(Instant::now()); + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.error_positions.clone() + } + + pub fn get_current_streak(&self) -> usize { + self.current_streak + } + + pub fn get_best_streak(&self) -> usize { + self.best_streak + } + + pub fn get_wpm(&self) -> f64 { + let time_elapsed = match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + }; + + if time_elapsed.as_secs_f64() > 0.0 { + (self.correct_chars as f64 / 5.0) / (time_elapsed.as_secs_f64() / 60.0) + } else { + 0.0 + } + } + + pub fn get_accuracy(&self) -> f64 { + // Prefer persistent keystroke accuracy for CLI to avoid resetting to 100% after fixes. + if self.keystrokes_total > 0 { + let correct = (self.keystrokes_total - self.keystrokes_incorrect) as f64; + return (correct / self.keystrokes_total as f64) * 100.0; + } + if self.total_chars > 0 { + (self.correct_chars as f64 / self.total_chars as f64) * 100.0 + } else { 0.0 } + } + + pub fn get_time_elapsed(&self) -> Duration { + match (self.start_time, self.end_time) { + (Some(start), Some(end)) => end.duration_since(start), + (Some(start), None) => Instant::now().duration_since(start), + _ => Duration::from_secs(0), + } + } + + pub fn wpm(&self) -> f64 { self.get_wpm() } + pub fn accuracy(&self) -> f64 { self.get_accuracy() } + pub fn elapsed_time(&self) -> std::time::Duration { self.get_time_elapsed() } + pub fn is_running(&self) -> bool { self.start_time.is_some() && self.end_time.is_none() } + pub fn stop(&mut self) { self.end_time = Some(std::time::Instant::now()); } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.start_time, None); + assert_eq!(stats.end_time, None); + assert_eq!(stats.error_positions, Vec::<usize>::new()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); + } + + #[test] + fn test_stats_update() { + let mut stats = Stats::new(); + stats.start_time = Some(Instant::now()); + stats.update("hello", "hello"); + stats.end_time = Some(Instant::now()); + + // Comparing against a freshly-captured Instant::now() (as this + // asserted before) can never pass - nanosecond-resolution clocks + // don't produce two equal readings taken at different times. What's + // actually being verified is that these got set at all. + assert!(stats.start_time.is_some()); + assert!(stats.end_time.is_some()); + assert_eq!(stats.error_positions, Vec::<usize>::new()); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + } + + #[test] + fn test_stats_word_counting() { + let mut stats = Stats::new(); + stats.total_words = 2; + stats.correct_words = 2; + + assert_eq!(stats.total_words, 2); + assert_eq!(stats.correct_words, 2); + } +}
\ No newline at end of file diff --git a/crates/core/src/tests/game_tests.rs b/crates/core/src/tests/game_tests.rs new file mode 100644 index 0000000..6bc67ed --- /dev/null +++ b/crates/core/src/tests/game_tests.rs @@ -0,0 +1,143 @@ +use crate::game::{Game, GameMode}; +use crate::text::Text; + +#[test] +fn test_game_initialization() { + let game = Game::new(GameMode::Time(60)); + assert_eq!(game.mode(), GameMode::Time(60)); + assert_eq!(game.is_finished(), false); +} + +#[test] +fn test_game_time_mode() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + // Simulate some typing + game.update("Hello"); + assert_eq!(game.is_finished(), false); + + // Simulate time running out + game.update_time(61); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_words_mode() { + let mut game = Game::new(GameMode::Words(10)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + // Simulate typing some words + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + + // Simulate completing all words + game.update("Hello world! This is a test."); + assert_eq!(game.is_finished(), true); +} + +#[test] +fn test_game_reset() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + game.reset(); + + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), ""); +} + +#[test] +fn test_game_mode_transition() { + let mut game = Game::new(GameMode::Time(60)); + game.set_mode(GameMode::Words(10)); + assert_eq!(game.mode(), GameMode::Words(10)); +} + +#[test] +fn test_game_text_update() { + let mut game = Game::new(GameMode::Time(60)); + let text1 = Text::new("First text"); + let text2 = Text::new("Second text"); + + game.set_text(text1); + assert_eq!(game.current_text().content(), "First text"); + + game.set_text(text2); + assert_eq!(game.current_text().content(), "Second text"); +} + +#[test] +fn test_game_partial_completion() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Hello world!"); +} + +#[test] +fn test_game_error_handling() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Helo world!"); + assert_eq!(game.is_finished(), false); + assert_eq!(game.current_input(), "Helo world!"); +} + +#[test] +fn test_game_time_remaining() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update_time(30); + assert_eq!(game.time_remaining(), 30); +} + +#[test] +fn test_game_words_remaining() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.words_remaining(), 3); +} + +#[test] +fn test_game_progress() { + let mut game = Game::new(GameMode::Words(5)); + let text = Text::new("Hello world! This is a test."); + game.set_text(text); + + game.update("Hello world!"); + assert_eq!(game.progress(), 0.4); // 2 out of 5 words completed +} + +#[test] +fn test_game_state_persistence() { + let mut game = Game::new(GameMode::Time(60)); + let text = Text::new("Hello world!"); + game.set_text(text); + + game.update("Hello"); + let input = game.current_input(); + let is_finished = game.is_finished(); + + game.reset(); + assert_eq!(game.current_input(), ""); + assert_eq!(game.is_finished(), false); + + game.update(input); + assert_eq!(game.current_input(), input); + assert_eq!(game.is_finished(), is_finished); +}
\ No newline at end of file diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs new file mode 100644 index 0000000..f39330c --- /dev/null +++ b/crates/core/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod stats_tests; +mod text_tests; +mod game_tests;
\ No newline at end of file diff --git a/crates/core/src/tests/stats_tests.rs b/crates/core/src/tests/stats_tests.rs new file mode 100644 index 0000000..1b210c8 --- /dev/null +++ b/crates/core/src/tests/stats_tests.rs @@ -0,0 +1,125 @@ +use crate::stats::Stats; +use std::time::Duration; + +#[test] +fn test_stats_initialization() { + let stats = Stats::new(); + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_start_stop() { + let mut stats = Stats::new(); + assert!(!stats.is_running()); + + stats.start(); + assert!(stats.is_running()); + + stats.stop(); + assert!(!stats.is_running()); +} + +#[test] +fn test_stats_update() { + let mut stats = Stats::new(); + stats.start(); + + // Test perfect typing + stats.update("hello", "hello"); + assert_eq!(stats.correct_chars, 5); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_chars, 5); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + // Test with errors + stats.update("helo", "hello"); + assert_eq!(stats.correct_chars, 3); + assert_eq!(stats.incorrect_chars, 1); + assert_eq!(stats.total_chars, 4); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_reset() { + let mut stats = Stats::new(); + stats.start(); + stats.update("hello", "hello"); + stats.reset(); + + assert_eq!(stats.wpm, 0.0); + assert_eq!(stats.accuracy, 100.0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.correct_chars, 0); + assert_eq!(stats.incorrect_chars, 0); + assert_eq!(stats.total_words, 0); + assert_eq!(stats.correct_words, 0); + assert_eq!(stats.errors, 0); + assert_eq!(stats.time_elapsed, Duration::from_secs(0)); + assert!(stats.error_positions.is_empty()); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 0); +} + +#[test] +fn test_stats_wpm_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 60 characters (12 words) in 1 minute + stats.update("hello world hello world hello world", "hello world hello world hello world"); + assert_eq!(stats.wpm, 12.0); +} + +#[test] +fn test_stats_accuracy_calculation() { + let mut stats = Stats::new(); + stats.start(); + + // Type 10 characters with 2 errors + stats.update("hello wrld", "hello world"); + assert_eq!(stats.accuracy, 80.0); +} + +#[test] +fn test_stats_streak_tracking() { + let mut stats = Stats::new(); + stats.start(); + + // Test streak building and breaking + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); + + stats.update("helo", "hello"); + assert_eq!(stats.current_streak, 0); + assert_eq!(stats.best_streak, 5); + + stats.update("hello", "hello"); + assert_eq!(stats.current_streak, 5); + assert_eq!(stats.best_streak, 5); +} + +#[test] +fn test_stats_error_positions() { + let mut stats = Stats::new(); + stats.start(); + + stats.update("helo", "hello"); + assert_eq!(stats.error_positions, vec![3]); + + stats.update("hllo", "hello"); + assert_eq!(stats.error_positions, vec![1]); +}
\ No newline at end of file diff --git a/crates/core/src/tests/text_tests.rs b/crates/core/src/tests/text_tests.rs new file mode 100644 index 0000000..2964e2a --- /dev/null +++ b/crates/core/src/tests/text_tests.rs @@ -0,0 +1,83 @@ +use crate::text::Text; + +#[test] +fn test_text_creation() { + let content = "Hello, world!"; + let text = Text::new(content); + assert_eq!(text.content(), content); + assert_eq!(text.words().len(), 2); +} + +#[test] +fn test_text_word_count() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_empty() { + let text = Text::new(""); + assert_eq!(text.content(), ""); + assert_eq!(text.words().len(), 0); +} + +#[test] +fn test_text_with_special_chars() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); + assert_eq!(text.content(), "Hello, world! This is a test..."); +} + +#[test] +fn test_text_word_boundaries() { + let text = Text::new("Hello-world! This_is_a_test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_multiple_spaces() { + let text = Text::new("Hello world! This is a test."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_numbers() { + let text = Text::new("Hello 123 world! 456 test."); + assert_eq!(text.words().len(), 4); +} + +#[test] +fn test_text_with_punctuation() { + let text = Text::new("Hello, world! This is a test..."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_mixed_case() { + let text = Text::new("Hello WORLD! This IS a TEST."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_unicode() { + let text = Text::new("Hello 世界! This is a テスト."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_emojis() { + let text = Text::new("Hello 👋 world! This is a test 🎯."); + assert_eq!(text.words().len(), 7); +} + +#[test] +fn test_text_with_tabs() { + let text = Text::new("Hello\tworld!\tThis\tis\ta\ttest."); + assert_eq!(text.words().len(), 6); +} + +#[test] +fn test_text_with_newlines() { + let text = Text::new("Hello\nworld!\nThis\nis\na\ntest."); + assert_eq!(text.words().len(), 6); +}
\ No newline at end of file diff --git a/crates/core/src/text.rs b/crates/core/src/text.rs new file mode 100644 index 0000000..0e0e248 --- /dev/null +++ b/crates/core/src/text.rs @@ -0,0 +1,72 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub struct Text { + pub content: String, + pub source: String, + pub language: String, + pub category: String, +} + +impl Text { + pub fn new() -> Self { + Self { + content: String::new(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str(content: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_source(content: &str, source: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: String::new(), + category: String::new(), + } + } + + pub fn from_str_with_language(content: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: String::new(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_str_with_source_and_language(content: &str, source: &str, language: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: String::new(), + } + } + + pub fn from_all(content: &str, source: &str, language: &str, category: &str) -> Self { + Self { + content: content.to_string(), + source: source.to_string(), + language: language.to_string(), + category: category.to_string(), + } + } +} + +impl fmt::Display for Text { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.content) + } +} +
\ No newline at end of file diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..8ebad89 --- /dev/null +++ b/crates/core/src/theme.rs @@ -0,0 +1,24 @@ +#[cfg(feature = "tui")] +use ratatui::style::Color; + +#[cfg(feature = "tui")] +pub struct Theme { + pub background: Color, + pub foreground: Color, + pub accent: Color, + pub error: Color, + pub success: Color, +} + +#[cfg(feature = "tui")] +impl Default for Theme { + fn default() -> Self { + Self { + background: Color::Black, + foreground: Color::White, + accent: Color::Cyan, + error: Color::Red, + success: Color::Green, + } + } +}
\ No newline at end of file diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs new file mode 100644 index 0000000..dc57c9c --- /dev/null +++ b/crates/core/src/types.rs @@ -0,0 +1,148 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Platform { + Desktop, + Web, + Mobile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GameMode { + Normal, + Programming, + Security, + Multiplayer, + Zen, + Time(u64), + Words(usize), + Quote, +} + +impl Default for GameMode { + fn default() -> Self { + GameMode::Normal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Difficulty { + Basic, + Intermediate, + Advanced, + Easy, + Medium, + Hard, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Topic { + General, + Programming, + Security, + DataStructures, + Algorithms, + RedTeam, + BlueTeam, + Gaming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameConfig { + pub mode: GameMode, + pub difficulty: Difficulty, + pub topic: Topic, + pub time_limit: Option<u64>, + pub word_count: Option<usize>, + pub custom_text: Option<String>, + pub multiplayer: bool, + pub quote_length: usize, + pub theme: Theme, +} + +impl Default for GameConfig { + fn default() -> Self { + Self { + mode: GameMode::default(), + difficulty: Difficulty::Basic, + topic: Topic::General, + time_limit: None, + word_count: None, + custom_text: None, + multiplayer: false, + quote_length: 1, + theme: Theme::default(), + } + } +} + +pub trait GameModeTrait { + fn get_mode(&self) -> GameMode; + fn get_difficulty(&self) -> Difficulty; + fn get_topic(&self) -> Topic; + fn get_time_limit(&self) -> Option<u64>; + fn get_word_count(&self) -> Option<usize>; + fn get_custom_text(&self) -> Option<&str>; + fn is_multiplayer(&self) -> bool; + fn get_quote_length(&self) -> usize; + fn get_theme(&self) -> &Theme; +} + +impl GameModeTrait for GameConfig { + fn get_mode(&self) -> GameMode { + self.mode + } + + fn get_difficulty(&self) -> Difficulty { + self.difficulty + } + + fn get_topic(&self) -> Topic { + self.topic + } + + fn get_time_limit(&self) -> Option<u64> { + self.time_limit + } + + fn get_word_count(&self) -> Option<usize> { + self.word_count + } + + fn get_custom_text(&self) -> Option<&str> { + self.custom_text.as_deref() + } + + fn is_multiplayer(&self) -> bool { + self.multiplayer + } + + fn get_quote_length(&self) -> usize { + self.quote_length + } + + fn get_theme(&self) -> &Theme { + &self.theme + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Dark + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Theme::Light => write!(f, "light"), + Theme::Dark => write!(f, "dark"), + } + } +}
\ No newline at end of file diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs new file mode 100644 index 0000000..7acf927 --- /dev/null +++ b/crates/core/src/ui.rs @@ -0,0 +1,729 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Paragraph, Wrap}, + Frame, +}; +use ratatui::prelude::{Alignment, Line}; + +use crate::app::{App, LoginField, State}; +use crate::custom_text::{highlight_classes, SynClass}; + +pub fn draw(f: &mut Frame, app: &App) { + match app.state { + State::MainMenu => draw_main_menu(f, app), + State::CustomTextPrompt => draw_custom_text_prompt(f, app), + State::TypingGame => draw_typing_game(f, app), + State::PassiveMode => draw_passive_mode(f, app), + State::EndScreen => draw_end_screen(f, app), + State::Login => draw_login(f, app), + State::Leaderboard => draw_leaderboard(f, app), + State::Friends => draw_friends(f, app), + State::MultiplayerLobby => draw_multiplayer_lobby(f, app), + State::MultiplayerRace => draw_multiplayer_race(f, app), + State::MultiplayerResults => draw_multiplayer_results(f, app), + } +} + +pub fn draw_main_menu(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mode_label = if app.custom_mode_selected { + match &app.custom_text { + Some(ct) => format!("Mode: Custom ({}, {} segment{})", ct.name, ct.chunks.len(), if ct.chunks.len() == 1 { "" } else { "s" }), + None => "Mode: Custom (not loaded)".to_string(), + } + } else if app.words_mode_selected { + format!("Mode: Words ({})", app.word_count) + } else if app.time_mode_selected { + format!("Mode: Time ({}s)", app.time_duration) + } else { + let cat = app.selected_category.as_deref().unwrap_or("Random"); + format!("Mode: {}", cat) + }; + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "TYPERPUNK", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled(mode_label, Style::default().fg(Color::Cyan))), + Line::from(Span::from("")), + Line::from(Span::styled("Start: Enter", Style::default())), + Line::from(Span::styled("Change Mode: \u{2190} / \u{2192}", Style::default())), + Line::from(Span::styled("Load Custom Text: c", Style::default())), + ]; + + if app.custom_mode_selected { + if let Some(ct) = &app.custom_text { + if ct.timed { + lines.push(Line::from(Span::styled("Passive Mode: p", Style::default()))); + } + lines.push(Line::from(Span::styled("Clear Custom Text: x", Style::default()))); + } + } + if app.words_mode_selected { + lines.push(Line::from(Span::styled("Word Count: \u{2191} / \u{2193}", Style::default()))); + } + if app.time_mode_selected { + lines.push(Line::from(Span::styled("Time Duration: \u{2191} / \u{2193}", Style::default()))); + } + lines.push(Line::from(Span::from(""))); + let account_label = match &app.logged_in_username { + Some(name) => format!("Account: {} (a)", name), + None => "Sign In: a".to_string(), + }; + lines.push(Line::from(Span::styled(account_label, Style::default()))); + lines.push(Line::from(Span::styled("Leaderboard: l Friends: f Multiplayer: m", Style::default()))); + lines.push(Line::from(Span::styled("Quit: Esc", Style::default()))); + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()), + chunks[0], + ); +} + +pub fn draw_login(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "ACCOUNT", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if let Some(name) = &app.logged_in_username { + lines.push(Line::from(Span::styled(format!("Signed in as {}", name), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Esc: Back", Style::default()))); + } else { + let mode_label = if app.login_register_mode { "Register" } else { "Log In" }; + lines.push(Line::from(Span::styled(format!("Mode: {} (Ctrl+R to switch)", mode_label), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + + let user_style = if app.login_field == LoginField::Username { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let pass_style = if app.login_field == LoginField::Password { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + lines.push(Line::from(Span::styled(format!("Username: {}", app.login_username), user_style))); + lines.push(Line::from(Span::styled(format!("Password: {}", "*".repeat(app.login_password.chars().count())), pass_style))); + lines.push(Line::from(Span::from(""))); + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Tab: switch field Enter: submit Esc: Cancel", Style::default()))); + } + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_leaderboard(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + format!("LEADERBOARD ({})", app.leaderboard_mode), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.net_busy { + lines.push(Line::from(Span::styled("Loading...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } else if app.leaderboard_entries.is_empty() { + lines.push(Line::from(Span::styled("No results yet for this mode.", Style::default().fg(Color::DarkGray)))); + } else { + for (i, row) in app.leaderboard_entries.iter().enumerate() { + let device = if row.device_type == "mobile" { " [mobile]" } else { "" }; + lines.push(Line::from(Span::styled( + format!("#{:<3} {:<20} {:>6.1} wpm {:>5.1}%{}", i + 1, row.username, row.wpm, row.accuracy, device), + Style::default(), + ))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("r: refresh Esc: Back", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_friends(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "FRIENDS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.logged_in_username.is_none() { + lines.push(Line::from(Span::styled("Sign in first - press a from the main menu.", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Esc: Back", Style::default()))); + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); + return; + } + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.net_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + + if !app.friends_incoming.is_empty() { + lines.push(Line::from(Span::styled("Requests (y: accept, d: decline):", Style::default().fg(Color::Cyan)))); + for (i, row) in app.friends_incoming.iter().enumerate() { + let marker = if i == app.friends_selected { ">" } else { " " }; + lines.push(Line::from(Span::from(format!("{} {}", marker, row.username)))); + } + lines.push(Line::from(Span::from(""))); + } + + if !app.friends_outgoing.is_empty() { + lines.push(Line::from(Span::styled("Sent:", Style::default().fg(Color::Cyan)))); + for row in &app.friends_outgoing { + lines.push(Line::from(Span::from(format!(" {} (pending)", row.username)))); + } + lines.push(Line::from(Span::from(""))); + } + + lines.push(Line::from(Span::styled("Friends:", Style::default().fg(Color::Cyan)))); + if app.friends_list.is_empty() { + lines.push(Line::from(Span::styled(" No friends yet.", Style::default().fg(Color::DarkGray)))); + } else { + for row in &app.friends_list { + lines.push(Line::from(Span::from(format!(" {}", row.username)))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled(format!("Add: {}", app.friends_add_input), Style::default().fg(Color::Yellow)))); + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Type a username, Enter: send request Esc: Back", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Left).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_custom_text_prompt(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "LOAD CUSTOM TEXT", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + "Enter a path to a .txt, .md, code file, .srt/.vtt, or .lrc file:", + Style::default().fg(Color::Gray), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + format!("> {}", app.path_input), + Style::default().fg(Color::Cyan), + )), + Line::from(Span::from("")), + ]; + + if let Some(err) = &app.path_error { + lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red)))); + lines.push(Line::from(Span::from(""))); + } + + lines.push(Line::from(Span::styled("Enter: Load Esc: Cancel", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +// Shared between draw_typing_game and draw_multiplayer_race - both render +// the identical character-by-character correctness coloring against +// app.current_text()/app.input, differing only in surrounding layout (a +// player-progress header above the text in the multiplayer case). +fn build_colored_text(app: &App) -> Vec<Span<'static>> { + let current_text = app.current_text(); + let text_chars: Vec<char> = current_text.content.chars().collect(); + let input_chars: Vec<char> = app.input.chars().collect(); + let language = if current_text.language.is_empty() { None } else { Some(current_text.language.as_str()) }; + let syntax = highlight_classes(¤t_text.content, language); + let mut colored_text: Vec<Span> = Vec::new(); + let cursor_pos = app.input.len(); + + // Long buffers (words/time mode) can run to hundreds of words -- + // centering the whole thing in one wrapped Line would either overflow + // the screen or collide with the anchored stats row at the bottom. + // Show a bounded window around the cursor instead, so the view scrolls + // forward as the typist progresses. This is a no-op for short texts + // (quotes, custom chunks), since the window is wider than they are. + // CHARS_AFTER comfortably covers Words mode's largest buffer (100 words, + // ~585 chars measured) so that mode still shows its whole text upfront + // exactly as before; only Time mode's much longer buffers actually get + // windowed down. + const CHARS_BEFORE: usize = 150; + const CHARS_AFTER: usize = 620; + let win_start = cursor_pos.saturating_sub(CHARS_BEFORE); + let win_end = (cursor_pos + CHARS_AFTER).min(text_chars.len()); + + for (i, &c) in text_chars.iter().enumerate().skip(win_start).take(win_end.saturating_sub(win_start)) { + let style = if i < input_chars.len() { + if input_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + match syntax.get(i).copied().flatten() { + Some(SynClass::Keyword) => Style::default().fg(Color::Magenta), + Some(SynClass::StringLit) => Style::default().fg(Color::Yellow), + Some(SynClass::Comment) => Style::default().fg(Color::DarkGray), + Some(SynClass::Number) => Style::default().fg(Color::Blue), + None => Style::default().fg(Color::Gray), + } + }; + let span = if i == cursor_pos { + Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED)) + } else { + Span::styled(c.to_string(), style) + }; + colored_text.push(span); + } + + if input_chars.len() > text_chars.len() { + for &c in &input_chars[text_chars.len()..] { + colored_text.push(Span::styled(c.to_string(), Style::default().fg(Color::Red))); + } + } + colored_text +} + +pub fn draw_typing_game(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let colored_text = build_colored_text(app); + + let mut lines = Vec::new(); + if app.using_custom { + if let Some(ct) = &app.custom_text { + lines.push(Line::from(Span::styled( + format!("{} \u{b7} segment {}/{}", ct.name, app.custom_index + 1, ct.chunks.len()), + Style::default().fg(Color::DarkGray), + ))); + } + } + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(colored_text)); + + f.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()) + .wrap(Wrap { trim: true }), + chunks[0], + ); + + // Attribution under text + if !app.current_text().source.is_empty() { + let att_area = ratatui::layout::Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(chunks[0].height.saturating_sub(5)), + width: chunks[0].width, + height: 2, + }; + let attribution_line = Line::from(Span::styled( + format!("- {}", app.current_text().source), + Style::default().fg(Color::Gray), + )); + f.render_widget( + Paragraph::new(vec![attribution_line]) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + att_area, + ); + } + + // Anchored stats: WPM (left), ACC (right), TIME (bottom center) + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(2), width: 20, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + // Time mode counts down to zero instead of counting up, since the test + // ends on the clock rather than at the end of the text. + let time_value = if app.time_mode_selected { + (app.time_duration as f64 - app.stats.elapsed_time().as_secs_f64()).max(0.0) + } else { + app.stats.elapsed_time().as_secs_f64() + }; + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", time_value), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let area = f.size(); + // We don't render a central RESULTS section to avoid duplication. + // We only render anchored stats and bottom buttons. + + // Anchored stats at the edges + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(5), width: 20, height: 2 }; + let buttons_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 20, y: area.y + area.height.saturating_sub(2), width: 40, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + let buttons = Paragraph::new(vec![ + Line::from(Span::styled("Enter / Tab: Play Again", Style::default())), + Line::from(Span::styled("Esc: Main Menu", Style::default())), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); + f.render_widget(buttons, buttons_rect); +} + +fn colorize_against(target: &str, typed: &str) -> Vec<Span<'static>> { + let target_chars: Vec<char> = target.chars().collect(); + let typed_chars: Vec<char> = typed.chars().collect(); + let mut spans = Vec::new(); + for (i, &c) in target_chars.iter().enumerate() { + let style = if i < typed_chars.len() { + if typed_chars[i] == c { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + } + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(c.to_string(), style)); + } + spans +} + +pub fn draw_passive_mode(f: &mut Frame, app: &App) { + let area = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + let Some(ct) = &app.custom_text else { + f.render_widget( + Paragraph::new("No custom text loaded.").alignment(Alignment::Center), + chunks[0], + ); + return; + }; + + let prev = app.passive_active_index.checked_sub(1).and_then(|i| ct.chunks.get(i)); + let active = ct.chunks.get(app.passive_active_index); + let next = ct.chunks.get(app.passive_active_index + 1); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + format!("{} \u{b7} passive mode", ct.name), + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::from("")), + ]; + + if let Some(p) = prev { + lines.push(Line::from(Span::styled(p.content.clone(), Style::default().fg(Color::DarkGray)))); + } + if let Some(a) = active { + lines.push(Line::from(colorize_against(&a.content, &app.passive_typed))); + } + if let Some(n) = next { + lines.push(Line::from(Span::styled(n.content.clone(), Style::default().fg(Color::DarkGray)))); + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled( + format!("Accuracy: {:.0}%", app.passive_accuracy()), + Style::default().fg(Color::Green), + ))); + + if app.passive_done { + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled( + "Session complete", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + format!("{} lines \u{b7} {:.0}% accuracy", ct.chunks.len(), app.passive_accuracy()), + Style::default().fg(Color::Gray), + ))); + lines.push(Line::from(Span::styled("Enter / Esc: Back to Menu", Style::default()))); + } else { + lines.push(Line::from(Span::styled("Esc: Back to Menu", Style::default().fg(Color::Gray)))); + } + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).wrap(Wrap { trim: true }), + chunks[0], + ); +} + +pub fn draw_multiplayer_lobby(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "MULTIPLAYER", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if let Some(seconds) = app.mp_countdown { + lines.push(Line::from(Span::styled(format!("Starting in {seconds}..."), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::from(""))); + } + + if let Some(code) = &app.mp_own_room_code { + lines.push(Line::from(Span::styled(format!("Room code: {code}"), Style::default().fg(Color::Cyan)))); + lines.push(Line::from(Span::styled("Share this code so someone else can join.", Style::default().fg(Color::DarkGray)))); + lines.push(Line::from(Span::from(""))); + } + + if app.mp_local_player_id.is_none() { + lines.push(Line::from(Span::styled(format!("Join code: {}", app.mp_room_code_input), Style::default().fg(Color::Yellow)))); + lines.push(Line::from(Span::from(""))); + } + + if app.net_busy { + lines.push(Line::from(Span::styled("Working...", Style::default().fg(Color::DarkGray)))); + } else if let Some(status) = &app.mp_status { + lines.push(Line::from(Span::styled(status.clone(), Style::default().fg(Color::Red)))); + } + + if !app.mp_players.is_empty() { + lines.push(Line::from(Span::styled("Players:", Style::default().fg(Color::Cyan)))); + for p in &app.mp_players { + let is_me = app.mp_local_player_id.as_deref() == Some(p.id.as_str()); + let label = if is_me { format!("{} (you)", p.name) } else { p.name.clone() }; + let ready = if p.ready { "ready" } else { "not ready" }; + lines.push(Line::from(Span::from(format!(" {label} - {ready}")))); + } + lines.push(Line::from(Span::from(""))); + } + + let hint = if app.mp_local_player_id.is_some() { + "r: toggle ready Esc: Leave" + } else if app.mp_own_room_code.is_none() { + "c: Create room Type a code, Enter: Join Esc: Back" + } else { + "Waiting for someone to join with the code above... Esc: Back" + }; + lines.push(Line::from(Span::styled(hint, Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} + +pub fn draw_multiplayer_race(f: &mut Frame, app: &App) { + let area = f.size(); + let header_height = (app.mp_players.len() as u16).max(1) + 2; + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Length(header_height), Constraint::Min(0)]) + .split(area); + + let mut header_lines = vec![Line::from(Span::styled( + "RACE IN PROGRESS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ))]; + const BAR_WIDTH: usize = 24; + for p in &app.mp_players { + let is_me = app.mp_local_player_id.as_deref() == Some(p.id.as_str()); + let filled = ((p.progress / 100.0 * BAR_WIDTH as f32).round() as usize).min(BAR_WIDTH); + let bar: String = "#".repeat(filled) + &".".repeat(BAR_WIDTH - filled); + let label = if is_me { format!("{} (you)", p.name) } else { p.name.clone() }; + let color = if is_me { Color::Green } else { Color::Cyan }; + header_lines.push(Line::from(Span::styled( + format!("{:<16} [{bar}] {:>3.0}% {:>3.0}wpm", label, p.progress, p.wpm), + Style::default().fg(color), + ))); + } + f.render_widget(Paragraph::new(header_lines).alignment(Alignment::Center), chunks[0]); + + let colored_text = build_colored_text(app); + f.render_widget( + Paragraph::new(vec![Line::from(Span::from("")), Line::from(colored_text)]) + .alignment(Alignment::Center) + .block(Block::default()) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled(format!("{:.0}", app.stats.wpm()), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))), + ]).alignment(Alignment::Left); + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled(format!("{:.0}%", app.stats.accuracy()), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))), + ]).alignment(Alignment::Right); + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); +} + +pub fn draw_multiplayer_results(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(f.size()); + + let mut lines: Vec<Line> = vec![ + Line::from(Span::styled( + "RESULTS", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + ]; + + if app.mp_results.is_empty() { + lines.push(Line::from(Span::styled("Waiting for results...", Style::default().fg(Color::DarkGray)))); + } else { + for (_id, name, wpm, place) in &app.mp_results { + lines.push(Line::from(Span::styled( + format!("#{place} {name:<16} {wpm:>5.0} wpm"), + Style::default().fg(Color::Cyan), + ))); + } + } + + lines.push(Line::from(Span::from(""))); + lines.push(Line::from(Span::styled("Enter / Esc: Back to Menu", Style::default()))); + + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block(Block::default()), + chunks[0], + ); +} diff --git a/crates/core/src/wasm.rs b/crates/core/src/wasm.rs new file mode 100644 index 0000000..c71072e --- /dev/null +++ b/crates/core/src/wasm.rs @@ -0,0 +1,98 @@ +use crate::game::Game; +use crate::types::Theme; + +pub struct TyperPunkGame { + pub game: Game, +} + +impl TyperPunkGame { + pub fn new() -> Self { + Self { + game: Game::new(), + } + } + + pub fn set_text(&mut self, text: String) { + self.game.set_text(text); + } + + pub fn get_text(&self) -> String { + self.game.get_text() + } + + pub fn get_input(&self) -> String { + self.game.get_input().to_string() + } + + pub fn start(&mut self) { + self.game.start(); + } + + pub fn handle_input(&mut self, input: &str) -> Result<(), String> { + self.game.handle_input(input) + } + + pub fn is_finished(&self) -> bool { + self.game.is_finished() + } + + pub fn get_error_positions(&self) -> Vec<usize> { + self.game.get_error_positions() + } + + pub fn get_current_streak(&self) -> u32 { + self.game.get_current_streak() + } + + pub fn get_best_streak(&self) -> u32 { + self.game.get_best_streak() + } + + pub fn get_theme(&self) -> String { + self.game.get_theme().to_string() + } + + pub fn set_theme(&mut self, theme: String) { + let theme = match theme.as_str() { + "light" => Theme::Light, + _ => Theme::Dark, + }; + self.game.set_theme(theme); + } + + pub fn get_wpm(&self) -> f64 { + self.game.get_wpm() + } + + pub fn get_accuracy(&self) -> f64 { + self.game.get_accuracy() + } + + pub fn get_time_elapsed(&self) -> f64 { + self.game.get_time_elapsed() + } + + pub fn can_backspace(&self) -> bool { + self.game.can_backspace() + } + + pub fn can_ctrl_backspace(&self) -> bool { + self.game.can_ctrl_backspace() + } + + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, String> { + self.game.handle_backspace(ctrl) + } + + pub fn get_total_mistakes(&self) -> u32 { + self.game.get_total_mistakes() + } + + pub fn get_stats(&self) -> Result<(f64, u32), String> { + self.game.get_stats() + } + + pub fn get_stats_and_input(&self) -> Result<(String, f64, u32), String> { + self.game.get_stats_and_input() + } +}
\ No newline at end of file diff --git a/crates/core/src/words.rs b/crates/core/src/words.rs new file mode 100644 index 0000000..3c101c5 --- /dev/null +++ b/crates/core/src/words.rs @@ -0,0 +1,81 @@ +use rand::Rng; + +const COMMON_WORDS: &[&str] = &[ + "the", "be", "to", "of", "and", "a", "in", "that", "have", "it", + "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", + "but", "his", "by", "from", "they", "we", "say", "her", "she", "or", + "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", + "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", + "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", + "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", + "then", "now", "look", "only", "come", "its", "over", "think", "also", "back", + "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", + "new", "want", "because", "any", "these", "give", "day", "most", "us", "water", + "long", "find", "here", "thing", "place", "hand", "part", "child", "eye", "life", + "world", "school", "state", "family", "student", "group", "country", "problem", "fact", "month", + "right", "study", "book", "word", "business", "issue", "side", "kind", "head", "house", + "service", "friend", "father", "power", "hour", "game", "line", "end", "member", "law", + "car", "city", "community", "name", "president", "team", "minute", "idea", "body", "information", + "parent", "face", "others", "level", "office", "door", "health", "person", "art", "war", + "history", "party", "result", "change", "morning", "reason", "research", "girl", "guy", "moment", + "air", "teacher", "force", "education", "foot", "boy", "age", "policy", "process", "music", + "market", "sense", "nation", "plan", "college", "interest", "death", "experience", "effect", "model", +]; + +/// Builds a random passage of the given word count: a flat stream of common +/// words with no fixed meaning, used for count-based practice instead of a +/// memorized quote. `numbers` occasionally swaps a word for a random number; +/// `punctuation` breaks the stream into capitalized, comma- and +/// period-punctuated "sentences" - mirrors web/src/wordGenerator.js. +pub fn generate_words(count: usize, punctuation: bool, numbers: bool) -> String { + let mut rng = rand::thread_rng(); + let mut words: Vec<String> = Vec::with_capacity(count); + let mut since_comma = 0i32; + let mut since_sentence_start = 0i32; + + for i in 0..count { + let mut word = if numbers && rng.gen_bool(0.12) { + rng.gen_range(0..1000).to_string() + } else { + COMMON_WORDS[rng.gen_range(0..COMMON_WORDS.len())].to_string() + }; + + if punctuation { + if since_sentence_start == 0 { + word = capitalize(&word); + } + since_comma += 1; + since_sentence_start += 1; + + let at_end = i == count - 1; + if at_end { + word.push('.'); + } else if since_sentence_start >= 6 && rng.gen_bool(0.15) { + word.push('.'); + since_sentence_start = 0; + } else if since_comma >= 4 && rng.gen_bool(0.2) { + word.push(','); + since_comma = 0; + } + } + + words.push(word); + } + words.join(" ") +} + +fn capitalize(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + None => String::new(), + } +} + +/// For time-based mode the buffer has to outlast the timer regardless of how +/// fast the typist is. 6 words/sec (360 WPM) is well past the sustained +/// world-record typing speed (~216 WPM), so this is generous headroom rather +/// than a tight fit. +pub fn word_count_for_duration(seconds: u64) -> usize { + ((seconds * 6) as usize).max(20) +} diff --git a/crates/server/.env.example b/crates/server/.env.example new file mode 100644 index 0000000..6dabec5 --- /dev/null +++ b/crates/server/.env.example @@ -0,0 +1,17 @@ +DATABASE_URL=sqlite://typerpunk.db +PORT=8787 +FRONTEND_ORIGIN=http://localhost:4173 + +# Shared dataset multiplayer rooms draw race passages from - defaults to +# texts.json in the process's working directory (the repo root, normally). +TEXTS_JSON_PATH=texts.json + +# Set to 1 behind TLS in production. Left off for local HTTP dev, where a +# Secure cookie would silently never be set by the browser. +COOKIE_SECURE=0 + +# Required for the Lyrics mode's "Connect Spotify" flow (phase 5). Register +# an app at https://developer.spotify.com/dashboard to get these - this app +# never asks end users for their own API keys, only its own. +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..d0292fc --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "typerpunk-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk-server" +path = "src/main.rs" + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["multiplayer", "serde"] } + +tokio.workspace = true +axum.workspace = true +axum-extra.workspace = true +tower.workspace = true +tower-http.workspace = true +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +thiserror.workspace = true +argon2.workspace = true +uuid.workspace = true +time.workspace = true +dashmap.workspace = true +dotenvy.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +rand.workspace = true +futures-util.workspace = true +reqwest.workspace = true + +[dev-dependencies] +reqwest = { workspace = true, features = ["cookies"] } diff --git a/crates/server/migrations/0001_users_sessions.sql b/crates/server/migrations/0001_users_sessions.sql new file mode 100644 index 0000000..1fe3cee --- /dev/null +++ b/crates/server/migrations/0001_users_sessions.sql @@ -0,0 +1,14 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); diff --git a/crates/server/migrations/0002_test_results.sql b/crates/server/migrations/0002_test_results.sql new file mode 100644 index 0000000..aa876b5 --- /dev/null +++ b/crates/server/migrations/0002_test_results.sql @@ -0,0 +1,13 @@ +CREATE TABLE test_results ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mode_key TEXT NOT NULL, + wpm REAL NOT NULL, + raw_wpm REAL NOT NULL, + accuracy REAL NOT NULL, + time_seconds REAL NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_test_results_user_id ON test_results(user_id); +CREATE INDEX idx_test_results_mode_key ON test_results(mode_key); diff --git a/crates/server/migrations/0003_friendships.sql b/crates/server/migrations/0003_friendships.sql new file mode 100644 index 0000000..c44e62a --- /dev/null +++ b/crates/server/migrations/0003_friendships.sql @@ -0,0 +1,11 @@ +CREATE TABLE friendships ( + id TEXT PRIMARY KEY, + requester_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted')), + created_at TEXT NOT NULL, + UNIQUE (requester_id, addressee_id) +); + +CREATE INDEX idx_friendships_requester ON friendships(requester_id); +CREATE INDEX idx_friendships_addressee ON friendships(addressee_id); diff --git a/crates/server/migrations/0004_spotify_tokens.sql b/crates/server/migrations/0004_spotify_tokens.sql new file mode 100644 index 0000000..0129660 --- /dev/null +++ b/crates/server/migrations/0004_spotify_tokens.sql @@ -0,0 +1,6 @@ +CREATE TABLE spotify_tokens ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at TEXT NOT NULL +); diff --git a/crates/server/migrations/0005_fairness.sql b/crates/server/migrations/0005_fairness.sql new file mode 100644 index 0000000..28152d6 --- /dev/null +++ b/crates/server/migrations/0005_fairness.sql @@ -0,0 +1,2 @@ +ALTER TABLE test_results ADD COLUMN device_type TEXT NOT NULL DEFAULT 'desktop'; +ALTER TABLE test_results ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0; diff --git a/crates/server/migrations/0006_cosmetics.sql b/crates/server/migrations/0006_cosmetics.sql new file mode 100644 index 0000000..a795e0b --- /dev/null +++ b/crates/server/migrations/0006_cosmetics.sql @@ -0,0 +1,33 @@ +- Scoped to 'caret' and 'flair' only, not 'theme' - the app already has 7 +- free built-in themes (see web/src/theme.js), and selling some themes +- while others stay free in the same picker would be a confusing mix of +- paid and unpaid options in one list. Caret color and a small username +- flair badge are both genuinely new, non-overlapping cosmetic slots. +CREATE TABLE cosmetics ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('caret', 'flair')), + price_cents INTEGER NOT NULL, + - Caret: a CSS color. Flair: a short glyph/emoji shown next to a + - username on the leaderboard and public profile. + value TEXT NOT NULL +); + +CREATE TABLE user_cosmetics ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE, + acquired_at TEXT NOT NULL, + PRIMARY KEY (user_id, cosmetic_id) +); + +ALTER TABLE users ADD COLUMN equipped_caret TEXT; +ALTER TABLE users ADD COLUMN equipped_flair TEXT; + +- A small seed catalog so the store isn't empty on a fresh install. +INSERT INTO cosmetics (id, name, category, price_cents, value) VALUES + ('caret-magenta', 'Magenta Caret', 'caret', 199, '#ff2fb0'), + ('caret-amber', 'Amber Caret', 'caret', 199, '#ffb703'), + ('caret-cyan', 'Cyan Caret', 'caret', 199, '#00e5ff'), + ('flair-star', 'Star', 'flair', 149, 'star'), + ('flair-bolt', 'Bolt', 'flair', 149, 'bolt'), + ('flair-skull', 'Skull', 'flair', 149, 'skull'); diff --git a/crates/server/migrations/0007_api_tokens.sql b/crates/server/migrations/0007_api_tokens.sql new file mode 100644 index 0000000..61b6697 --- /dev/null +++ b/crates/server/migrations/0007_api_tokens.sql @@ -0,0 +1,8 @@ +- Long-lived personal-access tokens for CLI-style clients (the TUI) that +- have no cookie jar. Separate from the browser's session cookie so +- revoking one doesn't touch the other. +CREATE TABLE api_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL +); diff --git a/crates/server/src/anticheat.rs b/crates/server/src/anticheat.rs new file mode 100644 index 0000000..44887f5 --- /dev/null +++ b/crates/server/src/anticheat.rs @@ -0,0 +1,81 @@ +// Heuristic, not proof - this flags a result for exclusion from the public +// leaderboard, it never rejects a submission outright. A false positive +// against a genuine elite typist costs them a leaderboard slot until +// reviewed; a false negative just means a bot's score sits on a personal +// profile nobody but that account holder sees ranked. Rejecting outright +// would make the first kind of mistake much more costly for no real gain +// against the second. + +/// Above the sustained world record (~216 WPM) but generous enough that a +/// genuine top-tier human run doesn't get flagged just for being very fast. +const WPM_CEILING: f64 = 220.0; + +/// Below this many samples, a coefficient-of-variation read is too noisy to +/// act on - a handful of keystrokes can look "uniform" by chance alone. +const MIN_KEYSTROKE_SAMPLES: usize = 20; + +/// Real human inter-keystroke timing varies quite a bit even at a steady +/// pace; a script replaying fixed or near-fixed delays reads as unusually +/// uniform by comparison. This threshold is deliberately loose - it's +/// meant to catch obviously mechanical timing, not to be a precise +/// biometric classifier. +const MIN_PLAUSIBLE_CV: f64 = 0.15; + +pub fn should_flag(wpm: f64, keystroke_intervals_ms: &Option<Vec<f64>>) -> bool { + if wpm > WPM_CEILING { + return true; + } + + if let Some(intervals) = keystroke_intervals_ms { + if intervals.len() >= MIN_KEYSTROKE_SAMPLES { + let mean = intervals.iter().sum::<f64>() / intervals.len() as f64; + if mean > 0.0 { + let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / intervals.len() as f64; + let cv = variance.sqrt() / mean; + if cv < MIN_PLAUSIBLE_CV { + return true; + } + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_impossible_wpm() { + assert!(should_flag(300.0, &None)); + } + + #[test] + fn does_not_flag_ordinary_human_run() { + assert!(!should_flag(85.0, &None)); + } + + #[test] + fn flags_suspiciously_uniform_timing() { + let intervals: Vec<f64> = (0..30).map(|_| 120.0).collect(); + assert!(should_flag(90.0, &Some(intervals))); + } + + #[test] + fn does_not_flag_naturally_varied_timing() { + // Realistic-looking human variation around a 120ms average. + let intervals = vec![ + 95.0, 140.0, 110.0, 160.0, 100.0, 130.0, 90.0, 150.0, 120.0, 105.0, 135.0, 115.0, 145.0, 95.0, + 125.0, 110.0, 155.0, 100.0, 130.0, 120.0, 90.0, 140.0, 105.0, 160.0, 115.0, 135.0, 95.0, 150.0, + 120.0, 110.0, + ]; + assert!(!should_flag(90.0, &Some(intervals))); + } + + #[test] + fn ignores_too_few_samples() { + let intervals: Vec<f64> = (0..5).map(|_| 120.0).collect(); + assert!(!should_flag(90.0, &Some(intervals))); + } +} diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 0000000..d5294c4 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,285 @@ +use crate::error::AppError; +use crate::state::AppState; +use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::{ConnectInfo, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::net::SocketAddr; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +pub const SESSION_COOKIE: &str = "typerpunk_session"; +const SESSION_LIFETIME_DAYS: i64 = 30; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/auth/register", post(register)) + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/me", get(me)) + .route("/api/auth/token", post(issue_token)) +} + +#[derive(Debug, Serialize)] +pub struct UserView { + pub id: String, + pub username: String, +} + +#[derive(Debug, Deserialize)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +// Deliberately permissive-but-bounded: rejects the empty/absurdly-long +// inputs that would either be meaningless or a resource-exhaustion vector +// against argon2 hashing, without being a picky validator of what a +// username "should" look like. +fn validate_username(username: &str) -> Result<(), AppError> { + if username.len() < 3 || username.len() > 24 { + return Err(AppError::InvalidInput("username must be 3-24 characters".into())); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(AppError::InvalidInput("username may only contain letters, numbers, and underscores".into())); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 || password.len() > 256 { + return Err(AppError::InvalidInput("password must be 8-256 characters".into())); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result<String, AppError> { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing failed: {e}"))) +} + +fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { return false }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} + +// `OffsetDateTime::to_string()` is NOT RFC3339 (it's e.g. "2024-01-01 +// 12:34:56.0 +00:00:00"), so timestamps are always written and read back via +// this explicit RFC3339 formatter - mismatching the two silently broke +// session lookups (parse failure treated as "no session found"). +pub(crate) fn format_timestamp(dt: OffsetDateTime) -> String { + dt.format(&time::format_description::well_known::Rfc3339).expect("valid RFC3339 timestamp") +} + +async fn create_session(db: &sqlx::SqlitePool, user_id: &str) -> Result<String, AppError> { + let session_id = uuid::Uuid::new_v4().to_string(); + let expires_at = OffsetDateTime::now_utc() + TimeDuration::days(SESSION_LIFETIME_DAYS); + sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)") + .bind(&session_id) + .bind(user_id) + .bind(format_timestamp(expires_at)) + .execute(db) + .await?; + Ok(session_id) +} + +fn session_cookie(id: String, secure: bool) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE, id)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(TimeDuration::days(SESSION_LIFETIME_DAYS)) + .build() +} + +/// Resolves the signed-in user from the session cookie, if any and unexpired. +pub async fn current_user(db: &sqlx::SqlitePool, jar: &CookieJar) -> Option<UserView> { + let session_id = jar.get(SESSION_COOKIE)?.value().to_string(); + let row = sqlx::query( + "SELECT users.id as id, users.username as username, sessions.expires_at as expires_at + FROM sessions JOIN users ON users.id = sessions.user_id + WHERE sessions.id = ?", + ) + .bind(&session_id) + .fetch_optional(db) + .await + .ok()??; + + let expires_at: String = row.try_get("expires_at").ok()?; + let expires_at = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339).ok()?; + if expires_at < OffsetDateTime::now_utc() { + return None; + } + + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +fn client_ip(addr: &SocketAddr) -> std::net::IpAddr { + addr.ip() +} + +async fn register( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + validate_username(&creds.username)?; + validate_password(&creds.password)?; + + let existing = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(&state.db) + .await?; + if existing.is_some() { + return Err(AppError::UsernameTaken); + } + + let user_id = uuid::Uuid::new_v4().to_string(); + let password_hash = hash_password(&creds.password)?; + sqlx::query("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(&user_id) + .bind(&creds.username) + .bind(&password_hash) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + let session_id = create_session(&state.db, &user_id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(UserView { id: user_id, username: creds.username }))) +} + +/// Verifies username/password against the users table. Shared by the +/// cookie-session login and the CLI-style token login below, so a +/// nonexistent username always takes the same dummy-hash timing path +/// regardless of which endpoint is asking. +async fn authenticate(db: &sqlx::SqlitePool, creds: &Credentials) -> Result<UserView, AppError> { + let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + let _ = verify_password(&creds.password, "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$dummydummydummydummydummydummy"); + return Err(AppError::InvalidCredentials); + }; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let username: String = row.try_get("username").map_err(|e| AppError::Internal(e.into()))?; + let password_hash: String = row.try_get("password_hash").map_err(|e| AppError::Internal(e.into()))?; + + if !verify_password(&creds.password, &password_hash) { + return Err(AppError::InvalidCredentials); + } + + Ok(UserView { id: user_id, username }) +} + +async fn login( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let session_id = create_session(&state.db, &user.id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(user))) +} + +#[derive(Debug, Serialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +// CLI-style clients (the TUI) have no cookie jar to hold a session, and +// replicating browser cookie semantics in a terminal app isn't worth it -- +// this hands back a long-lived bearer token instead, meant to be saved in a +// local config file and sent as `Authorization: Bearer <token>`. +async fn issue_token( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let token = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO api_tokens (token, user_id, created_at) VALUES (?, ?, ?)") + .bind(&token) + .bind(&user.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + Ok(Json(TokenResponse { token, user })) +} + +/// Resolves a signed-in user from a bearer token (see api_tokens above), +/// for clients with no cookie jar. +pub async fn user_from_token(db: &sqlx::SqlitePool, token: &str) -> Option<UserView> { + let row = sqlx::query( + "SELECT users.id as id, users.username as username + FROM api_tokens JOIN users ON users.id = api_tokens.user_id + WHERE api_tokens.token = ?", + ) + .bind(token) + .fetch_optional(db) + .await + .ok()??; + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +/// Cookie session first (the browser's path), then an `Authorization: +/// Bearer <token>` header (the CLI's path) - lets a handler serve both +/// kinds of client without needing to know which one it's talking to. +pub async fn current_user_or_token( + db: &sqlx::SqlitePool, + jar: &CookieJar, + headers: &axum::http::HeaderMap, +) -> Option<UserView> { + if let Some(user) = current_user(db, jar).await { + return Some(user); + } + let token = headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ")?; + user_from_token(db, token).await +} + +async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + if let Some(cookie) = jar.get(SESSION_COOKIE) { + sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(cookie.value()) + .execute(&state.db) + .await?; + } + let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + Ok(jar) +} + +async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + match current_user(&state.db, &jar).await { + Some(user) => Ok(Json(user)), + None => Err(AppError::Unauthorized), + } +} diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs new file mode 100644 index 0000000..b3eee28 --- /dev/null +++ b/crates/server/src/cosmetics.rs @@ -0,0 +1,153 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/cosmetics", get(list_catalog)) + .route("/api/cosmetics/me", get(my_cosmetics)) + .route("/api/cosmetics/:id/purchase", post(purchase)) + .route("/api/cosmetics/:id/equip", post(equip)) + .route("/api/cosmetics/unequip", post(unequip)) +} + +#[derive(Debug, Serialize)] +struct Cosmetic { + id: String, + name: String, + category: String, + price_cents: i64, + value: String, +} + +async fn list_catalog(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { + let rows = sqlx::query("SELECT id, name, category, price_cents, value FROM cosmetics ORDER BY category, price_cents") + .fetch_all(&state.db) + .await?; + let items: Vec<Cosmetic> = rows + .into_iter() + .map(|row| Cosmetic { + id: row.try_get("id").unwrap_or_default(), + name: row.try_get("name").unwrap_or_default(), + category: row.try_get("category").unwrap_or_default(), + price_cents: row.try_get("price_cents").unwrap_or_default(), + value: row.try_get("value").unwrap_or_default(), + }) + .collect(); + Ok(Json(items)) +} + +#[derive(Debug, Serialize)] +struct MyCosmetics { + owned: Vec<String>, + equipped_caret: Option<String>, + equipped_flair: Option<String>, +} + +async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let owned_rows = sqlx::query("SELECT cosmetic_id FROM user_cosmetics WHERE user_id = ?") + .bind(&user.id) + .fetch_all(&state.db) + .await?; + let owned: Vec<String> = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect(); + + let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair FROM users WHERE id = ?") + .bind(&user.id) + .fetch_one(&state.db) + .await?; + + Ok(Json(MyCosmetics { + owned, + // Explicit Option<String> turbofish - try_get(...).ok() here was + // ambiguous enough that type inference picked plain String, and + // sqlx's SQLite decode of a NULL column into String silently + // produced an empty string instead of erroring the way decoding + // into Option<String> correctly does. Found via a real NULL column + // round-tripping as "" instead of JSON null. + equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None), + equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None), + })) +} + +// Stub: grants ownership immediately with no actual charge. Wiring a real +// payment processor (Stripe or otherwise) needs the project owner's own +// merchant account - same situation as the Spotify integration needing its +// own developer credentials. This endpoint is the seam a real charge would +// slot into later without changing the ownership/equip logic around it. +async fn purchase(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let exists = sqlx::query("SELECT id FROM cosmetics WHERE id = ?") + .bind(&cosmetic_id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + + sqlx::query("INSERT OR IGNORE INTO user_cosmetics (user_id, cosmetic_id, acquired_at) VALUES (?, ?, ?)") + .bind(&user.id) + .bind(&cosmetic_id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn equip(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let row = sqlx::query( + "SELECT cosmetics.category as category FROM cosmetics + JOIN user_cosmetics ON user_cosmetics.cosmetic_id = cosmetics.id + WHERE cosmetics.id = ? AND user_cosmetics.user_id = ?", + ) + .bind(&cosmetic_id) + .bind(&user.id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::InvalidInput("you don't own this cosmetic".into()))?; + + let category: String = row.try_get("category").map_err(|e| AppError::Internal(e.into()))?; + let column = match category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::Internal(anyhow::anyhow!("unknown cosmetic category"))), + }; + + // Column name comes from a hardcoded match above, never from request + // input, so this is safe despite not being a bind parameter. + let query = format!("UPDATE users SET {column} = ? WHERE id = ?"); + sqlx::query(&query).bind(&cosmetic_id).bind(&user.id).execute(&state.db).await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct UnequipRequest { + category: String, +} + +async fn unequip(State(state): State<Arc<AppState>>, jar: CookieJar, Json(body): Json<UnequipRequest>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let column = match body.category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::InvalidInput("category must be 'caret' or 'flair'".into())), + }; + let query = format!("UPDATE users SET {column} = NULL WHERE id = ?"); + sqlx::query(&query).bind(&user.id).execute(&state.db).await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..639dc09 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,52 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +// A single error type for every handler, so failure modes map to HTTP status +// consistently instead of each handler picking its own. Messages returned to +// the client are deliberately generic for anything auth-related - see +// AppError::InvalidCredentials - so a failed login never reveals whether +// the username exists. +#[derive(Debug)] +pub enum AppError { + InvalidCredentials, + UsernameTaken, + InvalidInput(String), + Unauthorized, + NotFound, + NotConfigured(String), + RateLimited, + Internal(anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid username or password".to_string()), + AppError::UsernameTaken => (StatusCode::CONFLICT, "username already taken".to_string()), + AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()), + AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + AppError::NotConfigured(msg) => (StatusCode::NOT_IMPLEMENTED, msg.clone()), + AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later".to_string()), + AppError::Internal(err) => { + tracing::error!("internal error: {err:#}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +impl From<anyhow::Error> for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err) + } +} + +impl From<sqlx::Error> for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::Internal(err.into()) + } +} diff --git a/crates/server/src/friends.rs b/crates/server/src/friends.rs new file mode 100644 index 0000000..e720dbf --- /dev/null +++ b/crates/server/src/friends.rs @@ -0,0 +1,193 @@ +use crate::auth::{current_user_or_token, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::HeaderMap; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/friends", get(list_friends)) + .route("/api/friends/request", post(send_request)) + .route("/api/friends/:id/accept", post(accept_request)) + .route("/api/friends/:id", delete(remove_friendship)) +} + +#[derive(Debug, Serialize)] +struct FriendEntry { + friendship_id: String, + user_id: String, + username: String, +} + +#[derive(Debug, Serialize)] +struct FriendsList { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn row_to_entry(row: &sqlx::sqlite::SqliteRow, friendship_id_col: &str, user_id_col: &str, username_col: &str) -> FriendEntry { + FriendEntry { + friendship_id: row.try_get(friendship_id_col).unwrap_or_default(), + user_id: row.try_get(user_id_col).unwrap_or_default(), + username: row.try_get(username_col).unwrap_or_default(), + } +} + +async fn list_friends(State(state): State<Arc<AppState>>, jar: CookieJar, headers: HeaderMap) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + // Accepted, in either direction - the "other" user is whichever side + // isn't us, so this always returns the friend's identity regardless of + // who originally sent the request. + let accepted = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships + JOIN users ON users.id = CASE WHEN friendships.requester_id = ? THEN friendships.addressee_id ELSE friendships.requester_id END + WHERE friendships.status = 'accepted' AND (friendships.requester_id = ? OR friendships.addressee_id = ?)", + ) + .bind(&user.id).bind(&user.id).bind(&user.id) + .fetch_all(&state.db) + .await?; + + let incoming = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.requester_id + WHERE friendships.status = 'pending' AND friendships.addressee_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + let outgoing = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.addressee_id + WHERE friendships.status = 'pending' AND friendships.requester_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + Ok(Json(FriendsList { + friends: accepted.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + incoming_requests: incoming.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + outgoing_requests: outgoing.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + })) +} + +#[derive(Debug, Deserialize)] +struct FriendRequestBody { + username: String, +} + +async fn send_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Json(body): Json<FriendRequestBody>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let target = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&body.username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let target_id: String = target.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + + if target_id == user.id { + return Err(AppError::InvalidInput("can't friend yourself".into())); + } + + let existing = sqlx::query( + "SELECT id, requester_id, status FROM friendships + WHERE (requester_id = ? AND addressee_id = ?) OR (requester_id = ? AND addressee_id = ?)", + ) + .bind(&user.id).bind(&target_id) + .bind(&target_id).bind(&user.id) + .fetch_optional(&state.db) + .await?; + + if let Some(row) = existing { + let status: String = row.try_get("status").unwrap_or_default(); + let requester_id: String = row.try_get("requester_id").unwrap_or_default(); + if status == "accepted" { + return Err(AppError::InvalidInput("already friends".into())); + } + // The other person already sent a request - accept it instead of + // creating a second, redundant pending row in the opposite direction. + if requester_id == target_id { + let id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + sqlx::query("UPDATE friendships SET status = 'accepted' WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await?; + return Ok(axum::http::StatusCode::NO_CONTENT); + } + return Err(AppError::InvalidInput("request already pending".into())); + } + + sqlx::query("INSERT INTO friendships (id, requester_id, addressee_id, status, created_at) VALUES (?, ?, ?, 'pending', ?)") + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&target_id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn accept_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query( + "UPDATE friendships SET status = 'accepted' WHERE id = ? AND addressee_id = ? AND status = 'pending'", + ) + .bind(&id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} + +// Covers declining an incoming request, cancelling one you sent, and +// unfriending an accepted one - all three are just "delete the row", scoped +// to rows the caller is actually a party to. +async fn remove_friendship( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query("DELETE FROM friendships WHERE id = ? AND (requester_id = ? OR addressee_id = ?)") + .bind(&id) + .bind(&user.id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/lyrics.rs b/crates/server/src/lyrics.rs new file mode 100644 index 0000000..d7c8710 --- /dev/null +++ b/crates/server/src/lyrics.rs @@ -0,0 +1,67 @@ +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub fn router() -> Router<Arc<AppState>> { + Router::new().route("/api/lyrics", get(get_lyrics)) +} + +#[derive(Debug, Deserialize)] +pub struct LyricsQuery { + pub artist: String, + pub track: String, + pub duration: Option<u64>, +} + +#[derive(Debug, Deserialize)] +struct LrclibResponse { + #[serde(rename = "syncedLyrics")] + synced_lyrics: Option<String>, + #[serde(rename = "plainLyrics")] + plain_lyrics: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LyricsResult { + /// Synced (.lrc-format) lyrics, when lrclib has them - feed straight + /// into the same parseCustomContent('lyrics.lrc') path Custom Text + /// already uses for uploaded .lrc files. + pub lrc: Option<String>, + /// Untimed fallback when only plain lyrics exist - still typeable, just + /// without the sync-to-playback behavior the Lyrics mode is built for. + pub plain: Option<String>, +} + +async fn get_lyrics(State(state): State<Arc<AppState>>, Query(q): Query<LyricsQuery>) -> Result<impl IntoResponse, AppError> { + if q.artist.trim().is_empty() || q.track.trim().is_empty() { + return Err(AppError::InvalidInput("artist and track are required".into())); + } + + let mut req = state + .http + .get("https://lrclib.net/api/get") + .query(&[("artist_name", q.artist.as_str()), ("track_name", q.track.as_str())]); + if let Some(duration) = q.duration { + req = req.query(&[("duration", duration)]); + } + + let res = req.send().await.map_err(|e| AppError::Internal(e.into()))?; + if res.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AppError::NotFound); + } + if !res.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!("lrclib returned {}", res.status()))); + } + + let body: LrclibResponse = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + if body.synced_lyrics.is_none() && body.plain_lyrics.is_none() { + return Err(AppError::NotFound); + } + + Ok(Json(LyricsResult { lrc: body.synced_lyrics, plain: body.plain_lyrics })) +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..7e9e9a8 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,255 @@ +mod anticheat; +mod auth; +mod cosmetics; +mod error; +mod friends; +mod lyrics; +mod multiplayer; +mod rate_limit; +mod spotify; +mod state; +mod stats; + +use axum::http::{HeaderValue, Method}; +use axum::routing::get; +use axum::Router; +use serde::Deserialize; +use state::{AppState, SpotifyConfig}; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +#[derive(Deserialize)] +struct TextEntry { + content: String, +} + +// Every multiplayer room draws from this same pool so every player in a +// room races the identical passage. Reuses the dataset already shared by +// the CLI and web app instead of maintaining a separate word list here -- +// falls back to a couple of plain sentences if the file isn't reachable +// (e.g. the server binary run from somewhere other than the repo root), +// so a room can still start rather than erroring on an empty pool. +fn load_race_texts() -> Vec<String> { + let path = std::env::var("TEXTS_JSON_PATH").unwrap_or_else(|_| "texts.json".to_string()); + match std::fs::read_to_string(&path).ok().and_then(|raw| serde_json::from_str::<Vec<TextEntry>>(&raw).ok()) { + Some(entries) if !entries.is_empty() => entries.into_iter().map(|e| e.content).collect(), + _ => { + tracing::warn!("could not load race texts from {path} - using a small built-in fallback pool"); + vec![ + "The quick brown fox jumps over the lazy dog.".to_string(), + "Pack my box with five dozen liquor jugs.".to_string(), + ] + } + } +} + +// Pulled out of main() so tests can assemble the exact same router against +// an in-memory test database, instead of a parallel hand-maintained copy of +// this list drifting out of sync with the real one. +fn build_app(app_state: Arc<AppState>) -> Router { + Router::new() + .route("/api/health", get(|| async { "ok" })) + .merge(auth::router()) + .merge(stats::router()) + .merge(friends::router()) + .merge(cosmetics::router()) + .merge(multiplayer::router()) + .merge(spotify::router()) + .merge(lyrics::router()) + .with_state(app_state) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Ignored if absent - production deployments are expected to set real + // env vars directly rather than ship a .env file. + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://typerpunk.db".to_string()); + let cookie_secure = std::env::var("COOKIE_SECURE").map(|v| v == "1" || v == "true").unwrap_or(false); + let frontend_origin = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:4173".to_string()); + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8787); + + let connect_options = sqlx::sqlite::SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); + let db = sqlx::sqlite::SqlitePoolOptions::new().max_connections(10).connect_with(connect_options).await?; + sqlx::migrate!("./migrations").run(&db).await?; + + if !cookie_secure { + tracing::warn!("COOKIE_SECURE is off - session cookies will be sent over plain HTTP. Set COOKIE_SECURE=1 behind TLS in production."); + } + + let cors = CorsLayer::new() + .allow_origin(frontend_origin.parse::<HeaderValue>()?) + .allow_credentials(true) + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + + let spotify_config = SpotifyConfig { + client_id: std::env::var("SPOTIFY_CLIENT_ID").unwrap_or_default(), + client_secret: std::env::var("SPOTIFY_CLIENT_SECRET").unwrap_or_default(), + redirect_uri: std::env::var("SPOTIFY_REDIRECT_URI") + .unwrap_or_else(|_| format!("http://localhost:{port}/api/spotify/callback")), + }; + if !spotify_config.is_configured() { + tracing::warn!("SPOTIFY_CLIENT_ID/SECRET not set - the Lyrics mode's Spotify connection will return 501 until configured."); + } + + let race_texts = load_race_texts(); + let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, frontend_origin.clone())); + + let app = build_app(app_state).layer(cors).layer(TraceLayer::new_for_http()); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("typerpunk-server listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?; + + Ok(()) +} + +// Real HTTP integration tests: each spins up the actual router (build_app, +// the same function main() uses) against an in-memory database on a random +// local port, and drives it with a real reqwest client - exercising the +// full stack (routing, extractors, cookies, JSON) rather than calling +// handler functions directly, which only proves the inner logic works and +// not that the wiring around it does too. +#[cfg(test)] +mod tests { + use super::*; + use state::SpotifyConfig; + + async fn spawn_test_server() -> String { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to open in-memory test database"); + sqlx::migrate!("./migrations").run(&db).await.expect("failed to run migrations"); + + let app_state = Arc::new(AppState::new( + db, + false, + vec!["The quick brown fox jumps over the lazy dog.".to_string()], + SpotifyConfig::default(), + "http://localhost:4173".to_string(), + )); + let app = build_app(app_state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("failed to bind test listener"); + let addr = listener.local_addr().expect("test listener has no local addr"); + tokio::spawn(async move { + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.ok(); + }); + format!("http://{addr}") + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder().cookie_store(true).build().expect("failed to build test http client") + } + + async fn register(client: &reqwest::Client, base: &str, username: &str) { + let resp = client + .post(format!("{base}/api/auth/register")) + .json(&serde_json::json!({ "username": username, "password": "correcthorsebattery1" })) + .send() + .await + .expect("register request failed"); + assert!(resp.status().is_success(), "register failed for {username}: {}", resp.status()); + } + + #[tokio::test] + async fn mutual_friend_request_accept() { + let base = spawn_test_server().await; + let alice = test_client(); + let bob = test_client(); + register(&alice, &base, "alice_friend_test").await; + register(&bob, &base, "bob_friend_test").await; + + let resp = alice + .post(format!("{base}/api/friends/request")) + .json(&serde_json::json!({ "username": "bob_friend_test" })) + .send() + .await + .expect("friend request failed"); + assert!(resp.status().is_success(), "friend request should succeed: {}", resp.status()); + + // Bob should see Alice as an incoming request before accepting. + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let incoming = bob_list["incoming_requests"].as_array().expect("incoming_requests missing"); + assert_eq!(incoming.len(), 1, "bob should have exactly one incoming request"); + let friendship_id = incoming[0]["friendship_id"].as_str().expect("friendship_id missing").to_string(); + assert_eq!(incoming[0]["username"], "alice_friend_test"); + + let resp = bob.post(format!("{base}/api/friends/{friendship_id}/accept")).send().await.unwrap(); + assert!(resp.status().is_success(), "accept should succeed: {}", resp.status()); + + // After accepting, both sides should list each other as an + // accepted friend, and neither should have any pending requests + // left over. + let alice_list: serde_json::Value = + alice.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let alice_friends = alice_list["friends"].as_array().unwrap(); + assert_eq!(alice_friends.len(), 1, "alice should have exactly one friend"); + assert_eq!(alice_friends[0]["username"], "bob_friend_test"); + assert!(alice_list["outgoing_requests"].as_array().unwrap().is_empty()); + + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let bob_friends = bob_list["friends"].as_array().unwrap(); + assert_eq!(bob_friends.len(), 1, "bob should have exactly one friend"); + assert_eq!(bob_friends[0]["username"], "alice_friend_test"); + assert!(bob_list["incoming_requests"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn leaderboard_ranks_by_wpm_descending() { + let base = spawn_test_server().await; + let racers = [("leaderboard_low", 40.0), ("leaderboard_high", 95.0), ("leaderboard_mid", 70.0)]; + + for (name, wpm) in racers { + let client = test_client(); + register(&client, &base, name).await; + let resp = client + .post(format!("{base}/api/stats")) + .json(&serde_json::json!({ + "mode_key": "time-30", + "wpm": wpm, + "raw_wpm": wpm, + "accuracy": 98.0, + "time_seconds": 30.0, + "device_type": "desktop", + })) + .send() + .await + .expect("stats submission failed"); + assert!(resp.status().is_success(), "stats submission should succeed for {name}: {}", resp.status()); + } + + let anon = test_client(); + let entries: serde_json::Value = anon + .get(format!("{base}/api/leaderboard?mode=time-30")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let entries = entries.as_array().expect("leaderboard should return an array"); + assert_eq!(entries.len(), 3, "expected all three submitted results to appear"); + + let names: Vec<&str> = entries.iter().map(|e| e["username"].as_str().unwrap()).collect(); + assert_eq!( + names, + vec!["leaderboard_high", "leaderboard_mid", "leaderboard_low"], + "leaderboard should be ordered by wpm descending" + ); + } +} diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs new file mode 100644 index 0000000..54df6b2 --- /dev/null +++ b/crates/server/src/multiplayer.rs @@ -0,0 +1,594 @@ +use crate::state::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures_util::{SinkExt, StreamExt}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Mutex}; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +// A stalled connection (tab closed without a clean disconnect, laptop went +// to sleep mid-race) otherwise holds its room slot and the room's resources +// forever - there is no other cleanup path. This is a hard requirement +// carried over from the original design note on this module, not a nicety: +// every connection is force-closed after this long with no activity. +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(5); +// How many players quick match will pack into one room before opening a new +// one. Joining by code is deliberately not capped by this - a group racing +// each other on a shared code decides its own size; this only bounds the +// rooms quick match hands out to strangers. +const QUICK_MATCH_CAPACITY: usize = 5; +// A brand-new game has nobody in it, and an empty lobby is where a player +// leaves and does not come back. Quick-match rooms fill with bots after a +// short wait so there is always a race to join. They are ordinary room +// members: they appear in the player list, they are typed against, and they +// can be beaten. +const BOT_JOIN_DELAY: Duration = Duration::from_secs(6); +const BOT_TICK: Duration = Duration::from_millis(250); +// Two clearly separated tiers rather than one wide range: a room of bots that +// all land within a few WPM of each other reads as one opponent duplicated, +// and gives a human nothing to place against. A bot takes the tier the room +// does not already have, so two bots are never near each other's pace. +const BOT_SLOW_WPM: (f32, f32) = (34.0, 48.0); +const BOT_FAST_WPM: (f32, f32) = (72.0, 88.0); +// Nobody types perfectly. A bot's accuracy sets both what it reports at the +// finish and how often it stalls mid-race to "correct" itself. +const BOT_MIN_ACCURACY: f32 = 88.0; +const BOT_MAX_ACCURACY: f32 = 99.0; +// How long a correction costs, in ticks. +const BOT_CORRECTION_TICKS: u32 = 2; +const MAX_BOTS_PER_ROOM: usize = 2; +const BOT_NAMES: &[&str] = &[ + "Ghostwire", "NullPointer", "Kanji", "Sable", "Vex", "Orbit", "Static", + "Halcyon", "Nyx", "Drift", "Ember", "Kilo", "Rune", "Zephyr", "Onyx", + "Pixel", "Quartz", "Sigil", "Tessa", "Umbra", +]; + +const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud + +#[derive(Debug, Clone, PartialEq)] +enum RoomStatus { + Lobby, + Countdown, + Racing, + Finished, +} + +pub(crate) struct Player { + name: String, + ready: bool, + sender: mpsc::UnboundedSender<Message>, + /// Bots have no socket behind `sender`; the receiver is dropped the moment + /// they are created, so broadcasts to them fail silently and harmlessly. + is_bot: bool, + /// Only meaningful for bots: the pace they type at. + target_wpm: f32, + /// Only meaningful for bots: the accuracy they finish with. + target_accuracy: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) enum DeviceFilter { + #[default] + Everyone, + DesktopOnly, +} + +pub(crate) struct Room { + players: HashMap<String, Player>, + status: RoomStatus, + text: Option<String>, + finish_order: Vec<String>, + device_filter: DeviceFilter, + /// Whether the room may be filled with bots while it waits. True only for + /// quick match: a room you opened to race specific friends must stay + /// exactly as empty as you left it until they arrive. + allow_bots: bool, +} + +impl Room { + fn new(device_filter: DeviceFilter) -> Self { + Self { players: HashMap::new(), status: RoomStatus::Lobby, text: None, finish_order: Vec::new(), device_filter, allow_bots: false } + } + + fn new_quick_match(device_filter: DeviceFilter) -> Self { + Self { allow_bots: true, ..Self::new(device_filter) } + } + + fn human_count(&self) -> usize { + self.players.values().filter(|p| !p.is_bot).count() + } +} + +pub type RoomRegistry = Arc<dashmap::DashMap<String, Arc<Mutex<Room>>>>; + +pub fn new_registry() -> RoomRegistry { + Arc::new(dashmap::DashMap::new()) +} + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/multiplayer/rooms", post(create_room)) + .route("/api/multiplayer/quickmatch", post(quick_match)) + .route("/api/multiplayer/online", get(online_count)) + .route("/ws/multiplayer/:room_code", get(ws_handler)) +} + +#[derive(Debug, Serialize)] +struct CreateRoomResponse { + room_code: String, +} + +#[derive(Debug, Deserialize, Default)] +struct CreateRoomRequest { + /// "desktop_only" to reject mobile joiners at the door; anything else + /// (including absent) means no restriction. + #[serde(default)] + device_filter: Option<String>, +} + +fn generate_room_code() -> String { + let mut rng = rand::thread_rng(); + (0..5).map(|_| ROOM_CODE_CHARS[rng.gen_range(0..ROOM_CODE_CHARS.len())] as char).collect() +} + +// Body is optional - a plain POST with no JSON at all (or an empty object) +// is a normal "everyone" room, not a client error. +async fn create_room(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +// Auto-matchmaking: drop the caller into whichever room is still open, and +// only create one when none is. This is the path the "Find a Race" button +// uses, so a player never has to see or exchange a room code - codes remain +// for the deliberate "race my friends" case. +// +// try_lock, never lock: a room mid-countdown holds its own lock for the full +// three seconds on purpose (see the Ready handler), and quick match must not +// stall behind that - a room it cannot inspect right now is simply not a +// candidate. Room handles are cloned out of the registry before any locking +// so no DashMap shard guard is ever held while locking a room. +async fn quick_match(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let candidates: Vec<(String, Arc<Mutex<Room>>)> = + state.rooms.iter().map(|e| (e.key().clone(), e.value().clone())).collect(); + + for (code, room) in candidates { + let Ok(room) = room.try_lock() else { continue }; + if room.status == RoomStatus::Lobby + && room.device_filter == device_filter + && room.players.len() < QUICK_MATCH_CAPACITY + { + return Json(CreateRoomResponse { room_code: code }); + } + } + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new_quick_match(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +#[derive(Serialize)] +struct OnlineResponse { + players: usize, + rooms: usize, +} + +// How many people are in multiplayer rooms right now, for the header's live +// counter. Public and unauthenticated - it is a "is anyone around to race" +// signal shown before sign-in, so gating it would defeat the point. +// +// try_lock for the same reason quick_match uses it: a room mid-countdown +// holds its lock, and a counter must never stall a page load. A room that +// cannot be read this instant is skipped rather than waited on, so the figure +// is a lower bound under contention, never a hang. +async fn online_count(State(state): State<Arc<AppState>>) -> impl IntoResponse { + let rooms: Vec<Arc<Mutex<Room>>> = state.rooms.iter().map(|e| e.value().clone()).collect(); + let mut players = 0; + let mut counted_rooms = 0; + for room in rooms { + if let Ok(room) = room.try_lock() { + players += room.players.len(); + if !room.players.is_empty() { + counted_rooms += 1; + } + } + } + Json(OnlineResponse { players, rooms: counted_rooms }) +} + +async fn ws_handler( + ws: WebSocketUpgrade, + Path(room_code): Path<String>, + State(state): State<Arc<AppState>>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, room_code, state)) +} + +async fn broadcast(room: &Room, msg: &ServerMessage) { + let Ok(text) = serde_json::to_string(msg) else { return }; + for player in room.players.values() { + let _ = player.sender.send(Message::Text(text.clone())); + } +} + +fn player_list(room: &Room) -> ServerMessage { + ServerMessage::PlayerList { + players: room.players.iter().map(|(id, p)| PlayerInfo { id: id.clone(), name: p.name.clone(), ready: p.ready }).collect(), + } +} + +async fn handle_socket(socket: WebSocket, room_code: String, state: Arc<AppState>) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let player_id = uuid::Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); + + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_tx.send(msg).await.is_err() { + break; + } + } + }); + + let room = state + .rooms + .entry(room_code.clone()) + .or_insert_with(|| Arc::new(Mutex::new(Room::new(DeviceFilter::default())))) + .clone(); + + let mut last_activity = Instant::now(); + let mut idle_check = tokio::time::interval(IDLE_CHECK_INTERVAL); + + loop { + tokio::select! { + maybe_msg = ws_rx.next() => { + match maybe_msg { + Some(Ok(Message::Text(text))) => { + last_activity = Instant::now(); + if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) { + handle_client_message(&room, &room_code, &state, &player_id, &tx, client_msg).await; + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + _ = idle_check.tick() => { + if last_activity.elapsed() > IDLE_TIMEOUT { + let _ = tx.send(Message::Close(None)); + break; + } + } + } + } + + { + let mut room_guard = room.lock().await; + room_guard.players.remove(&player_id); + if room_guard.players.is_empty() { + state.rooms.remove(&room_code); + } else { + let msg = player_list(&room_guard); + broadcast(&room_guard, &msg).await; + } + } + send_task.abort(); +} + +// Runs the countdown and starts the race. Split out of the Ready handler so a +// bot joining a waiting lobby can trigger a start exactly the way a human +// readying up does, and so the room lock is taken per step rather than held +// across the whole three seconds. +async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + let text = { + let mut room = room_arc.lock().await; + // Another caller may have started it in the gap since the check. + if room.status != RoomStatus::Lobby { + return; + } + room.status = RoomStatus::Countdown; + let text = state + .race_texts + .get(rand::thread_rng().gen_range(0..state.race_texts.len())) + .cloned() + .unwrap_or_default(); + room.text = Some(text.clone()); + text + }; + + for seconds in (1..=3).rev() { + { + let room = room_arc.lock().await; + broadcast(&room, &ServerMessage::Countdown { seconds }).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + let bots: Vec<(String, f32, f32)> = { + let mut room = room_arc.lock().await; + room.status = RoomStatus::Racing; + broadcast(&room, &ServerMessage::Start { text: text.clone() }).await; + room.players + .iter() + .filter(|(_, p)| p.is_bot) + .map(|(id, p)| (id.clone(), p.target_wpm, p.target_accuracy)) + .collect() + }; + + let char_count = text.chars().count().max(1); + for (bot_id, wpm, accuracy) in bots { + tokio::spawn(drive_bot(room_arc.clone(), bot_id, wpm, accuracy, char_count)); + } +} + +// Types on a bot's behalf: converts its target pace into a share of the +// passage completed so far and broadcasts that like any other player's +// progress. Deliberately not perfectly even - a metronome-steady opponent +// reads as obviously fake. +async fn drive_bot( + room_arc: Arc<Mutex<Room>>, + bot_id: String, + target_wpm: f32, + target_accuracy: f32, + char_count: usize, +) { + let started = Instant::now(); + let mut ticker = tokio::time::interval(BOT_TICK); + // tokio's interval fires once immediately. Consumed here, because that + // first tick lands with essentially zero elapsed time and the live-WPM + // division below then reports thousands of words per minute. + ticker.tick().await; + // Progress is accumulated rather than recomputed from elapsed time, so a + // correction genuinely costs the bot ground instead of being erased by the + // next tick's arithmetic. + let mut chars_done: f32 = 0.0; + let mut correcting: u32 = 0; + // A less accurate bot stumbles more often. At 99% this is near zero; at + // 88% it is a stumble every few seconds. + let mistake_chance = ((100.0 - target_accuracy) / 100.0).clamp(0.0, 1.0) * 0.35; + + loop { + ticker.tick().await; + + if correcting > 0 { + // Backing up over a mistake: no forward progress this tick. + correcting -= 1; + } else { + if rand::thread_rng().gen_bool(mistake_chance as f64) { + correcting = BOT_CORRECTION_TICKS; + // Losing a little of what was typed, the way a real correction does. + chars_done = (chars_done - target_wpm * 0.08).max(0.0); + } else { + // Per-tick pace, wobbled so the line is not a ruler. + let wobble = 1.0 + (rand::thread_rng().gen_range(-12..=12) as f32 / 100.0); + chars_done += target_wpm * 5.0 * (BOT_TICK.as_secs_f32() / 60.0) * wobble; + } + } + + let percent = ((chars_done / char_count as f32) * 100.0).min(100.0); + let elapsed = started.elapsed().as_secs_f32(); + // Reported WPM follows what has actually been typed, so a bot that + // stalled reads slower for a while - exactly as a human would. + // Floored as well as offset by the consumed first tick: a burst of + // early progress divided by a near-zero elapsed time is what produced + // four-figure WPM readings. + let live_wpm = (chars_done / 5.0) / (elapsed.max(BOT_TICK.as_secs_f32()) / 60.0); + + let mut room = room_arc.lock().await; + // Stop if the race ended, the room emptied, or this bot was removed. + if room.status != RoomStatus::Racing || !room.players.contains_key(&bot_id) { + return; + } + if percent >= 100.0 { + if !room.finish_order.contains(&bot_id) { + room.finish_order.push(bot_id.clone()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { + player_id: bot_id.clone(), + wpm: live_wpm, + accuracy: target_accuracy, + time: elapsed, + place, + }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + return; + } + let msg = ServerMessage::PlayerProgress { + player_id: bot_id.clone(), + percent, + wpm: live_wpm, + }; + broadcast(&room, &msg).await; + } +} + +// Adds a bot to a quick-match room that is still sitting empty-ish, then +// readies it up - which starts the race through the same path a second human +// would have taken. +async fn maybe_add_bot(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + tokio::time::sleep(BOT_JOIN_DELAY).await; + + let should_start = { + let mut room = room_arc.lock().await; + // Conditions can all have changed during the wait: a real player may + // have arrived, the race may have started, everyone may have left. + if !room.allow_bots || room.status != RoomStatus::Lobby || room.human_count() == 0 { + return; + } + + // Decided in one pass rather than one bot per call: adding a bot + // readies it, which starts the race, so a second bot added afterwards + // would never make it into the lobby. + let room_for = 1 + MAX_BOTS_PER_ROOM; + let free = room_for.saturating_sub(room.players.len()); + if free == 0 { + return; + } + let wanted = rand::thread_rng().gen_range(1..=free); + + for _ in 0..wanted { + let taken: Vec<String> = room.players.values().map(|p| p.name.clone()).collect(); + let available: Vec<&&str> = BOT_NAMES + .iter() + .filter(|n| !taken.iter().any(|t| t == *n)) + .collect(); + if available.is_empty() { + break; + } + let name = available[rand::thread_rng().gen_range(0..available.len())]; + + // Take whichever tier is not already represented, so the room's + // bots never sit at similar speeds. With none present, pick at + // random. + let has_fast = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm >= BOT_FAST_WPM.0); + let has_slow = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm <= BOT_SLOW_WPM.1); + let fast = if has_fast { + false + } else if has_slow { + true + } else { + rand::thread_rng().gen_bool(0.5) + }; + let (lo, hi) = if fast { BOT_FAST_WPM } else { BOT_SLOW_WPM }; + + // The receiver is dropped immediately: a bot has no socket, and + // every send to it fails silently, which is what broadcast expects. + let (tx, _rx) = mpsc::unbounded_channel(); + let bot_id = format!("bot-{}", generate_room_code().to_lowercase()); + room.players.insert( + bot_id, + Player { + name: (*name).to_string(), + ready: true, + sender: tx, + is_bot: true, + target_wpm: rand::thread_rng().gen_range(lo..hi), + target_accuracy: rand::thread_rng().gen_range(BOT_MIN_ACCURACY..BOT_MAX_ACCURACY), + }, + ); + } + + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + room.players.len() >= 2 && room.players.values().all(|p| p.ready) + }; + + if should_start { + start_race(room_arc, state).await; + } +} + +async fn handle_client_message( + room_arc: &Arc<Mutex<Room>>, + room_code: &str, + state: &Arc<AppState>, + player_id: &str, + tx: &mpsc::UnboundedSender<Message>, + msg: ClientMessage, +) { + let mut room = room_arc.lock().await; + match msg { + ClientMessage::Join { name, device_type } => { + if room.device_filter == DeviceFilter::DesktopOnly && device_type != "desktop" { + let err = ServerMessage::Error { + message: "This room is desktop-only - create or join an \"Everyone\" room from a mobile device.".to_string(), + }; + if let Ok(text) = serde_json::to_string(&err) { + let _ = tx.send(Message::Text(text)); + } + let _ = tx.send(Message::Close(None)); + return; + } + room.players.insert(player_id.to_string(), Player { name, ready: false, sender: tx.clone(), is_bot: false, target_wpm: 0.0, target_accuracy: 0.0 }); + if let Ok(text) = serde_json::to_string(&ServerMessage::Joined { player_id: player_id.to_string() }) { + let _ = tx.send(Message::Text(text)); + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + if room.allow_bots && room.status == RoomStatus::Lobby && room.human_count() == 1 { + tokio::spawn(maybe_add_bot(room_arc.clone(), state.clone())); + } + } + ClientMessage::Ready => { + if let Some(p) = room.players.get_mut(player_id) { + p.ready = true; + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + // Requires >=2, not just non-empty: "all ready" is vacuously true + // for a room of one, so without this a player who readies up + // before anyone else has joined immediately starts (and finishes) + // a solo race, and a genuine second player joining moments later + // arrives to a room that already left the lobby. Caught via an + // actual two-client race where connection timing wasn't + // simultaneous - exactly the ordinary case over a real network. + let should_start = room.players.len() >= 2 + && room.players.values().all(|p| p.ready) + && room.status == RoomStatus::Lobby; + // The lock is released before the countdown runs. It used to be + // held for all three seconds as a deliberate synchronisation + // point, but that also blocked every other client's messages for + // the duration - which is exactly what reset the desktop client + // mid-countdown. start_race takes the lock per step instead. + drop(room); + if should_start { + start_race(room_arc.clone(), state.clone()).await; + } + return; + } + ClientMessage::Progress { percent, wpm } => { + let msg = ServerMessage::PlayerProgress { player_id: player_id.to_string(), percent, wpm }; + broadcast(&room, &msg).await; + } + ClientMessage::Finish { wpm, accuracy, time } => { + if !room.finish_order.contains(&player_id.to_string()) { + room.finish_order.push(player_id.to_string()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { player_id: player_id.to_string(), wpm, accuracy, time, place }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + } + } + let _ = room_code; // kept for future room-scoped logging/metrics +} diff --git a/crates/server/src/rate_limit.rs b/crates/server/src/rate_limit.rs new file mode 100644 index 0000000..ae79775 --- /dev/null +++ b/crates/server/src/rate_limit.rs @@ -0,0 +1,34 @@ +use dashmap::DashMap; +use std::hash::Hash; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// Fixed-window limiter, generic over whatever key makes sense for the +// endpoint (client IP for unauthenticated auth attempts, user id for an +// authenticated endpoint like stats submission). Good enough to blunt +// brute force / scripted abuse against a single-instance deployment without +// pulling in a separate crate; a multi-instance deployment would need this +// backed by a shared store (e.g. the DB or Redis) instead. +#[derive(Clone)] +pub struct RateLimiter<K: Eq + Hash + Clone + Send + Sync + 'static = std::net::IpAddr> { + hits: Arc<DashMap<K, (Instant, u32)>>, + max_attempts: u32, + window: Duration, +} + +impl<K: Eq + Hash + Clone + Send + Sync + 'static> RateLimiter<K> { + pub fn new(max_attempts: u32, window: Duration) -> Self { + Self { hits: Arc::new(DashMap::new()), max_attempts, window } + } + + /// Returns true if this key is still within its allowance and records the attempt. + pub fn check(&self, key: K) -> bool { + let now = Instant::now(); + let mut entry = self.hits.entry(key).or_insert((now, 0)); + if now.duration_since(entry.0) > self.window { + *entry = (now, 0); + } + entry.1 += 1; + entry.1 <= self.max_attempts + } +} diff --git a/crates/server/src/spotify.rs b/crates/server/src/spotify.rs new file mode 100644 index 0000000..9076afd --- /dev/null +++ b/crates/server/src/spotify.rs @@ -0,0 +1,237 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Redirect}; +use axum::routing::get; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::Row; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +const STATE_COOKIE: &str = "spotify_oauth_state"; +const SCOPES: &str = "user-read-currently-playing user-read-playback-state"; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/spotify/login", get(login)) + .route("/api/spotify/callback", get(callback)) + .route("/api/spotify/now-playing", get(now_playing)) +} + +fn random_state() -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..32).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect() +} + +async fn login(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.spotify.is_configured() { + return Err(AppError::NotConfigured( + "Spotify isn't configured on this server - SPOTIFY_CLIENT_ID/SECRET are unset.".into(), + )); + } + + let csrf_state = random_state(); + let auth_url = format!( + "https://accounts.spotify.com/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}", + urlencoding_encode(&state.spotify.client_id), + urlencoding_encode(&state.spotify.redirect_uri), + urlencoding_encode(SCOPES), + urlencoding_encode(&csrf_state), + ); + + let cookie = Cookie::build((STATE_COOKIE, csrf_state)) + .http_only(true) + .secure(state.cookie_secure) + .same_site(SameSite::Lax) + .path("/api/spotify") + .max_age(TimeDuration::minutes(5)) + .build(); + + Ok((jar.add(cookie), Redirect::to(&auth_url))) +} + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: Option<String>, + state: Option<String>, + error: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option<String>, + expires_in: i64, +} + +async fn callback( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Query(q): Query<CallbackQuery>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + if let Some(err) = q.error { + return Err(AppError::InvalidInput(format!("Spotify denied the request: {err}"))); + } + let code = q.code.ok_or(AppError::InvalidInput("missing code".into()))?; + let returned_state = q.state.ok_or(AppError::InvalidInput("missing state".into()))?; + let expected_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string()); + if expected_state.as_deref() != Some(returned_state.as_str()) { + return Err(AppError::InvalidInput("state mismatch - possible CSRF, try connecting again".into())); + } + + let token: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", &state.spotify.redirect_uri), + ]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad token response: {e}")))?; + + let Some(refresh_token) = token.refresh_token else { + return Err(AppError::Internal(anyhow::anyhow!("Spotify did not return a refresh token"))); + }; + let expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(token.expires_in)); + + sqlx::query( + "INSERT INTO spotify_tokens (user_id, access_token, refresh_token, expires_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at", + ) + .bind(&user.id) + .bind(&token.access_token) + .bind(&refresh_token) + .bind(&expires_at) + .execute(&state.db) + .await?; + + let jar = jar.remove(Cookie::from(STATE_COOKIE)); + Ok((jar, Redirect::to(&state.frontend_origin))) +} + +async fn get_valid_access_token(state: &AppState, user_id: &str) -> Result<Option<String>, AppError> { + let row = sqlx::query("SELECT access_token, refresh_token, expires_at FROM spotify_tokens WHERE user_id = ?") + .bind(user_id) + .fetch_optional(&state.db) + .await?; + let Some(row) = row else { return Ok(None) }; + + let access_token: String = row.try_get("access_token").map_err(|e| AppError::Internal(e.into()))?; + let refresh_token: String = row.try_get("refresh_token").map_err(|e| AppError::Internal(e.into()))?; + let expires_at: String = row.try_get("expires_at").map_err(|e| AppError::Internal(e.into()))?; + + let expired = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339) + .map(|t| t < OffsetDateTime::now_utc()) + .unwrap_or(true); + + if !expired { + return Ok(Some(access_token)); + } + + // Access tokens are short-lived (Spotify: ~1 hour) - refreshed + // transparently here so the caller never has to think about expiry. + let refreshed: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[("grant_type", "refresh_token"), ("refresh_token", &refresh_token)]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad refresh response: {e}")))?; + + let new_refresh_token = refreshed.refresh_token.unwrap_or(refresh_token); + let new_expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(refreshed.expires_in)); + sqlx::query("UPDATE spotify_tokens SET access_token = ?, refresh_token = ?, expires_at = ? WHERE user_id = ?") + .bind(&refreshed.access_token) + .bind(&new_refresh_token) + .bind(&new_expires_at) + .bind(user_id) + .execute(&state.db) + .await?; + + Ok(Some(refreshed.access_token)) +} + +#[derive(Debug, Serialize)] +struct NowPlaying { + is_playing: bool, + track: Option<String>, + artist: Option<String>, + duration_ms: Option<u64>, + progress_ms: Option<u64>, +} + +impl NowPlaying { + fn nothing() -> Self { + Self { is_playing: false, track: None, artist: None, duration_ms: None, progress_ms: None } + } +} + +async fn now_playing(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let Some(access_token) = get_valid_access_token(&state, &user.id).await? else { + return Err(AppError::NotFound); // not connected to Spotify + }; + + let res = state + .http + .get("https://api.spotify.com/v1/me/player/currently-playing") + .bearer_auth(&access_token) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))?; + + if res.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(Json(NowPlaying::nothing())); + } + if !res.status().is_success() { + return Ok(Json(NowPlaying::nothing())); + } + + let body: Value = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + let is_playing = body.get("is_playing").and_then(Value::as_bool).unwrap_or(false); + let item = body.get("item"); + let track = item.and_then(|i| i.get("name")).and_then(Value::as_str).map(str::to_string); + let artist = item + .and_then(|i| i.get("artists")) + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|a| a.get("name")) + .and_then(Value::as_str) + .map(str::to_string); + let duration_ms = item.and_then(|i| i.get("duration_ms")).and_then(Value::as_u64); + let progress_ms = body.get("progress_ms").and_then(Value::as_u64); + + Ok(Json(NowPlaying { is_playing, track, artist, duration_ms, progress_ms })) +} + +// Minimal percent-encoding - avoids pulling in the `url` crate just to +// build a couple of query string values for the authorize URL. +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 0000000..267b6fb --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,65 @@ +use crate::multiplayer::{new_registry, RoomRegistry}; +use crate::rate_limit::RateLimiter; +use reqwest::Client; +use sqlx::SqlitePool; +use std::time::Duration; + +#[derive(Clone, Default)] +pub struct SpotifyConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, +} + +impl SpotifyConfig { + pub fn is_configured(&self) -> bool { + !self.client_id.is_empty() && !self.client_secret.is_empty() + } +} + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub auth_rate_limiter: RateLimiter, + /// Keyed by user id, not IP - this guards an authenticated endpoint + /// (stats submission) against a single compromised/scripted account + /// hammering it, which an IP-keyed limiter wouldn't catch behind NAT or + /// a VPN and would over-punish for a shared IP. + pub stats_rate_limiter: RateLimiter<String>, + /// Set from COOKIE_SECURE. Off for plain-HTTP local dev, must be on + /// behind TLS in production or browsers silently drop the cookie. + pub cookie_secure: bool, + pub rooms: RoomRegistry, + /// Race passages every multiplayer room draws from, so every player in + /// a room types the identical text - loaded once at startup rather + /// than per-room, since the pool itself never changes at runtime. + pub race_texts: Vec<String>, + pub spotify: SpotifyConfig, + pub frontend_origin: String, + pub http: Client, +} + +impl AppState { + pub fn new( + db: SqlitePool, + cookie_secure: bool, + race_texts: Vec<String>, + spotify: SpotifyConfig, + frontend_origin: String, + ) -> Self { + Self { + db, + auth_rate_limiter: RateLimiter::new(10, Duration::from_secs(5 * 60)), + // A genuine player finishes a test at most every several + // seconds; 60 submissions in 5 minutes is generous headroom for + // rapid Words-10 sessions while still capping scripted spam. + stats_rate_limiter: RateLimiter::new(60, Duration::from_secs(5 * 60)), + cookie_secure, + rooms: new_registry(), + race_texts, + spotify, + frontend_origin, + http: Client::new(), + } + } +} diff --git a/crates/server/src/stats.rs b/crates/server/src/stats.rs new file mode 100644 index 0000000..6e80a3d --- /dev/null +++ b/crates/server/src/stats.rs @@ -0,0 +1,275 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::CookieJar; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::sync::Arc; +use time::OffsetDateTime; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/stats", post(submit_result)) + .route("/api/stats/me", get(my_stats)) + .route("/api/leaderboard", get(leaderboard)) + .route("/api/users/:username/public", get(public_profile)) +} + +#[derive(Debug, Deserialize)] +pub struct SubmitResult { + pub mode_key: String, + pub wpm: f64, + pub raw_wpm: f64, + pub accuracy: f64, + pub time_seconds: f64, + #[serde(default)] + pub device_type: Option<String>, + /// Milliseconds between consecutive keystrokes, in order. Optional -- + /// older/other clients that don't send it just skip the timing-variance + /// half of the anti-cheat check (see anticheat::should_flag). + #[serde(default)] + pub keystroke_intervals_ms: Option<Vec<f64>>, +} + +// Bounds a client could never legitimately produce - rejecting them keeps +// obviously-fabricated rows out of personal bests and the public +// leaderboard without trying to fully police what "legitimate" means. +fn validate_result(r: &SubmitResult) -> Result<(), AppError> { + if r.mode_key.is_empty() || r.mode_key.len() > 64 { + return Err(AppError::InvalidInput("invalid mode_key".into())); + } + if !(0.0..=500.0).contains(&r.wpm) || !(0.0..=500.0).contains(&r.raw_wpm) { + return Err(AppError::InvalidInput("wpm out of range".into())); + } + if !(0.0..=100.0).contains(&r.accuracy) { + return Err(AppError::InvalidInput("accuracy out of range".into())); + } + if !(0.0..3600.0).contains(&r.time_seconds) { + return Err(AppError::InvalidInput("time_seconds out of range".into())); + } + if let Some(d) = &r.device_type { + if d != "desktop" && d != "mobile" { + return Err(AppError::InvalidInput("device_type must be 'desktop' or 'mobile'".into())); + } + } + Ok(()) +} + +async fn submit_result( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Json(body): Json<SubmitResult>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.stats_rate_limiter.check(user.id.clone()) { + return Err(AppError::RateLimited); + } + validate_result(&body)?; + + let device_type = body.device_type.as_deref().unwrap_or("desktop"); + let flagged = crate::anticheat::should_flag(body.wpm, &body.keystroke_intervals_ms); + + sqlx::query( + "INSERT INTO test_results (id, user_id, mode_key, wpm, raw_wpm, accuracy, time_seconds, created_at, device_type, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&body.mode_key) + .bind(body.wpm) + .bind(body.raw_wpm) + .bind(body.accuracy) + .bind(body.time_seconds) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .bind(device_type) + .bind(flagged) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize)] +pub struct PersonalBest { + pub mode_key: String, + pub wpm: f64, + pub date: String, +} + +#[derive(Debug, Serialize)] +pub struct MyStats { + pub tests_completed: i64, + pub total_time_seconds: f64, + pub average_wpm: f64, + pub average_accuracy: f64, + pub best_wpm: f64, + pub personal_bests: Vec<PersonalBest>, +} + +// Shared by /api/stats/me (the signed-in caller's own id) and the public +// profile endpoint (an arbitrary user_id looked up by username) - same +// aggregate, same window-function best-per-mode query either way. +async fn fetch_stats_summary(state: &AppState, user_id: &str) -> Result<MyStats, AppError> { + let summary = sqlx::query( + "SELECT COUNT(*) as tests_completed, + COALESCE(SUM(time_seconds), 0) as total_time_seconds, + COALESCE(AVG(wpm), 0) as average_wpm, + COALESCE(AVG(accuracy), 0) as average_accuracy, + COALESCE(MAX(wpm), 0) as best_wpm + FROM test_results WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(&state.db) + .await?; + + // Window function picks each mode's single highest-wpm row (and that + // row's own date) rather than independently maxing wpm and date, which + // could otherwise pair a best score with the date of a different run. + let bests = sqlx::query( + "SELECT mode_key, wpm, created_at FROM ( + SELECT mode_key, wpm, created_at, + ROW_NUMBER() OVER (PARTITION BY mode_key ORDER BY wpm DESC) as rn + FROM test_results WHERE user_id = ? + ) WHERE rn = 1", + ) + .bind(user_id) + .fetch_all(&state.db) + .await?; + + let personal_bests = bests + .into_iter() + .map(|row| PersonalBest { + mode_key: row.try_get("mode_key").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + }) + .collect(); + + Ok(MyStats { + tests_completed: summary.try_get("tests_completed").unwrap_or_default(), + total_time_seconds: summary.try_get("total_time_seconds").unwrap_or_default(), + average_wpm: summary.try_get("average_wpm").unwrap_or_default(), + average_accuracy: summary.try_get("average_accuracy").unwrap_or_default(), + best_wpm: summary.try_get("best_wpm").unwrap_or_default(), + personal_bests, + }) +} + +async fn my_stats(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + Ok(Json(fetch_stats_summary(&state, &user.id).await?)) +} + +#[derive(Debug, Serialize)] +pub struct PublicProfile { + pub username: String, + pub joined_at: String, + pub flair: Option<String>, + #[serde(flatten)] + pub stats: MyStats, +} + +// Deliberately excludes anything not meant for other people to see (no +// email, no password hash, no session/friendship data) - just the same +// aggregate/personal-bests shape /api/stats/me returns for yourself, plus +// the username, join date, and equipped flair (cosmetics only mean anything +// if other people can actually see them), for anyone looking someone up. +async fn public_profile(State(state): State<Arc<AppState>>, Path(username): Path<String>) -> Result<impl IntoResponse, AppError> { + // LEFT JOIN cosmetics for the flair's *value* ("star"), not the raw + // cosmetic id ("flair-star") stored on the user row - the frontend's + // FLAIR_ICONS map is keyed by value, and hardcoding the id->value + // mapping client-side would silently drift the moment the catalog + // changes. + let row = sqlx::query( + "SELECT users.id as id, users.created_at as created_at, cosmetics.value as flair_value + FROM users LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE users.username = ?", + ) + .bind(&username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let joined_at: String = row.try_get("created_at").map_err(|e| AppError::Internal(e.into()))?; + // See cosmetics.rs's comment on this exact pattern - .ok() alone left + // T ambiguous enough to decode NULL as "" instead of None. + let flair: Option<String> = row.try_get::<Option<String>, _>("flair_value").unwrap_or(None); + let stats = fetch_stats_summary(&state, &user_id).await?; + + Ok(Json(PublicProfile { username, joined_at, flair, stats })) +} + +#[derive(Debug, Deserialize)] +pub struct LeaderboardQuery { + pub mode: String, + pub limit: Option<u32>, + /// "desktop" to show desktop-only results; omitted/anything else means + /// no device filter. Mobile is intentionally still counted by default -- + /// this is an opt-in filter for viewers who want a keyboard-only board, + /// not a default exclusion of mobile players. + pub device: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LeaderboardEntry { + pub username: String, + pub wpm: f64, + pub accuracy: f64, + pub date: String, + pub device_type: String, + pub flair: Option<String>, +} + +async fn leaderboard(State(state): State<Arc<AppState>>, Query(q): Query<LeaderboardQuery>) -> Result<impl IntoResponse, AppError> { + if q.mode.is_empty() || q.mode.len() > 64 { + return Err(AppError::InvalidInput("invalid mode".into())); + } + let limit = q.limit.unwrap_or(50).clamp(1, 100); + let desktop_only = q.device.as_deref() == Some("desktop"); + + // One row per user: their single best NON-FLAGGED run in this mode, + // ranked by wpm. A flagged run doesn't just rank lower - it's excluded + // entirely, since a flagged score sitting on the board (even far down) + // is still a false signal to everyone who sees it. + // Same reasoning as public_profile: join through to the flair's value, + // not the raw cosmetic id. + let rows = sqlx::query( + "SELECT username, wpm, accuracy, created_at, device_type, flair_value FROM ( + SELECT users.username as username, test_results.wpm as wpm, + test_results.accuracy as accuracy, test_results.created_at as created_at, + test_results.device_type as device_type, cosmetics.value as flair_value, + ROW_NUMBER() OVER (PARTITION BY test_results.user_id ORDER BY test_results.wpm DESC) as rn + FROM test_results + JOIN users ON users.id = test_results.user_id + LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE test_results.mode_key = ? AND test_results.flagged = 0 + AND (? = 0 OR test_results.device_type = 'desktop') + ) WHERE rn = 1 + ORDER BY wpm DESC + LIMIT ?", + ) + .bind(&q.mode) + .bind(desktop_only) + .bind(limit) + .fetch_all(&state.db) + .await?; + + let entries: Vec<LeaderboardEntry> = rows + .into_iter() + .map(|row| LeaderboardEntry { + username: row.try_get("username").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + accuracy: row.try_get("accuracy").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + device_type: row.try_get("device_type").unwrap_or_default(), + flair: row.try_get::<Option<String>, _>("flair_value").unwrap_or(None), + }) + .collect(); + + Ok(Json(entries)) +} diff --git a/crates/steam/Cargo.toml b/crates/steam/Cargo.toml new file mode 100644 index 0000000..b6c4851 --- /dev/null +++ b/crates/steam/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "typerpunk-steam" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk-desktop" +path = "src/main.rs" + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["serde", "multiplayer"] } +bevy = { workspace = true } +serde.workspace = true +serde_json.workspace = true +rand.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-tungstenite.workspace = true +futures-util.workspace = true +reqwest.workspace = true diff --git a/crates/steam/assets/sprites/pulse.png b/crates/steam/assets/sprites/pulse.png Binary files differnew file mode 100644 index 0000000..a70d94d --- /dev/null +++ b/crates/steam/assets/sprites/pulse.png diff --git a/crates/steam/assets/sprites/pulse.svg b/crates/steam/assets/sprites/pulse.svg new file mode 100644 index 0000000..de5b98e --- /dev/null +++ b/crates/steam/assets/sprites/pulse.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64"> + <!-- Angular diamond, matching the web icon set's straight-edge language. + Reads as a signal pulse travelling along the track. --> + <path d="M12 2L22 12L12 22L2 12Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/racer_marker.png b/crates/steam/assets/sprites/racer_marker.png Binary files differnew file mode 100644 index 0000000..e7afbc3 --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.png diff --git a/crates/steam/assets/sprites/racer_marker.svg b/crates/steam/assets/sprites/racer_marker.svg new file mode 100644 index 0000000..60e973c --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48"> + <!-- Shared "this one is you" marker used above the local player's lane + in both Signal Run and Breach Protocol. A filled downward chevron, + angular like every other icon in the set. --> + <path d="M2 4H22L12 16Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/warden_layer.png b/crates/steam/assets/sprites/warden_layer.png Binary files differnew file mode 100644 index 0000000..0b4f3f2 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.png diff --git a/crates/steam/assets/sprites/warden_layer.svg b/crates/steam/assets/sprites/warden_layer.svg new file mode 100644 index 0000000..2fdf9a4 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 28" width="280" height="56"> + <!-- A circuit-board-style defensive layer: a solid bar with angular teeth + above and below, echoing the terminal/hacking aesthetic instead of a + flat rectangle. --> + <rect x="4" y="10" width="132" height="8" fill="#ffffff"/> + <rect x="14" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="14" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="18" width="14" height="8" fill="#ffffff"/> +</svg> diff --git a/crates/steam/src/lore.rs b/crates/steam/src/lore.rs new file mode 100644 index 0000000..193587c --- /dev/null +++ b/crates/steam/src/lore.rs @@ -0,0 +1,24 @@ +// In-universe naming for TyperPunk's multiplayer race modes. + +pub const APP_NAME: &str = "TyperPunk"; + +/// A player or bot in a race. "Runner" is our own term for a race participant. +pub const PARTICIPANT_LABEL: &str = "Runner"; + +pub mod modes { + /// Terminal Duel: two runners' text execution shown side by side, like + /// racing shells. Cheapest of the three to build well; on-brand. + pub const TERMINAL_DUEL: &str = "Terminal Duel"; + + /// Breach Protocol: each runner chips through a stack of defensive + /// layers ("Wardens") with correct keystrokes; first to clear them wins. + pub const BREACH_PROTOCOL: &str = "Breach Protocol"; + + /// Signal Run: runners are pulses of light racing along a circuit trace + /// toward a destination node. The animated, particle-heavy visual. + pub const SIGNAL_RUN: &str = "Signal Run"; +} + +/// A single defensive layer in Breach Protocol. Renamed from the generic +/// "firewall layer" to keep the game's own vocabulary distinct. +pub const WARDEN_LABEL: &str = "Warden"; diff --git a/crates/steam/src/main.rs b/crates/steam/src/main.rs new file mode 100644 index 0000000..3fafb61 --- /dev/null +++ b/crates/steam/src/main.rs @@ -0,0 +1,55 @@ +use bevy::prelude::*; + +mod lore; +mod multiplayer_plugin; +mod net; +mod race; +mod racer; +mod typing; + +use multiplayer_plugin::MultiplayerPlugin; +use race::{ + breach::BreachProtocolPlugin, signal::SignalRunPlugin, terminal::TerminalDuelPlugin, RaceVisual, +}; +use racer::DemoProgressPlugin; +use typing::TypingPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: lore::APP_NAME.to_string(), + resolution: (1280, 720).into(), + ..default() + }), + ..default() + })) + .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.03))) + .init_state::<RaceVisual>() + .add_plugins(DemoProgressPlugin) + .add_plugins(MultiplayerPlugin) + .add_plugins(TypingPlugin) + .add_plugins(TerminalDuelPlugin) + .add_plugins(BreachProtocolPlugin) + .add_plugins(SignalRunPlugin) + .add_systems(Startup, spawn_camera) + .add_systems(Update, cycle_visual_on_key) + .run(); +} + +fn spawn_camera(mut commands: Commands) { + commands.spawn(Camera2d); +} + +/// Demo-only: press 1/2/3 to switch which race visual is on screen, so all +/// three can be compared side by side before committing to one for real +/// multiplayer. Remove once there's a real mode-select flow. +fn cycle_visual_on_key(keys: Res<ButtonInput<KeyCode>>, mut next: ResMut<NextState<RaceVisual>>) { + if keys.just_pressed(KeyCode::Digit1) { + next.set(RaceVisual::TerminalDuel); + } else if keys.just_pressed(KeyCode::Digit2) { + next.set(RaceVisual::BreachProtocol); + } else if keys.just_pressed(KeyCode::Digit3) { + next.set(RaceVisual::SignalRun); + } +} diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs new file mode 100644 index 0000000..05c1760 --- /dev/null +++ b/crates/steam/src/multiplayer_plugin.rs @@ -0,0 +1,179 @@ +// Bridges the background WebSocket worker in net.rs into Bevy's ECS: applies +// incoming NetEvents to Racer entities every frame, and forwards the local +// racer's (synthetic, see net.rs's module comment) progress out over the +// same connection. +use crate::net::{LocalUpdate, MultiplayerNet, NetEvent}; +use crate::racer::{DemoRace, Racer}; +use crate::typing::TypingSession; +use bevy::prelude::*; +use std::sync::mpsc::Sender; + +fn server_http_base() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +fn server_ws_base() -> String { + // Same host as the HTTP API, just a different scheme - ws:// unless the + // HTTP base was already secured, in which case wss://. + let http = server_http_base(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} + +#[derive(Resource)] +struct NetHandle(MultiplayerNet); + +#[derive(Resource)] +struct LocalUpdateSender(Sender<LocalUpdate>); + +#[derive(Resource, Default)] +struct LocalPlayerId(Option<String>); + +// Without this, send_local_progress would re-send Finish every single frame +// once the local racer reaches 1.0 progress, flooding the connection +// forever instead of announcing it once. +#[derive(Resource, Default)] +struct LocalFinishSent(bool); + +/// Tags a Racer entity as backed by a specific server-assigned player id, so +/// incoming PlayerProgress/PlayerFinished events know which entity to +/// update, and PlayerList knows which remote players already have one. +#[derive(Component)] +struct RemotePlayer(String); + +pub struct MultiplayerPlugin; + +impl Plugin for MultiplayerPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(LocalPlayerId::default()) + .insert_resource(LocalFinishSent::default()) + .add_systems(Startup, connect) + .add_systems(Update, (poll_events, send_local_progress)); + } +} + +fn connect(mut commands: Commands) { + // A room code passed in means "join this existing room" (e.g. the code + // a web client printed) - otherwise a fresh room is created and its + // code logged so a second client can join it to actually see a live + // race between two real connections. + let room_code = std::env::var("TYPERPUNK_ROOM_CODE").ok(); + let name = std::env::var("TYPERPUNK_PLAYER_NAME").unwrap_or_else(|_| "Desktop".to_string()); + let (net, update_tx) = MultiplayerNet::spawn(server_http_base(), server_ws_base(), room_code, name); + commands.insert_resource(NetHandle(net)); + commands.insert_resource(LocalUpdateSender(update_tx)); +} + +fn poll_events( + mut commands: Commands, + net: Res<NetHandle>, + mut local_id: ResMut<LocalPlayerId>, + mut typing: ResMut<TypingSession>, + mut finish_sent: ResMut<LocalFinishSent>, + mut racers: Query<(&mut Racer, &RemotePlayer)>, + existing: Query<(Entity, &RemotePlayer)>, +) { + while let Some(event) = net.0.try_recv() { + match event { + NetEvent::RoomCreated { code } => { + info!("multiplayer room created - join it from the web app with code: {code}"); + } + NetEvent::Joined { player_id } => { + info!("joined multiplayer room as player {player_id}"); + local_id.0 = Some(player_id); + } + NetEvent::PlayerList(players) => { + for player in &players { + if local_id.0.as_deref() == Some(player.id.as_str()) { + continue; // that's us - our own Racer entity already exists + } + let already_spawned = existing.iter().any(|(_, r)| r.0 == player.id); + if !already_spawned { + commands.spawn(( + Racer { name: player.name.clone(), is_local: false, progress: 0.0, wpm: 0.0, accuracy: 100.0, mistakes: 0 }, + RemotePlayer(player.id.clone()), + DemoRace, + )); + } + } + // A player who left the room stops appearing in PlayerList -- + // their racer entity is stale and would otherwise sit frozen + // on screen forever. + let current_ids: Vec<&str> = players.iter().map(|p| p.id.as_str()).collect(); + for (entity, remote) in &existing { + if !current_ids.contains(&remote.0.as_str()) { + commands.entity(entity).despawn(); + } + } + } + NetEvent::Countdown(seconds) => { + info!("race starting in {seconds}s"); + } + NetEvent::Start(text) => { + info!("race started"); + typing.start(text); + // A fresh race needs its own Finish announcement - without + // resetting this, a second race in the same process would + // never send one, since the guard would still remember the + // first race's finish. + finish_sent.0 = false; + } + NetEvent::PlayerProgress { player_id, percent, wpm } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = (percent / 100.0).clamp(0.0, 1.0); + racer.wpm = wpm; + } + } + } + NetEvent::PlayerFinished { player_id, wpm, .. } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = 1.0; + racer.wpm = wpm; + } + } + } + NetEvent::RoomClosed(reason) => { + warn!("multiplayer room closed: {reason}"); + } + NetEvent::Error(message) => { + warn!("multiplayer error: {message}"); + } + } + } +} + +fn send_local_progress( + sender: Res<LocalUpdateSender>, + mut finish_sent: ResMut<LocalFinishSent>, + typing: Res<TypingSession>, + racers: Query<&Racer, With<DemoRace>>, +) { + // Nothing to report before a race has actually started - sending + // Progress{0%, 0wpm} on a loop while sitting in the lobby is meaningless + // and, worse, floods the connection right as the server's Ready handler + // is mid-countdown holding the room lock (see multiplayer.rs's Ready + // handler comment on why that hold is intentional). + if !typing.has_started() { + return; + } + for racer in &racers { + if !racer.is_local { + continue; + } + if racer.progress >= 1.0 { + if !finish_sent.0 { + let _ = sender.0.send(LocalUpdate::Finish { wpm: racer.wpm, accuracy: racer.accuracy, time: 0.0 }); + finish_sent.0 = true; + } + } else { + let _ = sender.0.send(LocalUpdate::Progress { percent: racer.progress * 100.0, wpm: racer.wpm }); + } + } +} diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs new file mode 100644 index 0000000..4d213b4 --- /dev/null +++ b/crates/steam/src/net.rs @@ -0,0 +1,178 @@ +// Real multiplayer networking for the desktop client, replacing racer.rs's +// synthetic two-racer demo driver. Bevy's render loop is synchronous, so (as +// with the TUI's own net.rs) a dedicated OS thread owns a tokio runtime and +// the actual WebSocket connection, bridging to the ECS over plain +// std::sync::mpsc channels polled once per frame. +// +// The local racer's progress is still produced by a synthetic driver (see +// racer::drive_demo_progress) rather than real typing input - this crate +// has no typing UI yet, that's a separate, much larger feature. What's real +// here is the network path itself: the local racer's synthetic progress is +// sent to typerpunk-server over the same protocol the web client uses, and +// every *other* racer on screen is driven entirely by what the server +// actually broadcasts, so two real clients (this one and a browser, or two +// copies of this one) genuinely race against each other's live state. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +// std::sync::mpsc::Receiver is Send but not Sync, and Bevy's Resource trait +// requires Sync - a single-slot Mutex around it costs nothing here since +// poll_events is the only reader, called from a single system. +pub struct MultiplayerNet { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, +} + +impl MultiplayerNet { + /// `room_code: None` creates a fresh room and reports its code back via + /// `NetEvent::RoomCreated` (printed to the terminal by main.rs so a + /// tester can join it from the web app); `Some(code)` joins an existing + /// one instead. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> (Self, mpsc::Sender<LocalUpdate>) { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start steam network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + (Self { event_rx: std::sync::Mutex::new(event_rx) }, update_tx) + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let url = format!("{http_base}/api/multiplayer/rooms"); + let resp = client.post(&url).send().await.map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, accuracy, time, place } => { + NetEvent::PlayerFinished { player_id, wpm, accuracy, time, place } + } + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + let _ = send_msg(&mut write, &ClientMessage::Ready).await; + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short interval + // instead of a third select branch reading it directly. + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => { + while let Ok(update) = update_rx.try_recv() { + let client_msg = match update { + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + } + } + } +} diff --git a/crates/steam/src/race/breach.rs b/crates/steam/src/race/breach.rs new file mode 100644 index 0000000..9151774 --- /dev/null +++ b/crates/steam/src/race/breach.rs @@ -0,0 +1,114 @@ +use super::RaceVisual; +use crate::lore; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Breach Protocol" - each runner chips through a vertical stack of +/// defensive layers ("Wardens") as they type correctly; first to clear +/// their whole stack wins. Doubles as the visual for a hacking-themed mode. +pub struct BreachProtocolPlugin; + +const LAYER_COUNT: usize = 8; +const LAYER_SIZE: Vec2 = Vec2::new(140.0, 28.0); +const LAYER_GAP: f32 = 6.0; +const LANE_GAP: f32 = 240.0; + +impl Plugin for BreachProtocolPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::BreachProtocol), spawn_lanes) + .add_systems(OnExit(RaceVisual::BreachProtocol), despawn_lanes) + .add_systems( + Update, + update_layers.run_if(in_state(RaceVisual::BreachProtocol)), + ); + } +} + +#[derive(Component)] +struct BreachRoot; + +#[derive(Component)] +struct WardenLayer { + racer: Entity, + index: usize, +} + +fn spawn_lanes( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let warden_texture: Handle<Image> = asset_server.load("sprites/warden_layer.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + let lanes: Vec<_> = racers.iter().collect(); + let start_x = -(LANE_GAP * (lanes.len().max(1) as f32 - 1.0)) / 2.0; + + for (lane_index, (entity, racer)) in lanes.iter().enumerate() { + let x = start_x + lane_index as f32 * LANE_GAP; + let stack_top = -200.0 + LAYER_COUNT as f32 * (LAYER_SIZE.y + LAYER_GAP); + + commands.spawn(( + BreachRoot, + Text2d::new(format!("{} - {}", racer.name, lore::WARDEN_LABEL)), + TextFont { + font_size: bevy::text::FontSize::Px(18.0), + ..default() + }, + TextColor(Color::srgb(0.6, 0.6, 0.65)), + Transform::from_xyz(x, stack_top + 20.0, 0.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's own stack, same asset Signal Run uses for the + // same purpose. + if racer.is_local { + commands.spawn(( + BreachRoot, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(x, stack_top + 44.0, 0.0), + )); + } + + for i in 0..LAYER_COUNT { + let y = -200.0 + i as f32 * (LAYER_SIZE.y + LAYER_GAP); + commands.spawn(( + BreachRoot, + WardenLayer { + racer: *entity, + index: i, + }, + Sprite { + image: warden_texture.clone(), + color: Color::srgb(0.15, 0.15, 0.18), + custom_size: Some(LAYER_SIZE), + ..default() + }, + Transform::from_xyz(x, y, 0.0), + )); + } + } +} + +fn update_layers(racers: Query<&Racer>, mut layers: Query<(&WardenLayer, &mut Sprite)>) { + for (layer, mut sprite) in &mut layers { + if let Ok(racer) = racers.get(layer.racer) { + let cleared = (racer.progress * LAYER_COUNT as f32).floor() as usize; + sprite.color = if layer.index < cleared { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(0.15, 0.15, 0.18) + }; + } + } +} + +fn despawn_lanes(mut commands: Commands, root: Query<Entity, With<BreachRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/mod.rs b/crates/steam/src/race/mod.rs new file mode 100644 index 0000000..15ad5f3 --- /dev/null +++ b/crates/steam/src/race/mod.rs @@ -0,0 +1,16 @@ +use bevy::prelude::*; + +pub mod breach; +pub mod signal; +pub mod terminal; + +/// Which race visualization is currently on screen. Switchable at runtime +/// (keys 1/2/3 in the demo) so all three can be compared side by side +/// during development instead of committing to one before they're built. +#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum RaceVisual { + #[default] + TerminalDuel, + BreachProtocol, + SignalRun, +} diff --git a/crates/steam/src/race/signal.rs b/crates/steam/src/race/signal.rs new file mode 100644 index 0000000..1046fc8 --- /dev/null +++ b/crates/steam/src/race/signal.rs @@ -0,0 +1,110 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Signal Run" - runners are pulses of light traveling along a circuit +/// trace toward a destination node. +pub struct SignalRunPlugin; + +const TRACK_START_X: f32 = -500.0; +const TRACK_END_X: f32 = 500.0; +const TRACK_Y: f32 = 0.0; +const LANE_SPACING: f32 = 60.0; + +impl Plugin for SignalRunPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::SignalRun), spawn_pulses) + .add_systems(OnExit(RaceVisual::SignalRun), despawn_pulses) + .add_systems( + Update, + (draw_track, move_pulses).run_if(in_state(RaceVisual::SignalRun)), + ); + } +} + +#[derive(Component)] +struct SignalEntity; + +#[derive(Component)] +struct Pulse { + racer: Entity, +} + +fn lane_y(lane: usize) -> f32 { + TRACK_Y - lane as f32 * LANE_SPACING +} + +fn spawn_pulses( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let pulse_texture: Handle<Image> = asset_server.load("sprites/pulse.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + + for (lane, (entity, racer)) in racers.iter().enumerate() { + let color = if racer.is_local { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(1.0, 0.3, 0.5) + }; + commands.spawn(( + SignalEntity, + Pulse { racer: entity }, + Sprite { + image: pulse_texture.clone(), + color, + custom_size: Some(Vec2::splat(22.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane), 1.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's lane, on-brand with the same asset the Breach + // Protocol visual uses for the same purpose. + if racer.is_local { + commands.spawn(( + SignalEntity, + // Tagged with Pulse too, purely so move_pulses (below) keeps + // its x position tracking the same racer's progress - its + // spawned y offset above the lane is left untouched since + // that system only ever writes .x. + Pulse { racer: entity }, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane) + 26.0, 1.0), + )); + } + } +} + +fn move_pulses(racers: Query<&Racer>, mut pulses: Query<(&Pulse, &mut Transform)>) { + for (pulse, mut transform) in &mut pulses { + if let Ok(racer) = racers.get(pulse.racer) { + transform.translation.x = + TRACK_START_X + (TRACK_END_X - TRACK_START_X) * racer.progress; + } + } +} + +fn draw_track(mut gizmos: Gizmos, racers: Query<&Racer, With<DemoRace>>) { + for lane in 0..racers.iter().len() { + let y = lane_y(lane); + gizmos.line_2d( + Vec2::new(TRACK_START_X, y), + Vec2::new(TRACK_END_X, y), + Color::srgba(0.0, 1.0, 0.62, 0.25), + ); + } +} + +fn despawn_pulses(mut commands: Commands, pulses: Query<Entity, With<SignalEntity>>) { + for entity in &pulses { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/terminal.rs b/crates/steam/src/race/terminal.rs new file mode 100644 index 0000000..d558e42 --- /dev/null +++ b/crates/steam/src/race/terminal.rs @@ -0,0 +1,81 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Terminal Duel" - each runner's line renders like a shell command +/// executing in real time. Cheapest of the three visuals: no sprites, no +/// physics, just text - and it reads as authentically on-brand for a +/// typing game about hacking rather than racing. +pub struct TerminalDuelPlugin; + +impl Plugin for TerminalDuelPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::TerminalDuel), spawn_ui) + .add_systems(OnExit(RaceVisual::TerminalDuel), despawn_ui) + .add_systems( + Update, + update_lines.run_if(in_state(RaceVisual::TerminalDuel)), + ); + } +} + +#[derive(Component)] +struct TerminalDuelRoot; + +#[derive(Component)] +struct TerminalLine(Entity); + +fn spawn_ui(mut commands: Commands, racers: Query<(Entity, &Racer), With<DemoRace>>) { + commands + .spawn(( + TerminalDuelRoot, + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + row_gap: Val::Px(24.0), + ..default() + }, + )) + .with_children(|parent| { + for (entity, racer) in &racers { + parent.spawn(( + TerminalLine(entity), + Text::new(line_for(racer)), + TextFont { + font_size: bevy::text::FontSize::Px(26.0), + ..default() + }, + TextColor(Color::srgb(0.0, 1.0, 0.62)), + )); + } + }); +} + +fn update_lines(racers: Query<&Racer>, mut lines: Query<(&TerminalLine, &mut Text)>) { + for (link, mut text) in &mut lines { + if let Ok(racer) = racers.get(link.0) { + *text = Text::new(line_for(racer)); + } + } +} + +fn line_for(racer: &Racer) -> String { + let width = 32; + let filled = ((racer.progress * width as f32).round() as usize).min(width); + let bar: String = "#".repeat(filled) + &".".repeat(width - filled); + format!( + "{:<8} [{bar}] {:>3.0}% {:>3.0}wpm", + racer.name, + racer.progress * 100.0, + racer.wpm + ) +} + +fn despawn_ui(mut commands: Commands, root: Query<Entity, With<TerminalDuelRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/racer.rs b/crates/steam/src/racer.rs new file mode 100644 index 0000000..790833e --- /dev/null +++ b/crates/steam/src/racer.rs @@ -0,0 +1,66 @@ +use crate::typing::TypingSession; +use bevy::prelude::*; + +/// A participant in a race visual. Decoupled from networking on purpose -- +/// the three visualizations below only ever read this component, so they +/// work identically whether progress comes from a local typing session, a +/// bot, or the real multiplayer connection. +#[derive(Component, Debug, Clone)] +pub struct Racer { + pub name: String, + pub is_local: bool, + /// 0.0 (start) to 1.0 (finished). + pub progress: f32, + pub wpm: f32, + pub accuracy: f32, + pub mistakes: u32, +} + +/// Marks the set of racer entities that belong to the current demo race, so +/// visualization plugins can query just their own participants. +#[derive(Component)] +pub struct DemoRace; + +pub struct DemoProgressPlugin; + +impl Plugin for DemoProgressPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, spawn_demo_racers) + .add_systems(Update, drive_demo_progress); + } +} + +fn spawn_demo_racers(mut commands: Commands) { + // Every other racer on screen comes from multiplayer_plugin.rs, spawned + // and updated from the actual server. This one is "You" - driven for + // real by typing.rs once a race is underway (see update_local_racer), + // and by drive_demo_progress's idle animation before that. + commands.spawn(( + Racer { + name: "You".into(), + is_local: true, + progress: 0.0, + wpm: 0.0, + accuracy: 100.0, + mistakes: 0, + }, + DemoRace, + )); +} + +// Idle animation only, for the brief window before a real race has started +// (typing.rs's TypingSession isn't active yet) - once real keystrokes are +// driving the local racer, this backs off so it doesn't fight that. +fn drive_demo_progress(time: Res<Time>, session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + if session.is_active() { + return; + } + for mut racer in &mut racers { + if !racer.is_local || racer.progress >= 1.0 { + continue; + } + let base_speed = 0.12; + racer.progress = (racer.progress + base_speed * time.delta_secs()).min(1.0); + racer.wpm = 60.0 + (racer.progress * 40.0); + } +} diff --git a/crates/steam/src/typing.rs b/crates/steam/src/typing.rs new file mode 100644 index 0000000..c64a540 --- /dev/null +++ b/crates/steam/src/typing.rs @@ -0,0 +1,216 @@ +// Real typing input for the desktop client, replacing the local racer's +// synthetic progress with actual keystrokes against the passage the server +// sent for the current race. Reuses typerpunk_core::game::Game - the same +// correctness/WPM/accuracy engine the wasm/web build runs - instead of +// reimplementing typing-test logic a third time. +use crate::racer::{DemoRace, Racer}; +use bevy::input::keyboard::{Key, KeyboardInput}; +use bevy::input::ButtonState; +use bevy::prelude::*; +use typerpunk_core::game::Game; + +// Bounded the same way the TUI windows its own text rendering - a +// multiplayer passage could in principle be long, and this keeps the +// number of spawned per-character entities sane regardless. +const MAX_VISIBLE_CHARS: usize = 400; + +#[derive(Resource, Default)] +pub struct TypingSession { + game: Option<Game>, + spans_built: bool, +} + +impl TypingSession { + pub fn start(&mut self, text: String) { + let mut game = Game::new(); + game.set_text(text); + game.start(); + self.game = Some(game); + self.spans_built = false; + } + + pub fn is_active(&self) -> bool { + self.game.as_ref().is_some_and(|g| !g.is_finished()) + } + + /// True once a race's Start message has actually arrived - distinct + /// from is_active(), which also goes false again once finished. Used to + /// gate outgoing Progress messages: sending them while still sitting in + /// the lobby is meaningless (there's no race to report progress on) and + /// floods the connection during the server's countdown at the worst + /// possible moment (see multiplayer_plugin.rs's send_local_progress). + pub fn has_started(&self) -> bool { + self.game.is_some() + } +} + +#[derive(Component)] +struct TypingTextRoot; + +#[derive(Component)] +struct TypingCharSpan(usize); + +#[derive(Component)] +struct TypingStatsText; + +pub struct TypingPlugin; + +impl Plugin for TypingPlugin { + fn build(&self, app: &mut App) { + app.init_resource::<TypingSession>() + .add_systems(Startup, spawn_typing_overlay) + .add_systems( + Update, + (rebuild_spans_on_session_start, handle_keyboard_input, recolor_spans, update_local_racer, update_stats_text).chain(), + ); + } +} + +const CORRECT_COLOR: Color = Color::srgb(0.0, 1.0, 0.62); +const INCORRECT_COLOR: Color = Color::srgb(1.0, 0.3, 0.3); +const PENDING_COLOR: Color = Color::srgb(0.5, 0.5, 0.55); +const CURSOR_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); + +fn spawn_typing_overlay(mut commands: Commands) { + commands.spawn(( + TypingTextRoot, + Text::new(""), + TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(70.0), + left: Val::Px(40.0), + right: Val::Px(40.0), + ..default() + }, + )); + commands.spawn(( + TypingStatsText, + Text::new(""), + TextColor(CORRECT_COLOR), + TextFont { font_size: bevy::text::FontSize::Px(18.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(24.0), + left: Val::Px(40.0), + ..default() + }, + )); +} + +// Spans are spawned once per race (character identities never change for a +// fixed passage) and only recolored afterward - rebuilding text entities +// every frame would be wasteful for no visual benefit over just updating +// TextColor on the ones that already exist. +fn rebuild_spans_on_session_start( + mut commands: Commands, + mut session: ResMut<TypingSession>, + root: Query<Entity, With<TypingTextRoot>>, + old_spans: Query<Entity, With<TypingCharSpan>>, +) { + let has_game = session.game.is_some(); + if !has_game { + if !old_spans.is_empty() { + for e in &old_spans { + commands.entity(e).despawn(); + } + } + return; + } + if session.spans_built { + return; + } + session.spans_built = true; + + for e in &old_spans { + commands.entity(e).despawn(); + } + + let Ok(root_entity) = root.single() else { return }; + let text = session.game.as_ref().unwrap().get_text(); + let children: Vec<Entity> = text + .chars() + .take(MAX_VISIBLE_CHARS) + .enumerate() + .map(|(i, c)| { + commands + .spawn((TypingCharSpan(i), TextSpan(c.to_string()), TextColor(PENDING_COLOR), TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() })) + .id() + }) + .collect(); + commands.entity(root_entity).add_children(&children); +} + +fn handle_keyboard_input(mut events: MessageReader<KeyboardInput>, mut session: ResMut<TypingSession>) { + let Some(game) = session.game.as_mut() else { + events.clear(); + return; + }; + if game.is_finished() { + events.clear(); + return; + } + for ev in events.read() { + if ev.state != ButtonState::Pressed { + continue; + } + match &ev.logical_key { + Key::Character(s) => { + let mut input = game.get_input(); + input.push_str(s); + let _ = game.handle_input(&input); + } + Key::Space => { + let mut input = game.get_input(); + input.push(' '); + let _ = game.handle_input(&input); + } + Key::Backspace => { + let _ = game.handle_backspace(false); + } + _ => {} + } + } +} + +fn recolor_spans(session: Res<TypingSession>, mut spans: Query<(&TypingCharSpan, &mut TextColor)>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count(); + let errors = game.get_error_positions(); + for (span, mut color) in &mut spans { + let i = span.0; + color.0 = if i < input_len { + if errors.contains(&i) { INCORRECT_COLOR } else { CORRECT_COLOR } + } else if i == input_len { + CURSOR_COLOR + } else { + PENDING_COLOR + }; + } +} + +fn update_local_racer(session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count() as f32; + let text_len = (game.get_text().chars().count() as f32).max(1.0); + let progress = (input_len / text_len).min(1.0); + let wpm = game.get_wpm() as f32; + let accuracy = game.get_accuracy() as f32; + for mut racer in &mut racers { + if racer.is_local { + racer.progress = if game.is_finished() { 1.0 } else { progress }; + racer.wpm = wpm; + racer.accuracy = accuracy; + } + } +} + +fn update_stats_text(session: Res<TypingSession>, mut text_query: Query<&mut Text, With<TypingStatsText>>) { + let Ok(mut text) = text_query.single_mut() else { return }; + let Some(game) = &session.game else { + **text = String::new(); + return; + }; + let status = if game.is_finished() { " - finished" } else { "" }; + **text = format!("{:.0} wpm {:.0}% acc{status}", game.get_wpm(), game.get_accuracy()); +} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..a86d055 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "typerpunk-tui" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk" +path = "src/main.rs" + +[features] +default = ["tui"] +tui = ["typerpunk-core/tui"] + +[dependencies] +typerpunk-core = { path = "../core", features = ["tui", "multiplayer"] } +crossterm.workspace = true +ratatui.workspace = true +tokio = { workspace = true, features = ["full"] } +anyhow.workspace = true +config.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +dirs.workspace = true +rlua.workspace = true +rand.workspace = true +tokio-tungstenite.workspace = true +futures-util.workspace = true +reqwest.workspace = true
\ No newline at end of file diff --git a/crates/tui/src/config_file.rs b/crates/tui/src/config_file.rs new file mode 100644 index 0000000..cc9f05f --- /dev/null +++ b/crates/tui/src/config_file.rs @@ -0,0 +1,51 @@ +// Local config file for the TUI's account state - a saved personal-access +// token, not a browser cookie, since a terminal app has no cookie jar. +// Lives at the OS config dir so it survives between runs the same way any +// other CLI tool's saved credentials would. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + pub token: Option<String>, + pub username: Option<String>, +} + +fn config_path() -> Option<PathBuf> { + let mut dir = dirs::config_dir()?; + dir.push("typerpunk"); + Some(dir.join("cli_auth.json")) +} + +pub fn load() -> AuthConfig { + let Some(path) = config_path() else { return AuthConfig::default() }; + let Ok(raw) = std::fs::read_to_string(&path) else { return AuthConfig::default() }; + serde_json::from_str(&raw).unwrap_or_default() +} + +pub fn save(config: &AuthConfig) { + let Some(path) = config_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(config) { + let _ = std::fs::write(&path, json); + } +} + +pub fn server_url() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +/// Same host as server_url(), just ws:// (or wss:// if the HTTP base was +/// already secured) instead of http(s):// - for the multiplayer WebSocket. +pub fn server_ws_url() -> String { + let http = server_url(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..f5c4a68 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,222 @@ +mod config_file; +mod multiplayer_net; +mod net; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use multiplayer_net::{LocalUpdate, MultiplayerConnection, NetEvent as MpNetEvent}; +use net::{NetClient, NetResponse}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; +use std::{io, error::Error as StdError}; +use typerpunk_core::{ + app::{App, MpPlayer, NetworkAction, State}, + input::InputHandler, + ui::draw, +}; + +fn main() -> Result<(), Box<dyn StdError>> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app and run it + let mut app = match App::new() { + Ok(app) => app, + Err(e) => { + cleanup_terminal(&mut terminal)?; + return Err(e); + } + }; + + // A saved token means a returning user doesn't have to log in again -- + // restored eagerly so the main menu's account line reflects it from the + // very first frame. + let saved_auth = config_file::load(); + if let (Some(token), Some(username)) = (saved_auth.token, saved_auth.username) { + app.restore_session(token, username); + } + + let net_client = NetClient::spawn(); + + let mut input_handler = InputHandler::new(app); + let res = run_app(&mut terminal, &mut input_handler, &net_client); + + // Restore terminal + cleanup_terminal(&mut terminal)?; + + if let Err(err) = res { + println!("Error: {:?}", err); + } + + Ok(()) +} + +fn cleanup_terminal<B: ratatui::backend::Backend + std::io::Write>(terminal: &mut Terminal<B>) -> io::Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn apply_net_response(app: &mut App, response: NetResponse) { + match response { + NetResponse::LoginOk { username, token } => { + app.set_login_success(username.clone(), token.clone()); + // Only a real login/register issues a fresh token - persist it + // so the next launch starts already signed in. + if let Some(token) = token { + config_file::save(&config_file::AuthConfig { token: Some(token), username: Some(username) }); + } + } + NetResponse::Error(message) => app.set_net_error(message), + NetResponse::Leaderboard(entries) => app.set_leaderboard(entries), + NetResponse::Friends { list, incoming, outgoing } => app.set_friends(list, incoming, outgoing), + } +} + +// PlayerList arrives every time someone joins/readies/leaves, not just +// once - resetting progress/wpm to zero on every refresh would erase a +// mid-race player's visible position each time the roster is re-sent, so +// existing entries carry theirs forward instead of being rebuilt from +// scratch. +fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multiplayer::PlayerInfo>) -> Vec<MpPlayer> { + incoming + .into_iter() + .map(|p| { + let (progress, wpm) = existing.iter().find(|e| e.id == p.id).map(|e| (e.progress, e.wpm)).unwrap_or((0.0, 0.0)); + MpPlayer { id: p.id, name: p.name, ready: p.ready, progress, wpm } + }) + .collect() +} + +fn apply_mp_event(app: &mut App, event: MpNetEvent) { + match event { + MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code), + MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id), + MpNetEvent::PlayerList(players) => { + let merged = merge_player_list(&app.mp_players, players); + app.set_mp_player_list(merged); + } + MpNetEvent::Countdown(seconds) => app.set_mp_countdown(seconds), + MpNetEvent::Start(text) => app.set_mp_start(text), + MpNetEvent::PlayerProgress { player_id, percent, wpm } => app.set_mp_player_progress(&player_id, percent, wpm), + MpNetEvent::PlayerFinished { player_id, wpm, place } => app.set_mp_player_finished(&player_id, "opponent", wpm, place), + MpNetEvent::RoomClosed(reason) => app.set_mp_room_closed(reason), + MpNetEvent::Error(message) => app.set_mp_error(message), + } +} + +fn run_app<B: ratatui::backend::Backend>( + terminal: &mut Terminal<B>, + input_handler: &mut InputHandler, + net_client: &NetClient, +) -> io::Result<()> { + let mut last_render = std::time::Instant::now(); + let render_interval = std::time::Duration::from_millis(16); // ~60 FPS + let mut mp_connection: Option<MultiplayerConnection> = None; + + loop { + // Update app state to refresh timers and stats + input_handler.app.update(); + + // Drain whatever the network workers have finished since last frame + // - never blocks, since try_recv() only returns what's already + // there. + while let Some(response) = net_client.try_recv() { + apply_net_response(&mut input_handler.app, response); + } + if let Some(conn) = &mp_connection { + while let Some(event) = conn.try_recv() { + apply_mp_event(&mut input_handler.app, event); + } + } + + // Leaving multiplayer entirely (Esc from lobby/race/results resets + // this on the app side) drops the connection here too, so the + // background thread notices its channels are gone and closes the + // socket instead of lingering for the rest of the process. + let in_multiplayer = matches!( + input_handler.app.state, + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults + ); + if !in_multiplayer && mp_connection.is_some() { + mp_connection = None; + } + + // A race that's finished typing (is_finished(), handled inside + // app.rs's own tick) but hasn't yet heard the server confirm it via + // PlayerFinished still needs its Finish message actually sent -- + // driven here every frame rather than per-keypress, since finishing + // is a state the typist arrives at continuously, not a discrete key. + if input_handler.app.state == State::MultiplayerRace && !input_handler.app.mp_finish_sent { + if input_handler.app.is_finished() { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Finish { + wpm: input_handler.app.stats.wpm() as f32, + accuracy: input_handler.app.stats.accuracy() as f32, + time: input_handler.app.stats.elapsed_time().as_secs_f32(), + }); + } + input_handler.app.mp_finish_sent = true; + } else if let Some(conn) = &mp_connection { + let percent = input_handler.app.get_progress() as f32; + let wpm = input_handler.app.stats.wpm() as f32; + conn.send(LocalUpdate::Progress { percent, wpm }); + } + } + + terminal.draw(|f| draw(f, &input_handler.app))?; + + if event::poll(std::time::Duration::from_millis(0))? { + if let Event::Key(key) = event::read()? { + input_handler.app.handle_input(key); + if let Some(action) = input_handler.app.pending_network_action.take() { + match action { + NetworkAction::CreateMultiplayerRoom => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), None, name)); + } + NetworkAction::JoinMultiplayerRoom { code } => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), Some(code), name)); + } + NetworkAction::MultiplayerReady => { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Ready); + } + input_handler.app.net_busy = false; + } + other => net_client.send(other, input_handler.app.auth_token.clone()), + } + } + if input_handler.app.should_exit { + return Ok(()); + } + } + } + + // Limit render rate + let now = std::time::Instant::now(); + if now.duration_since(last_render) < render_interval { + std::thread::sleep(render_interval - now.duration_since(last_render)); + } + last_render = now; + + if input_handler.app.should_exit { + return Ok(()); + } + } +}
\ No newline at end of file diff --git a/crates/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs new file mode 100644 index 0000000..8c6c6b0 --- /dev/null +++ b/crates/tui/src/multiplayer_net.rs @@ -0,0 +1,183 @@ +// Real-time WebSocket networking for TUI Multiplayer, alongside net.rs's +// one-shot REST calls (login/leaderboard/friends). Same background-thread +// pattern as crates/steam/src/net.rs (which speaks the identical wire +// protocol against the same server) - a dedicated OS thread owns a tokio +// runtime and the live connection, bridging to the sync render loop over +// plain std::sync::mpsc channels polled once per frame. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Ready, + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +pub struct MultiplayerConnection { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, + update_tx: mpsc::Sender<LocalUpdate>, +} + +impl MultiplayerConnection { + /// `room_code: None` creates a fresh room (POSTs to the REST endpoint + /// first, then connects); `Some(code)` connects straight to that + /// existing room's WebSocket. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> Self { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start multiplayer network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + Self { event_rx: std::sync::Mutex::new(event_rx), update_tx } + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } + + pub fn send(&self, update: LocalUpdate) { + let _ = self.update_tx.send(update); + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{http_base}/api/multiplayer/rooms")) + .send() + .await + .map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, place, .. } => NetEvent::PlayerFinished { player_id, wpm, place }, + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short + // interval, same pattern as crates/steam/src/net.rs. Short + // enough that a manual "r: ready" keypress feels responsive. + _ = tokio::time::sleep(std::time::Duration::from_millis(80)) => { + loop { + match update_rx.try_recv() { + Ok(update) => { + let client_msg = match update { + LocalUpdate::Ready => ClientMessage::Ready, + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + Err(mpsc::TryRecvError::Empty) => break, + // main.rs dropped its MultiplayerConnection (left the + // room/app) - nothing left to send to, close out + // instead of holding the socket open indefinitely. + Err(mpsc::TryRecvError::Disconnected) => return, + } + } + } + } + } +} diff --git a/crates/tui/src/net.rs b/crates/tui/src/net.rs new file mode 100644 index 0000000..a0e55da --- /dev/null +++ b/crates/tui/src/net.rs @@ -0,0 +1,193 @@ +// Background network worker for the TUI. The render loop in main.rs is a +// plain synchronous 60fps loop (see run_app) - rather than restructure that +// into async, a dedicated OS thread owns its own tokio runtime and a small +// reqwest client, communicating over plain std::sync::mpsc channels. The +// main loop sends a Job after each keypress that queues one, and polls +// try_recv() once per frame to apply whatever came back. +use crate::config_file::server_url; +use serde::Deserialize; +use std::sync::mpsc; +use typerpunk_core::app::{FriendRow, LeaderboardRow, NetworkAction}; + +pub enum NetResponse { + LoginOk { username: String, token: Option<String> }, + Error(String), + Leaderboard(Vec<LeaderboardRow>), + Friends { list: Vec<FriendRow>, incoming: Vec<FriendRow>, outgoing: Vec<FriendRow> }, +} + +struct Job { + action: NetworkAction, + token: Option<String>, +} + +pub struct NetClient { + tx: mpsc::Sender<Job>, + rx: mpsc::Receiver<NetResponse>, +} + +impl NetClient { + pub fn spawn() -> Self { + let (job_tx, job_rx) = mpsc::channel::<Job>(); + let (resp_tx, resp_rx) = mpsc::channel::<NetResponse>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to start TUI network runtime"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build http client"); + let base = server_url(); + + while let Ok(job) = job_rx.recv() { + let response = rt.block_on(run_job(&client, &base, job)); + if resp_tx.send(response).is_err() { + break; // main loop is gone + } + } + }); + + Self { tx: job_tx, rx: resp_rx } + } + + pub fn send(&self, action: NetworkAction, token: Option<String>) { + let _ = self.tx.send(Job { action, token }); + } + + pub fn try_recv(&self) -> Option<NetResponse> { + self.rx.try_recv().ok() + } +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +async fn error_message(resp: reqwest::Response) -> String { + let status = resp.status(); + match resp.json::<ErrorBody>().await { + Ok(body) => body.error, + Err(_) => format!("request failed ({status})"), + } +} + +#[derive(Deserialize)] +struct UserView { + username: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +async fn run_job(client: &reqwest::Client, base: &str, job: Job) -> NetResponse { + match job.action { + NetworkAction::Login { username, password } => login(client, base, &username, &password).await, + NetworkAction::Register { username, password } => { + let register_url = format!("{base}/api/auth/register"); + match client.post(®ister_url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => login(client, base, &username, &password).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchLeaderboard { mode } => { + let url = format!("{base}/api/leaderboard?mode={mode}"); + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<Vec<LeaderboardRow>>().await { + Ok(rows) => NetResponse::Leaderboard(rows), + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchFriends => fetch_friends(client, base, job.token.as_deref()).await, + NetworkAction::SendFriendRequest { username } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/request"); + match client.post(&url).bearer_auth(token).json(&serde_json::json!({ "username": username })).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::AcceptFriendRequest { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}/accept"); + match client.post(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::RemoveFriendship { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}"); + match client.delete(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + // Handled entirely by main.rs's own MultiplayerConnection instead + // (a persistent WebSocket, not a one-shot REST job) - these never + // actually reach this worker; this arm only exists so the match + // stays exhaustive over the shared NetworkAction enum. + NetworkAction::CreateMultiplayerRoom | NetworkAction::JoinMultiplayerRoom { .. } | NetworkAction::MultiplayerReady => { + NetResponse::Error("multiplayer action sent to the wrong network worker".to_string()) + } + } +} + +async fn login(client: &reqwest::Client, base: &str, username: &str, password: &str) -> NetResponse { + let url = format!("{base}/api/auth/token"); + match client.post(&url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<TokenResponse>().await { + Ok(body) => NetResponse::LoginOk { username: body.user.username, token: Some(body.token) }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} + +#[derive(Deserialize)] +struct FriendEntry { + friendship_id: String, + username: String, +} + +#[derive(Deserialize)] +struct FriendsListBody { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn to_rows(entries: Vec<FriendEntry>) -> Vec<FriendRow> { + entries.into_iter().map(|e| FriendRow { friendship_id: e.friendship_id, username: e.username }).collect() +} + +async fn fetch_friends(client: &reqwest::Client, base: &str, token: Option<&str>) -> NetResponse { + let Some(token) = token else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends"); + match client.get(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<FriendsListBody>().await { + Ok(body) => NetResponse::Friends { + list: to_rows(body.friends), + incoming: to_rows(body.incoming_requests), + outgoing: to_rows(body.outgoing_requests), + }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..8092566 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "typerpunk-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["wasm", "serde"] } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +web-sys = { version = "0.3", features = [ + "Document", + "Element", + "HtmlElement", + "Window", + "console", + "HtmlCanvasElement", + "CanvasRenderingContext2d", + "CssStyleDeclaration", + "MouseEvent", + "KeyboardEvent", +] } +serde = { workspace = true } +console_error_panic_hook = "0.1"
\ No newline at end of file diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..abec93b --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,162 @@ +use wasm_bindgen::prelude::*; +use typerpunk_core::game::Game; + +// Re-export TyperPunkGame as TyperPunk +pub use typerpunk_core::wasm::TyperPunkGame as TyperPunk; + +#[wasm_bindgen] +pub struct TyperPunkGame { + game: Option<Game>, +} + +#[wasm_bindgen] +impl TyperPunkGame { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + game: Some(Game::new()), + } + } + + #[wasm_bindgen] + pub fn set_text(&mut self, text: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let text_str = match std::str::from_utf8(text.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 text")), + }; + + game.set_text(text_str); + Ok(()) + } + + #[wasm_bindgen] + pub fn get_text(&self) -> String { + self.game.as_ref() + .map(|game| game.get_text()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn get_input(&self) -> String { + self.game.as_ref() + .map(|game| game.get_input()) + .unwrap_or_default() + } + + #[wasm_bindgen] + pub fn handle_input(&mut self, input: &str) -> Result<(), JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Create a new owned string and validate UTF-8 + let input_str = match std::str::from_utf8(input.as_bytes()) { + Ok(s) => s.to_string(), + Err(_) => return Err(JsValue::from_str("Invalid UTF-8 input")), + }; + + // Process input + game.handle_input(&input_str) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn handle_backspace(&mut self, ctrl: bool) -> Result<bool, JsValue> { + let game = self.game.as_mut() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + if !game.can_backspace() { + return Ok(false); + } + + // Check if ctrl+backspace is allowed + if ctrl && !game.can_ctrl_backspace() { + return Ok(false); + } + + // Perform backspace + game.handle_backspace(ctrl) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[wasm_bindgen] + pub fn get_stats(&self) -> Result<JsValue, JsValue> { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + let (accuracy, mistakes) = game.get_stats() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let array = js_sys::Array::new(); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn get_stats_and_input(&self) -> Result<JsValue, JsValue> { + let game = self.game.as_ref() + .ok_or_else(|| JsValue::from_str("Game not initialized"))?; + + // Get input first to avoid recursive use + let input = game.get_input(); + + // Then get stats + let (_, accuracy, mistakes) = game.get_stats_and_input() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let array = js_sys::Array::new(); + array.push(&JsValue::from_str(&input)); + array.push(&JsValue::from_f64(accuracy)); + array.push(&JsValue::from_f64(mistakes as f64)); + Ok(array.into()) + } + + #[wasm_bindgen] + pub fn is_finished(&self) -> bool { + // Delegates to the core Game's own is_finished() instead of a second, + // independent copy of the completion check - this used to + // re-implement the (then-current) logic by hand, which meant the + // exact-match-required bug fixed in core::game::Game had to be fixed + // here separately too, and wasn't: this copy kept requiring an exact + // match long after the core was fixed to be length-only, silently + // freezing every test with an uncorrected mistake anywhere in it. + self.game.as_ref() + .map(|game| game.is_finished()) + .unwrap_or(false) + } + + #[wasm_bindgen] + pub fn start(&mut self) { + if let Some(game) = &mut self.game { + game.start(); + } + } + + #[wasm_bindgen] + pub fn get_wpm(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_wpm()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn get_time_elapsed(&self) -> f64 { + self.game.as_ref() + .map(|game| game.get_time_elapsed()) + .unwrap_or(0.0) + } + + #[wasm_bindgen] + pub fn free(&mut self) { + self.game = None; + } + +} + +#[wasm_bindgen] +pub fn init() { + console_error_panic_hook::set_once(); +}
\ No newline at end of file diff --git a/data/packs/business.json b/data/packs/business.json new file mode 100644 index 0000000..627c6f0 --- /dev/null +++ b/data/packs/business.json @@ -0,0 +1,17 @@ +[ + {"category":"business","content":"Price is what you pay, and value is what you get.","attribution":"Warren Buffett"}, + {"category":"business","content":"The purpose of a business is to create and keep a customer.","attribution":"Peter Drucker"}, + {"category":"business","content":"Henry Ford, founder of the Ford Motor Company, is credited with popularizing the moving assembly line, which dramatically reduced the time needed to build an automobile.","attribution":"History of Business"}, + {"category":"business","content":"A balance sheet summarizes what a company owns and owes at a single point in time, split into assets, liabilities, and shareholders' equity.","attribution":"Business"}, + {"category":"business","content":"Supply and demand describes how the price of a good tends to rise when demand exceeds supply, and fall when supply exceeds demand.","attribution":"Business"}, + {"category":"business","content":"A startup's burn rate measures how quickly it spends its available cash, an important number for founders trying to plan how long the company can operate before running out of money.","attribution":"Business"}, + {"category":"business","content":"Diversification spreads investment risk across different assets, so that a poor outcome in one area does not sink an entire portfolio.","attribution":"Business"}, + {"category":"business","content":"Branding is the practice of shaping how customers perceive a company, going beyond a logo to include tone, values, and the overall experience of using a product.","attribution":"Business"}, + {"category":"business","content":"A merger combines two companies into one, while an acquisition occurs when one company purchases and absorbs another, though the two terms are often used loosely in everyday conversation.","attribution":"Business"}, + {"category":"business","content":"Cash flow tracks the actual movement of money into and out of a business, which can differ significantly from reported profit due to timing differences in payments.","attribution":"Business"}, + {"category":"business","content":"The man who dies rich, dies disgraced.","attribution":"Andrew Carnegie"}, + {"category":"business","content":"Market research helps a business understand its customers' needs and preferences before committing resources to a new product or service.","attribution":"Business"}, + {"category":"business","content":"A monopoly exists when a single company dominates an entire market with no significant competition, often leading regulators to intervene to protect consumers.","attribution":"Business"}, + {"category":"business","content":"Return on investment measures the profitability of an investment relative to its cost, expressed as a percentage to make it easy to compare different opportunities.","attribution":"Business"}, + {"category":"business","content":"Customer obsession, rather than competitor focus, is the surest path to long term success.","attribution":"Jeff Bezos"} +] diff --git a/data/packs/general.json b/data/packs/general.json new file mode 100644 index 0000000..9fb15c0 --- /dev/null +++ b/data/packs/general.json @@ -0,0 +1,22 @@ +[ + {"category":"general","content":"The quick brown fox jumps over the lazy dog.","attribution":"Traditional pangram"}, + {"category":"general","content":"Simplicity is the soul of efficiency.","attribution":"Austin Freeman"}, + {"category":"general","content":"The Pacific Ocean covers more of the planet than all the continents combined. It stretches from the icy waters near Antarctica to the Arctic Circle, and its deepest point, the Mariana Trench, plunges nearly eleven kilometers below the surface.","attribution":"General knowledge"}, + {"category":"general","content":"Honey never spoils if it is stored properly. Archaeologists have found pots of honey in ancient Egyptian tombs that are thousands of years old and still perfectly edible, preserved by its low moisture content and natural acidity.","attribution":"General knowledge"}, + {"category":"general","content":"A single bolt of lightning can heat the surrounding air to temperatures hotter than the surface of the sun. The rapid expansion of that superheated air is what produces the crack of thunder we hear moments later.","attribution":"General knowledge"}, + {"category":"general","content":"Octopuses have three hearts and blue blood. Two of the hearts pump blood to the gills, while the third circulates it to the rest of the body, and the blue color comes from a copper-based protein instead of iron.","attribution":"General knowledge"}, + {"category":"general","content":"The Great Wall of China was not built all at once. It was constructed and extended over many centuries by different dynasties, each adding new sections and rebuilding older ones to defend against invasions from the north.","attribution":"General knowledge"}, + {"category":"general","content":"Bamboo is one of the fastest growing plants on Earth. Some species can grow nearly a meter in a single day under the right conditions, which is why it is used so widely in construction and manufacturing across Asia.","attribution":"General knowledge"}, + {"category":"general","content":"A day on Venus is longer than its year. The planet rotates so slowly on its axis that it takes longer to spin around once than it does to complete an entire orbit around the sun.","attribution":"General knowledge"}, + {"category":"general","content":"The invention of the printing press in the fifteenth century transformed how knowledge spread across Europe. Books that once took months to copy by hand could suddenly be reproduced by the hundreds, making literacy far more accessible.","attribution":"General knowledge"}, + {"category":"general","content":"Sharks existed long before trees. Fossil evidence suggests that sharks have been swimming in the oceans for more than four hundred million years, while the earliest trees did not appear until tens of millions of years later.","attribution":"General knowledge"}, + {"category":"general","content":"Antarctica is technically the largest desert on the planet. Despite being covered in ice, it receives very little precipitation each year, which is the defining characteristic of a desert rather than temperature alone.","attribution":"General knowledge"}, + {"category":"general","content":"The first computer programmer is widely considered to be Ada Lovelace, who wrote an algorithm for an early mechanical computer in the eighteen forties, decades before any machine capable of running it was ever built.","attribution":"General knowledge"}, + {"category":"general","content":"The human brain generates enough electricity to power a small light bulb. Billions of neurons firing electrical signals at once produce roughly the same wattage as a dim household lamp.","attribution":"General knowledge"}, + {"category":"general","content":"The Eiffel Tower grows about six inches taller in the summer, as the iron structure expands slightly in the heat and contracts again once the weather cools.","attribution":"General knowledge"}, + {"category":"general","content":"A group of flamingos is called a flamboyance. The birds get their pink color from the pigments in the shrimp and algae they eat.","attribution":"General knowledge"}, + {"category":"general","content":"Venus is the hottest planet in the solar system, even though Mercury sits closer to the sun. A thick blanket of carbon dioxide traps heat at the surface.","attribution":"General knowledge"}, + {"category":"general","content":"The shortest war in recorded history was fought between Britain and Zanzibar in 1896. It lasted around forty minutes before Zanzibar surrendered.","attribution":"General knowledge"}, + {"category":"general","content":"Bananas are botanically classified as berries, while strawberries are not. The classification depends on how the fruit develops from the flower, not on taste or size.","attribution":"General knowledge"}, + {"category":"general","content":"The Amazon rainforest produces roughly twenty percent of the oxygen found in the planet's atmosphere, earning it the nickname the lungs of the Earth.","attribution":"General knowledge"} +] diff --git a/data/packs/history.json b/data/packs/history.json new file mode 100644 index 0000000..45c16b1 --- /dev/null +++ b/data/packs/history.json @@ -0,0 +1,16 @@ +[ + {"category":"history","content":"The printing press, developed by Johannes Gutenberg in the fifteenth century, made it possible to produce books far more quickly and cheaply than copying them by hand.","attribution":"History"}, + {"category":"history","content":"The Roman Empire at its height stretched from Britain in the north to Egypt in the south, connected by a vast network of roads that still exist in parts of Europe today.","attribution":"History"}, + {"category":"history","content":"The fall of the Berlin Wall in 1989 marked a turning point in the Cold War, as East and West Germany moved toward reunification the following year.","attribution":"History"}, + {"category":"history","content":"The signing of the Magna Carta in 1215 limited the power of the English king and established the principle that even a monarch was subject to the law.","attribution":"History"}, + {"category":"history","content":"The Silk Road was not a single road but a network of trade routes connecting China to the Mediterranean, carrying silk, spices, and ideas across the ancient world.","attribution":"History"}, + {"category":"history","content":"The Wright brothers achieved the first powered, controlled flight in 1903 near Kitty Hawk, North Carolina, a flight that lasted only twelve seconds.","attribution":"History"}, + {"category":"history","content":"Ancient Egyptian civilization flourished along the Nile River for thousands of years, leaving behind monuments such as the pyramids of Giza and the temples of Luxor.","attribution":"History"}, + {"category":"history","content":"The Renaissance began in Italy in the fourteenth century, sparking a renewed interest in classical art, science, and learning that eventually spread across Europe.","attribution":"History"}, + {"category":"history","content":"The Apollo 11 mission landed the first humans on the Moon in July of 1969, when astronauts Neil Armstrong and Buzz Aldrin walked on the lunar surface.","attribution":"History"}, + {"category":"history","content":"The Industrial Revolution transformed economies across Europe and North America during the eighteenth and nineteenth centuries, shifting production from hand tools to machines.","attribution":"History"}, + {"category":"history","content":"The Great Depression of the 1930s began with the stock market crash of 1929 and led to widespread unemployment and economic hardship across the globe.","attribution":"History"}, + {"category":"history","content":"The invention of the wheel around 3500 BCE is considered one of the most important technological breakthroughs in human history, transforming transportation and trade.","attribution":"History"}, + {"category":"history","content":"I will fight no more forever.","attribution":"Chief Joseph, Nez Perce, 1877"}, + {"category":"history","content":"The Cold War divided much of the world into two opposing blocs for nearly half a century, one aligned with the United States and the other with the Soviet Union.","attribution":"History"} +] diff --git a/data/packs/literature.json b/data/packs/literature.json new file mode 100644 index 0000000..a7a1cae --- /dev/null +++ b/data/packs/literature.json @@ -0,0 +1,25 @@ +[ + {"category":"literature","content":"It was the best of times, it was the worst of times.","attribution":"Charles Dickens, A Tale of Two Cities"}, + {"category":"literature","content":"All we have to decide is what to do with the time that is given us.","attribution":"J.R.R. Tolkien, The Fellowship of the Ring"}, + {"category":"literature","content":"Not all those who wander are lost.","attribution":"J.R.R. Tolkien"}, + {"category":"literature","content":"It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families.","attribution":"Jane Austen, Pride and Prejudice"}, + {"category":"literature","content":"Call me Ishmael. Some years ago, never mind how long precisely, having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.","attribution":"Herman Melville, Moby-Dick"}, + {"category":"literature","content":"It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness.","attribution":"Charles Dickens, A Tale of Two Cities"}, + {"category":"literature","content":"Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do. Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.","attribution":"Lewis Carroll, Alice's Adventures in Wonderland"}, + {"category":"literature","content":"It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet.","attribution":"Mary Shelley, Frankenstein"}, + {"category":"literature","content":"To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex.","attribution":"Arthur Conan Doyle, A Scandal in Bohemia"}, + {"category":"literature","content":"I read that every known superstition in the world is gathered into the horseshoe of the Carpathians, as if it were the centre of some sort of imaginative whirlpool, and it made me uneasy in a way I could not explain.","attribution":"Bram Stoker, Dracula"}, + {"category":"literature","content":"The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn.","attribution":"Oscar Wilde, The Picture of Dorian Gray"}, + {"category":"literature","content":"The Nellie, a cruising yawl, swung to her anchor without a flutter of the sails, and was at rest. The flood had made, the wind was nearly calm, and being bound down the river, the only thing for it was to come to and wait for the turn of the tide.","attribution":"Joseph Conrad, Heart of Darkness"}, + {"category":"literature","content":"You don't know about me without you have read a book by the name of The Adventures of Tom Sawyer, but that ain't no matter. That book was made by Mr. Mark Twain, and he told the truth, mainly.","attribution":"Mark Twain, Adventures of Huckleberry Finn"}, + {"category":"literature","content":"There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning, but since dinner the cold winter wind had brought with it clouds so sombre, and a rain so penetrating.","attribution":"Charlotte Bronte, Jane Eyre"}, + {"category":"literature","content":"The Time Traveller, for so it will be convenient to speak of him, was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated.","attribution":"H.G. Wells, The Time Machine"}, + {"category":"literature","content":"My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip.","attribution":"Charles Dickens, Great Expectations"}, + {"category":"literature","content":"In my younger and more vulnerable years my father gave me some advice that I've been turning over in my mind ever since. Whenever you feel like criticizing anyone, he told me, just remember that all the people in this world haven't had the advantages that you've had.","attribution":"F. Scott Fitzgerald, The Great Gatsby"}, + {"category":"literature","content":"1801. I have just returned from a visit to my landlord, the solitary neighbour that I shall be troubled with. This is certainly a beautiful country. In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society.","attribution":"Emily Bronte, Wuthering Heights"}, + {"category":"literature","content":"Christmas won't be Christmas without any presents, grumbled Jo, lying on the rug. It's so dreadful to be poor, sighed Meg, looking down at her old dress.","attribution":"Louisa May Alcott, Little Women"}, + {"category":"literature","content":"The year 1866 was signalised by a remarkable incident, a mysterious and inexplicable phenomenon, which doubtless no one has yet forgotten. Ships had met with a huge thing, a long object, spindle-shaped, and at times phosphorescent.","attribution":"Jules Verne, Twenty Thousand Leagues Under the Sea"}, + {"category":"literature","content":"Buck did not read the newspapers, or he would have known that trouble was brewing, not alone for himself, but for every tide-water dog, strong of muscle and with warm, long hair, from Puget Sound to San Diego.","attribution":"Jack London, The Call of the Wild"}, + {"category":"literature","content":"A throng of bearded men, in sad-colored garments and gray, steeple-crowned hats, intermixed with women, some wearing hoods, and others bareheaded, was assembled in front of a wooden edifice.","attribution":"Nathaniel Hawthorne, The Scarlet Letter"}, + {"category":"literature","content":"Mr. Sherlock Holmes, who was usually very late in the mornings, save upon those not infrequent occasions when he was up all night, was seated at the breakfast table.","attribution":"Arthur Conan Doyle, The Hound of the Baskervilles"} +] diff --git a/data/packs/movies.json b/data/packs/movies.json new file mode 100644 index 0000000..bc56cad --- /dev/null +++ b/data/packs/movies.json @@ -0,0 +1,17 @@ +[ + {"category":"movies","content":"The first feature length narrative film, The Story of the Kelly Gang, was made in Australia in 1906 and ran for about an hour.","attribution":"Film"}, + {"category":"movies","content":"The Wizard of Oz was one of the first major films to use Technicolor extensively, transitioning from black and white to vivid color once Dorothy arrives in Oz.","attribution":"Film"}, + {"category":"movies","content":"Alfred Hitchcock was known for making brief cameo appearances in nearly all of his own films, a habit that became something of a signature.","attribution":"Film"}, + {"category":"movies","content":"The special effects in the original Star Wars trilogy were largely achieved with physical models and practical effects, long before computer generated imagery became common.","attribution":"Film"}, + {"category":"movies","content":"Citizen Kane, released in 1941, is frequently cited by critics and filmmakers as one of the greatest films ever made, praised for its innovative camera work and narrative structure.","attribution":"Film"}, + {"category":"movies","content":"The word cinema comes from the Greek word for movement, reflecting how early filmmakers were fascinated by the illusion of motion created by a sequence of still images.","attribution":"Film"}, + {"category":"movies","content":"Pixar's first feature film, Toy Story, released in 1995, was the first full length movie created entirely with computer animation.","attribution":"Film"}, + {"category":"movies","content":"The Academy Awards, commonly known as the Oscars, have been presented annually since 1929 to honor achievements in the film industry.","attribution":"Film"}, + {"category":"movies","content":"Silent films relied on live musicians and title cards to convey dialogue and emotion, since synchronized sound was not introduced into feature films until the late 1920s.","attribution":"Film"}, + {"category":"movies","content":"A film's score is often composed after the movie has been edited, allowing the composer to match the music precisely to the pacing and mood of each scene.","attribution":"Film"}, + {"category":"movies","content":"The highest grossing films of all time are dominated by franchises built around superheroes, fantasy worlds, and long running science fiction universes.","attribution":"Film"}, + {"category":"movies","content":"Stop motion animation creates the illusion of movement by photographing physical objects one frame at a time and repositioning them slightly between each shot.","attribution":"Film"}, + {"category":"movies","content":"The Cannes Film Festival, held annually in France since 1946, is considered one of the most prestigious film festivals in the world.","attribution":"Film"}, + {"category":"movies","content":"Method acting, popularized in the mid twentieth century, encourages performers to draw on personal memories and emotions to portray a character as realistically as possible.","attribution":"Film"}, + {"category":"movies","content":"A film's runtime is typically decided during editing, where an editor works with hours of raw footage to shape a coherent story within a manageable length.","attribution":"Film"} +] diff --git a/data/packs/nature.json b/data/packs/nature.json new file mode 100644 index 0000000..4bb274f --- /dev/null +++ b/data/packs/nature.json @@ -0,0 +1,17 @@ +[ + {"category":"nature","content":"The world is big and I want to have a good look at it before it gets dark.","attribution":"John Muir"}, + {"category":"nature","content":"In every walk with nature one receives far more than he seeks.","attribution":"John Muir"}, + {"category":"nature","content":"Heaven is under our feet as well as over our heads.","attribution":"Henry David Thoreau, Walden"}, + {"category":"nature","content":"The Amazon rainforest is home to an estimated ten percent of all known species on Earth, making it one of the most biodiverse regions on the planet.","attribution":"Nature"}, + {"category":"nature","content":"Coral reefs cover less than one percent of the ocean floor, yet they support roughly a quarter of all marine species.","attribution":"Nature"}, + {"category":"nature","content":"Migratory birds can travel thousands of miles between breeding and wintering grounds, navigating using the sun, stars, and the Earth's magnetic field.","attribution":"Nature"}, + {"category":"nature","content":"A single mature tree can absorb around twenty two kilograms of carbon dioxide per year, making forests an important part of regulating the planet's climate.","attribution":"Nature"}, + {"category":"nature","content":"Bees pollinate roughly a third of the crops humans rely on for food, making them essential to global agriculture despite their small size.","attribution":"Nature"}, + {"category":"nature","content":"The deepest part of the ocean, the Mariana Trench, plunges nearly eleven kilometers below the surface, deeper than Mount Everest is tall.","attribution":"Nature"}, + {"category":"nature","content":"Deserts are defined by low precipitation rather than temperature, which is why some of the coldest places on Earth, including parts of Antarctica, are technically deserts.","attribution":"Nature"}, + {"category":"nature","content":"Mycorrhizal fungi form partnerships with the roots of most plants, helping them absorb water and nutrients in exchange for sugars produced by photosynthesis.","attribution":"Nature"}, + {"category":"nature","content":"Wolves live and hunt in structured packs, usually led by a breeding pair, and communicate through howls that can be heard for several kilometers.","attribution":"Nature"}, + {"category":"nature","content":"The Sahara Desert was once a green landscape with lakes and grasslands, before a shift in the Earth's orbit altered rainfall patterns thousands of years ago.","attribution":"Nature"}, + {"category":"nature","content":"Rivers shape the landscape around them over thousands of years, carving canyons and depositing sediment that forms fertile floodplains.","attribution":"Nature"}, + {"category":"nature","content":"A rainforest canopy can be so dense that less than two percent of sunlight reaches the forest floor below.","attribution":"Nature"} +] diff --git a/data/packs/philosophy.json b/data/packs/philosophy.json new file mode 100644 index 0000000..7c9aee7 --- /dev/null +++ b/data/packs/philosophy.json @@ -0,0 +1,17 @@ +[ + {"category":"philosophy","content":"Waste no more time arguing about what a good man should be. Be one.","attribution":"Marcus Aurelius, Meditations"}, + {"category":"philosophy","content":"It's not what happens to you, but how you react to it that matters.","attribution":"Epictetus"}, + {"category":"philosophy","content":"It is not that we have a short time to live, but that we waste a lot of it.","attribution":"Seneca, On the Shortness of Life"}, + {"category":"philosophy","content":"The beginning is the most important part of the work.","attribution":"Plato, The Republic"}, + {"category":"philosophy","content":"All men by nature desire to know.","attribution":"Aristotle, Metaphysics"}, + {"category":"philosophy","content":"I think, therefore I am.","attribution":"Rene Descartes, Discourse on the Method"}, + {"category":"philosophy","content":"It does not matter how slowly you go as long as you do not stop.","attribution":"Confucius, Analects"}, + {"category":"philosophy","content":"A journey of a thousand miles begins with a single step.","attribution":"Lao Tzu, Tao Te Ching"}, + {"category":"philosophy","content":"The only true wisdom is in knowing you know nothing.","attribution":"Socrates"}, + {"category":"philosophy","content":"Stoicism is a school of ancient Greek philosophy that teaches the practice of virtue as the only true good, and encourages focusing only on what lies within one's own control.","attribution":"Philosophy"}, + {"category":"philosophy","content":"Existentialism explores questions about individual freedom, choice, and meaning, arguing that people must create their own purpose in a universe that does not provide one automatically.","attribution":"Philosophy"}, + {"category":"philosophy","content":"The Socratic method uses a series of questions and answers to expose contradictions in a person's beliefs, encouraging deeper critical thinking rather than simply providing answers.","attribution":"Philosophy"}, + {"category":"philosophy","content":"The impediment to action advances action. What stands in the way becomes the way.","attribution":"Marcus Aurelius, Meditations"}, + {"category":"philosophy","content":"Every new beginning comes from some other beginning's end.","attribution":"Seneca, Letters from a Stoic"}, + {"category":"philosophy","content":"Utilitarianism holds that the right action is the one that produces the greatest good for the greatest number of people, judging morality by outcomes rather than intentions.","attribution":"Philosophy"} +] diff --git a/data/packs/programming.json b/data/packs/programming.json new file mode 100644 index 0000000..4384c6b --- /dev/null +++ b/data/packs/programming.json @@ -0,0 +1,28 @@ +[ + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"const total = items.reduce((acc, item) => acc + item.price, 0);"}, + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"const unique = [...new Set(values)].sort((a, b) => a - b);"}, + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"export async function load(url) { const res = await fetch(url); return res.json(); }"}, + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"document.querySelectorAll('.row').forEach(el => el.classList.toggle('active'));"}, + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"if (typeof value !== 'string' || value.length === 0) { return null; }"}, + {"category":"programming","language":"javascript","attribution":"JavaScript","content":"const { id, name = 'anonymous', ...rest } = await request.json();"}, + {"category":"programming","language":"python","attribution":"Python","content":"squares = [n * n for n in range(1, 21) if n % 3 != 0]"}, + {"category":"programming","language":"python","attribution":"Python","content":"def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)"}, + {"category":"programming","language":"python","attribution":"Python","content":"with open('data.csv', encoding='utf-8') as f: rows = [line.split(',') for line in f]"}, + {"category":"programming","language":"python","attribution":"Python","content":"counts = {word: text.count(word) for word in set(text.split())}"}, + {"category":"programming","language":"python","attribution":"Python","content":"try: value = int(raw) except ValueError: value = 0"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"let mut counts: HashMap<String, usize> = HashMap::new();"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"fn main() { println!(\"{}\", args().skip(1).collect::<Vec<_>>().join(\" \")); }"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"let total: i64 = values.iter().filter(|v| **v > 0).sum();"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"match result { Ok(v) => println!(\"{v}\"), Err(e) => eprintln!(\"error: {e}\") }"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"pub struct Point { pub x: f64, pub y: f64 }"}, + {"category":"programming","language":"rust","attribution":"Rust","content":"impl Display for Token { fn fmt(&self, f: &mut Formatter) -> Result { write!(f, \"{}\", self.0) } }"}, + {"category":"programming","language":"clike","attribution":"C","content":"int *buf = malloc(len * sizeof(int)); if (!buf) return -1;"}, + {"category":"programming","language":"clike","attribution":"C","content":"for (int i = 0; i < n; i++) { sum += a[i] * b[i]; }"}, + {"category":"programming","language":"clike","attribution":"C","content":"struct node { int value; struct node *next; };"}, + {"category":"programming","language":"clike","attribution":"Go","content":"for i, v := range items { fmt.Printf(\"%d: %v\\n\", i, v) }"}, + {"category":"programming","language":"clike","attribution":"Go","content":"if err != nil { return fmt.Errorf(\"read config: %w\", err) }"}, + {"category":"programming","language":"clike","attribution":"Java","content":"public static void main(String[] args) { System.out.println(\"ready\"); }"}, + {"category":"programming","language":"shell","attribution":"Shell","content":"grep -rn \"TODO\" src/ | awk -F: '{print $1}' | sort -u"}, + {"category":"programming","language":"shell","attribution":"Shell","content":"for f in *.log; do gzip -9 \"$f\" && echo \"packed $f\"; done"}, + {"category":"programming","language":"shell","attribution":"Shell","content":"export PATH=\"$HOME/.local/bin:$PATH\" && command -v rg >/dev/null || echo missing"} +] diff --git a/data/packs/quotes.json b/data/packs/quotes.json new file mode 100644 index 0000000..c727b81 --- /dev/null +++ b/data/packs/quotes.json @@ -0,0 +1,19 @@ +[ + {"category":"quotes","content":"The only limit to our realization of tomorrow is our doubts of today.","attribution":"Franklin D. Roosevelt"}, + {"category":"quotes","content":"It always seems impossible until it's done.","attribution":"Nelson Mandela"}, + {"category":"quotes","content":"Stay hungry, stay foolish.","attribution":"Steve Jobs"}, + {"category":"quotes","content":"Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.","attribution":"Abraham Lincoln, The Gettysburg Address"}, + {"category":"quotes","content":"You have power over your mind, not outside events. Realize this, and you will find strength.","attribution":"Marcus Aurelius, Meditations"}, + {"category":"quotes","content":"I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived.","attribution":"Henry David Thoreau, Walden"}, + {"category":"quotes","content":"It is not the critic who counts; not the man who points out how the strong man stumbles. The credit belongs to the man who is actually in the arena, whose face is marred by dust and sweat and blood.","attribution":"Theodore Roosevelt, Citizenship in a Republic"}, + {"category":"quotes","content":"If there is no struggle, there is no progress. Those who profess to favor freedom, and yet depreciate agitation, are men who want crops without plowing up the ground.","attribution":"Frederick Douglass"}, + {"category":"quotes","content":"The two most important days in your life are the day you are born and the day you find out why.","attribution":"Mark Twain"}, + {"category":"quotes","content":"To be great is to be misunderstood.","attribution":"Ralph Waldo Emerson, Self-Reliance"}, + {"category":"quotes","content":"Whatever you are, be a good one.","attribution":"Abraham Lincoln"}, + {"category":"quotes","content":"If I have seen further, it is by standing on the shoulders of giants.","attribution":"Isaac Newton"}, + {"category":"quotes","content":"Genius is one percent inspiration and ninety-nine percent perspiration.","attribution":"Thomas Edison"}, + {"category":"quotes","content":"That man over there says that women need to be helped into carriages, and lifted over ditches, and to have the best place everywhere. Nobody ever helps me into carriages, or over mud puddles, or gives me any best place, and ain't I a woman?","attribution":"Sojourner Truth, Ain't I a Woman?"}, + {"category":"quotes","content":"Failure is impossible.","attribution":"Susan B. Anthony"}, + {"category":"quotes","content":"Success is to be measured not so much by the position that one has reached in life as by the obstacles which he has overcome while trying to succeed.","attribution":"Booker T. Washington"}, + {"category":"quotes","content":"Our greatest glory is not in never falling, but in rising every time we fall.","attribution":"Confucius"} +] diff --git a/data/packs/science.json b/data/packs/science.json new file mode 100644 index 0000000..f105138 --- /dev/null +++ b/data/packs/science.json @@ -0,0 +1,17 @@ +[ + {"category":"science","content":"Photosynthesis is the process by which plants convert light energy into chemical energy, using carbon dioxide and water to produce glucose and release oxygen as a byproduct.","attribution":"Science"}, + {"category":"science","content":"Newton's first law of motion states that an object at rest stays at rest, and an object in motion stays in motion, unless acted on by an outside force.","attribution":"Science"}, + {"category":"science","content":"DNA carries the genetic instructions for every living organism, encoded in a sequence of four chemical bases that pair together to form the famous double helix shape.","attribution":"Science"}, + {"category":"science","content":"The theory of plate tectonics explains how the Earth's outer shell is broken into large plates that slowly drift over the mantle, causing earthquakes, volcanoes, and mountain formation.","attribution":"Science"}, + {"category":"science","content":"Water is one of the few substances that becomes less dense as it freezes, which is why ice floats on top of liquid water instead of sinking to the bottom.","attribution":"Science"}, + {"category":"science","content":"A black hole forms when a massive star collapses under its own gravity, packing so much mass into so small a space that not even light can escape its pull.","attribution":"Science"}, + {"category":"science","content":"Vaccines work by training the immune system to recognize a weakened or partial version of a pathogen, so the body can respond quickly if it ever encounters the real threat.","attribution":"Science"}, + {"category":"science","content":"The periodic table arranges chemical elements by increasing atomic number, grouping elements with similar properties into the same columns.","attribution":"Science"}, + {"category":"science","content":"Sound travels as a wave of pressure through a medium such as air or water, and it cannot travel through the vacuum of space, since there are no particles to carry the vibration.","attribution":"Science"}, + {"category":"science","content":"Evolution by natural selection describes how organisms with traits better suited to their environment tend to survive and reproduce more successfully, gradually shaping a population over many generations.","attribution":"Science, after Charles Darwin"}, + {"category":"science","content":"The speed of light in a vacuum is approximately two hundred ninety nine thousand, seven hundred ninety two kilometers per second, and nothing with mass can reach or exceed it.","attribution":"Science"}, + {"category":"science","content":"Mitochondria convert nutrients into a usable form of energy for the cell, which is why biology textbooks often call them the powerhouse of the cell.","attribution":"Science"}, + {"category":"science","content":"The human body contains roughly thirty seven trillion cells, each one specialized to carry out one of hundreds of different jobs inside the body.","attribution":"Science"}, + {"category":"science","content":"Marie Curie remains the only person to win Nobel Prizes in two different sciences, physics and chemistry, for her pioneering research into radioactivity.","attribution":"Science"}, + {"category":"science","content":"Genetics research has shown that humans share about ninety-nine percent of their DNA with chimpanzees, the closest living relative to our species.","attribution":"Science"} +] diff --git a/data/packs/technology.json b/data/packs/technology.json new file mode 100644 index 0000000..fd12785 --- /dev/null +++ b/data/packs/technology.json @@ -0,0 +1,17 @@ +[ + {"category":"technology","content":"The World Wide Web was invented by Tim Berners-Lee in 1989 as a way to share documents over the internet using hyperlinks.","attribution":"Technology"}, + {"category":"technology","content":"A central processing unit executes the instructions of a computer program by performing basic arithmetic, logic, and input and output operations.","attribution":"Technology"}, + {"category":"technology","content":"Cloud computing allows businesses and individuals to rent computing power and storage over the internet instead of maintaining their own physical servers.","attribution":"Technology"}, + {"category":"technology","content":"Artificial intelligence refers to computer systems designed to perform tasks that normally require human intelligence, such as recognizing speech, images, or patterns in data.","attribution":"Technology"}, + {"category":"technology","content":"A firewall monitors and controls incoming and outgoing network traffic based on predetermined security rules, acting as a barrier between a trusted network and an untrusted one.","attribution":"Technology"}, + {"category":"technology","content":"The first practical smartphones combined a mobile phone with computing features such as email, web browsing, and applications, changing how people communicate and work.","attribution":"Technology"}, + {"category":"technology","content":"Open source software is released with its source code publicly available, allowing anyone to view, modify, and distribute it under the terms of its license.","attribution":"Technology"}, + {"category":"technology","content":"A blockchain is a distributed ledger that records transactions across many computers, making it difficult to alter any single record without changing every subsequent block.","attribution":"Technology"}, + {"category":"technology","content":"Quantum computing uses the principles of quantum mechanics to process information in ways that classical computers cannot, potentially solving certain problems far faster.","attribution":"Technology"}, + {"category":"technology","content":"The transistor, invented in 1947, replaced bulky vacuum tubes and became the foundational building block of nearly every modern electronic device.","attribution":"Technology"}, + {"category":"technology","content":"Machine learning is a branch of artificial intelligence in which a system improves its performance on a task by learning patterns from data rather than following explicit instructions.","attribution":"Technology"}, + {"category":"technology","content":"Fiber optic cables transmit data as pulses of light through thin strands of glass, allowing information to travel at extremely high speeds over long distances.","attribution":"Technology"}, + {"category":"technology","content":"A virtual private network encrypts a device's internet connection, helping to protect data from being intercepted while it travels over a public or untrusted network.","attribution":"Technology"}, + {"category":"technology","content":"Moore's Law observed that the number of transistors on a computer chip tends to double roughly every two years, driving decades of rapid improvement in computing power.","attribution":"Technology"}, + {"category":"technology","content":"Robotics combines mechanical engineering, electronics, and computer science to design machines capable of performing tasks autonomously or with minimal human control.","attribution":"Technology"} +] diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ea51284 --- /dev/null +++ b/install.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print status messages +print_status() { + echo -e "${GREEN}[*]${NC} $1" +} + +# Function to print error messages +print_error() { + echo -e "${RED}[!]${NC} $1" +} + +# Function to print warning messages +print_warning() { + echo -e "${YELLOW}[!]${NC} $1" +} + +# Check if Rust is installed +if ! command -v rustc &> /dev/null; then + print_status "Rust not found. Installing Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" +else + print_status "Rust is already installed" +fi + +# Check if cargo is installed +if ! command -v cargo &> /dev/null; then + print_error "Cargo not found. Please install Rust properly." + exit 1 +fi + +# Try to generate texts.json from packs if node is available (no npm packages required) +if command -v node &> /dev/null; then + if node scripts/merge_packs.js; then + print_status "Merged packs into texts.json" + else + print_warning "merge_packs.js failed; using existing texts.json" + fi +else + print_warning "node not found; skipping dataset pack merge. Ensure texts.json exists at repo root." +fi + +# Build the TUI version +print_status "Building the TUI version..." +cargo build --release --package typerpunk-tui --features tui + +if [ $? -eq 0 ]; then + print_status "Build successful! The TUI executable is located in target/release/typerpunk" + print_status "You can run it with: ./target/release/typerpunk" +else + print_error "Build failed. Please check the error messages above." + exit 1 +fi + +print_status "Installation complete!"
\ No newline at end of file diff --git a/scripts/extract_texts.js b/scripts/extract_texts.js new file mode 100644 index 0000000..8e8bdd3 --- /dev/null +++ b/scripts/extract_texts.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/* + Extract paragraphs from mirrored sites under similar/ to build a large texts.json. + - Scans HTML files in similar/play.typeracer.com, similar/monkeytype.com, etc. + - Extracts visible text from common content tags, splits into paragraphs, filters by length. + - Deduplicates and shuffles, attaches category from source directory, and attribution as the source path. + - Writes to repo-root texts.json for both CLI and Web to use. +*/ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SIMILAR_DIR = path.join(ROOT, 'similar'); +const OUTPUT = path.join(ROOT, 'texts.json'); + +const CONTENT_TAGS = ['p', 'article', 'main', 'section']; + +function findHtmlFiles(dir) { + const results = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findHtmlFiles(full)); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) { + results.push(full); + } + } + return results; +} + +function stripBoilerplateTags(html) { + return html.replace(/<(script|style|nav|footer|header|noscript)[^>]*>[\s\S]*?<\/\1>/gi, ' '); +} + +function decodeEntities(text) { + return text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function extractTagText(html, tag) { + const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'gi'); + const out = []; + let match; + while ((match = regex.exec(html)) !== null) { + const text = decodeEntities(match[1].replace(/<[^>]+>/g, ' ')); + out.push(text); + } + return out; +} + +function isLikelyVisibleText(text) { + const t = text.replace(/\s+/g, ' ').trim(); + if (!t) return false; + if (t.length < 60) return false; // avoid too-short snippets + // avoid nav/footer boilerplate + if (/©|copyright|cookie|privacy|terms|policy|subscribe|sign in|login|menu|footer|header/i.test(t)) return false; + return true; +} + +function splitIntoParagraphs(text) { + const blocks = text + .split(/\n\s*\n|\r\n\r\n/) + .map(s => s.replace(/\s+/g, ' ').trim()) + .filter(Boolean); + const paras = []; + for (const b of blocks) { + if (b.length <= 400) { + paras.push(b); + } else { + let start = 0; + while (start < b.length) { + const end = Math.min(start + 350, b.length); + const slice = b.slice(start, end); + const lastPeriod = slice.lastIndexOf('. '); + const lastComma = slice.lastIndexOf(', '); + const cut = lastPeriod > 150 ? lastPeriod + 1 : (lastComma > 150 ? lastComma + 1 : slice.length); + paras.push(slice.slice(0, cut).trim()); + start += cut; + } + } + } + return paras; +} + +try { + const htmlFiles = findHtmlFiles(SIMILAR_DIR); + const items = []; + const seen = new Set(); + + for (const file of htmlFiles) { + const rel = path.relative(SIMILAR_DIR, file); + const parts = rel.split(path.sep); + const category = parts[0]?.replace(/\W+/g, '').toLowerCase() || 'general'; + const attribution = `similar/${rel}`; + + const html = stripBoilerplateTags(fs.readFileSync(file, 'utf8')); + const textBits = []; + for (const tag of CONTENT_TAGS) { + for (const text of extractTagText(html, tag)) { + if (isLikelyVisibleText(text)) textBits.push(text); + } + } + + const combined = textBits.join('\n\n'); + if (!combined.trim()) continue; + + const paras = splitIntoParagraphs(combined) + .map(s => s.replace(/\s+/g, ' ').trim()) + .filter(s => s.length >= 80 && s.length <= 400); + + for (const content of paras) { + const key = content.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + items.push({ category, content, attribution }); + } + } + + // Shuffle + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + // If not enough, keep existing texts.json and merge + let existing = []; + if (fs.existsSync(OUTPUT)) { + try { existing = JSON.parse(fs.readFileSync(OUTPUT, 'utf8')); } catch {} + } + const merged = [...items, ...existing].slice(0, 5000); // cap to 5k entries + + fs.writeFileSync(OUTPUT, JSON.stringify(merged, null, 2)); + console.log(`Wrote ${merged.length} texts to ${OUTPUT}`); +} catch (err) { + console.error('extract_texts failed:', err); + process.exit(1); +} diff --git a/scripts/merge_packs.js b/scripts/merge_packs.js Binary files differnew file mode 100644 index 0000000..ec0e06f --- /dev/null +++ b/scripts/merge_packs.js diff --git a/sentences.txt b/sentences.txt deleted file mode 100644 index 7288284..0000000 --- a/sentences.txt +++ /dev/null @@ -1,3 +0,0 @@ -The quick brown fox jumps over the lazy dog. -In the beginning God created the heavens and the earth. -To be, or not to be, that is the question:
\ No newline at end of file diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 9c529f0..0000000 --- a/src/main.rs +++ /dev/null @@ -1,320 +0,0 @@ -// Import necessary crates -use crossterm::{ - event::{self, KeyCode, KeyEvent}, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use rand::Rng; -use std::{ - fs, - io::{self}, - path::Path, - sync::{Arc, Mutex}, - time::Instant, -}; -use tui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::Paragraph, - Frame, Terminal, -}; - -// Define the possible states of the application -#[derive(Debug, Clone, Copy, PartialEq)] -enum State { - MainMenu, - TypingGame, - EndScreen, -} - -// Struct to hold the application state -struct App { - time_taken: u64, - input_string: String, - timer: Option<Instant>, - state: State, - should_exit: bool, - sentences: Vec<String>, - current_sentence_index: usize, -} - -impl App { - // Constructor to create a new instance of the application - fn new() -> Result<Self, io::Error> { - let sentences = read_sentences("sentences.txt")?; - let current_sentence_index = rand::thread_rng().gen_range(0..sentences.len()); - let app = App { - time_taken: 0, - input_string: String::new(), - timer: None, - state: State::MainMenu, - should_exit: false, - sentences, - current_sentence_index, - }; - Ok(app) - } - - // Reset the game to its initial state - fn reset(&mut self) { - let current_sentence_index = rand::thread_rng().gen_range(0..self.sentences.len()); - self.current_sentence_index = current_sentence_index; - self.time_taken = 0; - self.input_string.clear(); - self.timer = None; - self.state = State::TypingGame; - } - - // Get the current sentence the user needs to type - fn current_sentence(&self) -> &str { - if let Some(sentence) = self.sentences.get(self.current_sentence_index) { - sentence - } else { - "No sentence available" - } - } - - // Start the timer - fn start_timer(&mut self) { - if self.timer.is_none() { - self.timer = Some(Instant::now()); - } - } - - // Update the timer - fn update_timer(&mut self) { - if let Some(timer) = self.timer { - self.time_taken = timer.elapsed().as_secs(); - } - } - - // Calculate and return the current typing speed (Words Per Minute) - fn update_wpm(&self) -> f64 { - let time_elapsed = self.time_taken as f64; - if time_elapsed == 0.0 { - 0.0 - } else { - let wpm = (self.input_string.split_whitespace().count() as f64) / (time_elapsed / 60.0); - if wpm.is_nan() { - 0.0 - } else { - wpm - } - } - } -} - -// Function to read sentences from a file -fn read_sentences(filename: &str) -> Result<Vec<String>, io::Error> { - if !Path::new(filename).exists() { - return Err(io::Error::new(io::ErrorKind::NotFound, "File not found")); - } - - let contents = fs::read_to_string(filename)?; - let sentences: Vec<String> = contents.lines().map(|s| s.to_string()).collect(); - Ok(sentences) -} - -// Function to draw the typing game UI -fn draw_typing_game(f: &mut Frame<CrosstermBackend<std::io::Stdout>>, chunk: Rect, app: &mut App) { - let wpm = app.update_wpm(); - let time_used = app.time_taken as f64; - - let mut colored_text: Vec<Span> = Vec::new(); - let cursor_pos = app.input_string.len(); - - // Iterate over each character in the current sentence and color it based on user input - for (index, c) in app.current_sentence().chars().enumerate() { - let color = if let Some(input_char) = app.input_string.chars().nth(index) { - if c == input_char { - Color::Green - } else { - Color::Red - } - } else { - Color::Gray - }; - - // cursor - let span = if index == cursor_pos { - Span::styled( - c.to_string(), - Style::default().fg(color).add_modifier(Modifier::REVERSED), - ) - } else { - Span::styled(c.to_string(), Style::default().fg(color)) - }; - colored_text.push(span); - } - - let text = vec![ - Spans::from(Span::styled( - "Type the following sentence:", - Style::default().add_modifier(Modifier::BOLD), - )), - Spans::from(colored_text), - Spans::from(Span::styled(format!("WPM: {:.2}", wpm), Style::default())), - Spans::from(Span::styled( - format!("Time: {:.1} seconds", time_used), - Style::default(), - )), - ]; - - // Render the widget - f.render_widget(Paragraph::new(text).alignment(Alignment::Center), chunk); - - app.update_timer(); -} - -// Function to handle user input events -async fn input_handler(event: KeyEvent, app: &mut App, _event_tx: Arc<Mutex<()>>) { - match event.code { - KeyCode::Char(c) => { - if app.timer.is_none() { - app.timer = Some(Instant::now()); - } - app.input_string.push(c); - } - KeyCode::Backspace => { - app.input_string.pop(); - } - KeyCode::Esc => match app.state { - State::MainMenu => { - app.should_exit = true; - } - State::TypingGame | State::EndScreen => { - app.state = State::MainMenu; - app.input_string.clear(); - app.timer = None; - } - }, - KeyCode::Enter => match app.state { - State::MainMenu => { - app.state = State::TypingGame; - app.start_timer(); - app.input_string.clear(); - } - State::TypingGame => { - if app.input_string.trim() == app.current_sentence().trim() { - app.state = State::EndScreen; - app.update_timer(); - } - } - State::EndScreen => { - app.reset(); - } - }, - _ => {} - } -} - -// Include test module -#[cfg(test)] -mod test; - -// Main function -#[tokio::main] -async fn main() -> Result<(), io::Error> { - // Enable raw mode for terminal input - enable_raw_mode()?; - - // Create a new instance of the App - let mut app = App::new().unwrap(); - - // Initialize the terminal backend - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Clear the terminal and hide the cursor - terminal.clear()?; - terminal.hide_cursor()?; - - // Main event loop - loop { - // Get the terminal size - if let Ok(size) = terminal.backend().size() { - // Define layout for the UI - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Min(3), - Constraint::Percentage(70), - Constraint::Min(3), - ]) - .split(size); - - // Draw UI based on app state - terminal.draw(|f| match app.state { - State::MainMenu => { - let main_menu = vec![ - Spans::from(Span::styled("Welcome to typerpunk!", Style::default())), - Spans::from(Span::styled("Press Enter to Start", Style::default())), - Spans::from(Span::styled("Press Esc to Quit", Style::default())), - ]; - f.render_widget( - Paragraph::new(main_menu).alignment(Alignment::Center), - chunks[0], - ); - } - State::TypingGame => { - draw_typing_game(f, chunks[1], &mut app); - } - State::EndScreen => { - let wpm = app.update_wpm(); - let time_taken = app.time_taken as f64; - let end_screen = vec![ - Spans::from(Span::styled("Game Over!", Style::default())), - Spans::from(Span::styled( - format!("Words Per Minute: {:.2}", wpm), - Style::default(), - )), - Spans::from(Span::styled( - format!("Time Taken: {:.1} seconds", time_taken), - Style::default(), - )), - Spans::from(Span::styled("Press Enter to Play Again", Style::default())), - Spans::from(Span::styled("Press Esc to Quit", Style::default())), - ]; - f.render_widget( - Paragraph::new(end_screen).alignment(Alignment::Center), - chunks[1], - ); - } - })?; - - // Handle input events - if let event::Event::Key(event) = event::read()? { - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - } - - // Check if the app should exit - if app.should_exit { - break; - } - } - } - - // Cleanup: Show cursor, disable raw mode, and clear only the game UI - terminal.show_cursor()?; - disable_raw_mode()?; - let size = terminal.backend().size().unwrap(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Min(3), - Constraint::Percentage(70), - Constraint::Min(3), - ]) - .split(size); - terminal - .draw(|f| f.render_widget(Paragraph::new("").alignment(Alignment::Center), chunks[1]))?; - - // Manually clear the terminal before exiting - println!("\x1B[2J\x1B[1;1H"); - - Ok(()) -} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 7f2728e..0000000 --- a/src/test.rs +++ /dev/null @@ -1,164 +0,0 @@ -// Import necessary items for testing -use crate::{draw_typing_game, input_handler, read_sentences, App, State}; -use crossterm::event::{KeyCode, KeyEvent}; -use std::{ - io, - sync::{Arc, Mutex}, -}; -use tui::layout::Rect; -use tui::{backend::CrosstermBackend, Terminal}; - -#[cfg(test)] -#[allow(unused_imports)] // Ignore unused import warnings within the test module -mod tests { - use super::*; - - #[test] - fn test_new_app() { - // Test that a new App initializes correctly - let app_result = App::new(); - assert!(app_result.is_ok()); - let app = app_result.unwrap(); - assert_eq!(app.input_string, ""); - assert_eq!(app.time_taken, 0); - assert_eq!(app.timer, None); - assert_eq!(app.state, State::MainMenu); - assert_eq!(app.should_exit, false); - assert!(!app.sentences.is_empty()); - assert!(app.current_sentence_index < app.sentences.len()); - } - - #[test] - fn test_reset_app() { - // Test the reset functionality of App - let mut app = App::new().unwrap(); - app.current_sentence_index = 0; // Set initial index to 0 - let old_index = app.current_sentence_index; - println!( - "Before reset: old_index = {}, current_sentence_index = {}", - old_index, app.current_sentence_index - ); - - app.time_taken = 100; - app.input_string = "Some input".to_string(); - app.reset(); - - println!( - "After reset: old_index = {}, current_sentence_index = {}", - old_index, app.current_sentence_index - ); - - // Check that current_sentence_index changes after reset - assert_ne!(app.current_sentence_index, old_index); - assert_eq!(app.time_taken, 0); - assert_eq!(app.input_string, ""); - assert_eq!(app.timer, None); - assert_eq!(app.state, State::TypingGame); - } - - #[test] - fn test_current_sentence() { - // Test the retrieval of the current sentence - let app = App::new().unwrap(); - let current_sentence = app.current_sentence(); - assert!(!current_sentence.is_empty()); - } - - #[test] - fn test_start_timer() { - // Test starting the timer - let mut app = App::new().unwrap(); - app.start_timer(); - assert!(app.timer.is_some()); - } - - #[test] - fn test_update_timer() { - // Test updating the timer - let mut app = App::new().unwrap(); - app.start_timer(); - let initial_time = app.time_taken; - std::thread::sleep(std::time::Duration::from_secs(1)); - app.update_timer(); - assert!(app.time_taken > initial_time); - } - - #[test] - fn test_update_wpm() { - // Test updating words per minute calculation - let mut app = App::new().unwrap(); - app.time_taken = 60; // 1 minute - app.input_string = "This is a test sentence".to_string(); - let wpm = app.update_wpm(); - assert_eq!(wpm, 5.0); // 5 words per minute for this sentence - } - - #[test] - fn test_read_sentences() { - // Test reading sentences from a file - let sentences_result = read_sentences("sentences.txt"); - assert!(sentences_result.is_ok()); - let sentences = sentences_result.unwrap(); - assert!(!sentences.is_empty()); - } - - #[test] - fn test_draw_typing_game() { - // Test drawing the typing game UI - let mut app = App::new().unwrap(); - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).unwrap(); - let size = Rect::default(); - draw_typing_game(&mut terminal.get_frame(), size, &mut app); - // No need to assert anything here since it's a rendering function - } - - #[tokio::test] - async fn test_input_handler() { - // Test the input handler function - let mut app = App::new().unwrap(); - - // Simulate typing 'a' - let event = KeyEvent::from(KeyCode::Char('a')); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "a"); - - // Simulate typing 'b' - let event = KeyEvent::from(KeyCode::Char('b')); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "ab"); - - // Simulate pressing Backspace - let event = KeyEvent::from(KeyCode::Backspace); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "a"); - - // Simulate pressing Enter in MainMenu state - let event = KeyEvent::from(KeyCode::Enter); - app.state = State::MainMenu; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.state, State::TypingGame); - assert_eq!(app.input_string, ""); - - // Simulate typing 'T' in TypingGame state - let event = KeyEvent::from(KeyCode::Char('T')); - app.state = State::TypingGame; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.input_string, "T"); - - // Simulate completing sentence and pressing Enter - let sentence = app.current_sentence().to_string(); - app.input_string = sentence.clone(); - let event = KeyEvent::from(KeyCode::Enter); - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.state, State::EndScreen); - assert_eq!(app.input_string.trim(), sentence.trim()); - - // Simulate pressing Esc in EndScreen state - let event = KeyEvent::from(KeyCode::Esc); - app.state = State::EndScreen; - input_handler(event, &mut app, Arc::new(Mutex::new(()))).await; - assert_eq!(app.should_exit, true); - } -} diff --git a/texts.json b/texts.json new file mode 100644 index 0000000..0aef950 --- /dev/null +++ b/texts.json @@ -0,0 +1,978 @@ +[ + { + "category": "history", + "content": "The Cold War divided much of the world into two opposing blocs for nearly half a century, one aligned with the United States and the other with the Soviet Union.", + "attribution": "History" + }, + { + "category": "philosophy", + "content": "Existentialism explores questions about individual freedom, choice, and meaning, arguing that people must create their own purpose in a universe that does not provide one automatically.", + "attribution": "Philosophy" + }, + { + "category": "business", + "content": "The man who dies rich, dies disgraced.", + "attribution": "Andrew Carnegie" + }, + { + "category": "movies", + "content": "Stop motion animation creates the illusion of movement by photographing physical objects one frame at a time and repositioning them slightly between each shot.", + "attribution": "Film" + }, + { + "category": "technology", + "content": "A blockchain is a distributed ledger that records transactions across many computers, making it difficult to alter any single record without changing every subsequent block.", + "attribution": "Technology" + }, + { + "category": "nature", + "content": "Bees pollinate roughly a third of the crops humans rely on for food, making them essential to global agriculture despite their small size.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "A balance sheet summarizes what a company owns and owes at a single point in time, split into assets, liabilities, and shareholders' equity.", + "attribution": "Business" + }, + { + "category": "history", + "content": "The Industrial Revolution transformed economies across Europe and North America during the eighteenth and nineteenth centuries, shifting production from hand tools to machines.", + "attribution": "History" + }, + { + "category": "literature", + "content": "In my younger and more vulnerable years my father gave me some advice that I've been turning over in my mind ever since. Whenever you feel like criticizing anyone, he told me, just remember that all the people in this world haven't had the advantages that you've had.", + "attribution": "F. Scott Fitzgerald, The Great Gatsby" + }, + { + "category": "movies", + "content": "Method acting, popularized in the mid twentieth century, encourages performers to draw on personal memories and emotions to portray a character as realistically as possible.", + "attribution": "Film" + }, + { + "category": "quotes", + "content": "Whatever you are, be a good one.", + "attribution": "Abraham Lincoln" + }, + { + "category": "general", + "content": "A day on Venus is longer than its year. The planet rotates so slowly on its axis that it takes longer to spin around once than it does to complete an entire orbit around the sun.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "If there is no struggle, there is no progress. Those who profess to favor freedom, and yet depreciate agitation, are men who want crops without plowing up the ground.", + "attribution": "Frederick Douglass" + }, + { + "category": "programming", + "content": "def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)", + "attribution": "Python", + "language": "python" + }, + { + "category": "general", + "content": "The Great Wall of China was not built all at once. It was constructed and extended over many centuries by different dynasties, each adding new sections and rebuilding older ones to defend against invasions from the north.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "struct node { int value; struct node *next; };", + "attribution": "C", + "language": "clike" + }, + { + "category": "general", + "content": "The Pacific Ocean covers more of the planet than all the continents combined. It stretches from the icy waters near Antarctica to the Arctic Circle, and its deepest point, the Mariana Trench, plunges nearly eleven kilometers below the surface.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Marie Curie remains the only person to win Nobel Prizes in two different sciences, physics and chemistry, for her pioneering research into radioactivity.", + "attribution": "Science" + }, + { + "category": "literature", + "content": "Call me Ishmael. Some years ago, never mind how long precisely, having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.", + "attribution": "Herman Melville, Moby-Dick" + }, + { + "category": "technology", + "content": "A virtual private network encrypts a device's internet connection, helping to protect data from being intercepted while it travels over a public or untrusted network.", + "attribution": "Technology" + }, + { + "category": "philosophy", + "content": "It is not that we have a short time to live, but that we waste a lot of it.", + "attribution": "Seneca, On the Shortness of Life" + }, + { + "category": "nature", + "content": "Deserts are defined by low precipitation rather than temperature, which is why some of the coldest places on Earth, including parts of Antarctica, are technically deserts.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "Sound travels as a wave of pressure through a medium such as air or water, and it cannot travel through the vacuum of space, since there are no particles to carry the vibration.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "The Amazon rainforest is home to an estimated ten percent of all known species on Earth, making it one of the most biodiverse regions on the planet.", + "attribution": "Nature" + }, + { + "category": "programming", + "content": "with open('data.csv', encoding='utf-8') as f: rows = [line.split(',') for line in f]", + "attribution": "Python", + "language": "python" + }, + { + "category": "quotes", + "content": "The only limit to our realization of tomorrow is our doubts of today.", + "attribution": "Franklin D. Roosevelt" + }, + { + "category": "quotes", + "content": "Genius is one percent inspiration and ninety-nine percent perspiration.", + "attribution": "Thomas Edison" + }, + { + "category": "technology", + "content": "Cloud computing allows businesses and individuals to rent computing power and storage over the internet instead of maintaining their own physical servers.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "Silent films relied on live musicians and title cards to convey dialogue and emotion, since synchronized sound was not introduced into feature films until the late 1920s.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness.", + "attribution": "Charles Dickens, A Tale of Two Cities" + }, + { + "category": "quotes", + "content": "It always seems impossible until it's done.", + "attribution": "Nelson Mandela" + }, + { + "category": "general", + "content": "Bamboo is one of the fastest growing plants on Earth. Some species can grow nearly a meter in a single day under the right conditions, which is why it is used so widely in construction and manufacturing across Asia.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "const unique = [...new Set(values)].sort((a, b) => a - b);", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "nature", + "content": "The Sahara Desert was once a green landscape with lakes and grasslands, before a shift in the Earth's orbit altered rainfall patterns thousands of years ago.", + "attribution": "Nature" + }, + { + "category": "movies", + "content": "Alfred Hitchcock was known for making brief cameo appearances in nearly all of his own films, a habit that became something of a signature.", + "attribution": "Film" + }, + { + "category": "general", + "content": "The invention of the printing press in the fifteenth century transformed how knowledge spread across Europe. Books that once took months to copy by hand could suddenly be reproduced by the hundreds, making literacy far more accessible.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Genetics research has shown that humans share about ninety-nine percent of their DNA with chimpanzees, the closest living relative to our species.", + "attribution": "Science" + }, + { + "category": "programming", + "content": "if err != nil { return fmt.Errorf(\"read config: %w\", err) }", + "attribution": "Go", + "language": "clike" + }, + { + "category": "history", + "content": "The fall of the Berlin Wall in 1989 marked a turning point in the Cold War, as East and West Germany moved toward reunification the following year.", + "attribution": "History" + }, + { + "category": "business", + "content": "The purpose of a business is to create and keep a customer.", + "attribution": "Peter Drucker" + }, + { + "category": "technology", + "content": "Open source software is released with its source code publicly available, allowing anyone to view, modify, and distribute it under the terms of its license.", + "attribution": "Technology" + }, + { + "category": "business", + "content": "A startup's burn rate measures how quickly it spends its available cash, an important number for founders trying to plan how long the company can operate before running out of money.", + "attribution": "Business" + }, + { + "category": "philosophy", + "content": "I think, therefore I am.", + "attribution": "Rene Descartes, Discourse on the Method" + }, + { + "category": "programming", + "content": "export PATH=\"$HOME/.local/bin:$PATH\" && command -v rg >/dev/null || echo missing", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "programming", + "content": "if (typeof value !== 'string' || value.length === 0) { return null; }", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "philosophy", + "content": "Every new beginning comes from some other beginning's end.", + "attribution": "Seneca, Letters from a Stoic" + }, + { + "category": "history", + "content": "Ancient Egyptian civilization flourished along the Nile River for thousands of years, leaving behind monuments such as the pyramids of Giza and the temples of Luxor.", + "attribution": "History" + }, + { + "category": "literature", + "content": "To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex.", + "attribution": "Arthur Conan Doyle, A Scandal in Bohemia" + }, + { + "category": "literature", + "content": "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip.", + "attribution": "Charles Dickens, Great Expectations" + }, + { + "category": "literature", + "content": "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families.", + "attribution": "Jane Austen, Pride and Prejudice" + }, + { + "category": "technology", + "content": "The World Wide Web was invented by Tim Berners-Lee in 1989 as a way to share documents over the internet using hyperlinks.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "The highest grossing films of all time are dominated by franchises built around superheroes, fantasy worlds, and long running science fiction universes.", + "attribution": "Film" + }, + { + "category": "programming", + "content": "match result { Ok(v) => println!(\"{v}\"), Err(e) => eprintln!(\"error: {e}\") }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "programming", + "content": "let total: i64 = values.iter().filter(|v| **v > 0).sum();", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "science", + "content": "Water is one of the few substances that becomes less dense as it freezes, which is why ice floats on top of liquid water instead of sinking to the bottom.", + "attribution": "Science" + }, + { + "category": "movies", + "content": "The Academy Awards, commonly known as the Oscars, have been presented annually since 1929 to honor achievements in the film industry.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "All we have to decide is what to do with the time that is given us.", + "attribution": "J.R.R. Tolkien, The Fellowship of the Ring" + }, + { + "category": "business", + "content": "Market research helps a business understand its customers' needs and preferences before committing resources to a new product or service.", + "attribution": "Business" + }, + { + "category": "general", + "content": "A group of flamingos is called a flamboyance. The birds get their pink color from the pigments in the shrimp and algae they eat.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "for f in *.log; do gzip -9 \"$f\" && echo \"packed $f\"; done", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "business", + "content": "Customer obsession, rather than competitor focus, is the surest path to long term success.", + "attribution": "Jeff Bezos" + }, + { + "category": "technology", + "content": "A central processing unit executes the instructions of a computer program by performing basic arithmetic, logic, and input and output operations.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "The speed of light in a vacuum is approximately two hundred ninety nine thousand, seven hundred ninety two kilometers per second, and nothing with mass can reach or exceed it.", + "attribution": "Science" + }, + { + "category": "science", + "content": "The human body contains roughly thirty seven trillion cells, each one specialized to carry out one of hundreds of different jobs inside the body.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "In every walk with nature one receives far more than he seeks.", + "attribution": "John Muir" + }, + { + "category": "business", + "content": "Branding is the practice of shaping how customers perceive a company, going beyond a logo to include tone, values, and the overall experience of using a product.", + "attribution": "Business" + }, + { + "category": "technology", + "content": "The transistor, invented in 1947, replaced bulky vacuum tubes and became the foundational building block of nearly every modern electronic device.", + "attribution": "Technology" + }, + { + "category": "programming", + "content": "pub struct Point { pub x: f64, pub y: f64 }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "general", + "content": "Octopuses have three hearts and blue blood. Two of the hearts pump blood to the gills, while the third circulates it to the rest of the body, and the blue color comes from a copper-based protein instead of iron.", + "attribution": "General knowledge" + }, + { + "category": "general", + "content": "The Amazon rainforest produces roughly twenty percent of the oxygen found in the planet's atmosphere, earning it the nickname the lungs of the Earth.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "If I have seen further, it is by standing on the shoulders of giants.", + "attribution": "Isaac Newton" + }, + { + "category": "general", + "content": "A single bolt of lightning can heat the surrounding air to temperatures hotter than the surface of the sun. The rapid expansion of that superheated air is what produces the crack of thunder we hear moments later.", + "attribution": "General knowledge" + }, + { + "category": "business", + "content": "Henry Ford, founder of the Ford Motor Company, is credited with popularizing the moving assembly line, which dramatically reduced the time needed to build an automobile.", + "attribution": "History of Business" + }, + { + "category": "business", + "content": "A merger combines two companies into one, while an acquisition occurs when one company purchases and absorbs another, though the two terms are often used loosely in everyday conversation.", + "attribution": "Business" + }, + { + "category": "movies", + "content": "The special effects in the original Star Wars trilogy were largely achieved with physical models and practical effects, long before computer generated imagery became common.", + "attribution": "Film" + }, + { + "category": "history", + "content": "The printing press, developed by Johannes Gutenberg in the fifteenth century, made it possible to produce books far more quickly and cheaply than copying them by hand.", + "attribution": "History" + }, + { + "category": "science", + "content": "Vaccines work by training the immune system to recognize a weakened or partial version of a pathogen, so the body can respond quickly if it ever encounters the real threat.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "Wolves live and hunt in structured packs, usually led by a breeding pair, and communicate through howls that can be heard for several kilometers.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "Supply and demand describes how the price of a good tends to rise when demand exceeds supply, and fall when supply exceeds demand.", + "attribution": "Business" + }, + { + "category": "nature", + "content": "The deepest part of the ocean, the Mariana Trench, plunges nearly eleven kilometers below the surface, deeper than Mount Everest is tall.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "I read that every known superstition in the world is gathered into the horseshoe of the Carpathians, as if it were the centre of some sort of imaginative whirlpool, and it made me uneasy in a way I could not explain.", + "attribution": "Bram Stoker, Dracula" + }, + { + "category": "general", + "content": "The human brain generates enough electricity to power a small light bulb. Billions of neurons firing electrical signals at once produce roughly the same wattage as a dim household lamp.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.", + "attribution": "Abraham Lincoln, The Gettysburg Address" + }, + { + "category": "programming", + "content": "export async function load(url) { const res = await fetch(url); return res.json(); }", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "science", + "content": "Mitochondria convert nutrients into a usable form of energy for the cell, which is why biology textbooks often call them the powerhouse of the cell.", + "attribution": "Science" + }, + { + "category": "history", + "content": "The Wright brothers achieved the first powered, controlled flight in 1903 near Kitty Hawk, North Carolina, a flight that lasted only twelve seconds.", + "attribution": "History" + }, + { + "category": "literature", + "content": "It was the best of times, it was the worst of times.", + "attribution": "Charles Dickens, A Tale of Two Cities" + }, + { + "category": "movies", + "content": "Pixar's first feature film, Toy Story, released in 1995, was the first full length movie created entirely with computer animation.", + "attribution": "Film" + }, + { + "category": "business", + "content": "Diversification spreads investment risk across different assets, so that a poor outcome in one area does not sink an entire portfolio.", + "attribution": "Business" + }, + { + "category": "quotes", + "content": "It is not the critic who counts; not the man who points out how the strong man stumbles. The credit belongs to the man who is actually in the arena, whose face is marred by dust and sweat and blood.", + "attribution": "Theodore Roosevelt, Citizenship in a Republic" + }, + { + "category": "programming", + "content": "let mut counts: HashMap<String, usize> = HashMap::new();", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "quotes", + "content": "The two most important days in your life are the day you are born and the day you find out why.", + "attribution": "Mark Twain" + }, + { + "category": "philosophy", + "content": "The Socratic method uses a series of questions and answers to expose contradictions in a person's beliefs, encouraging deeper critical thinking rather than simply providing answers.", + "attribution": "Philosophy" + }, + { + "category": "programming", + "content": "const total = items.reduce((acc, item) => acc + item.price, 0);", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "programming", + "content": "counts = {word: text.count(word) for word in set(text.split())}", + "attribution": "Python", + "language": "python" + }, + { + "category": "general", + "content": "Honey never spoils if it is stored properly. Archaeologists have found pots of honey in ancient Egyptian tombs that are thousands of years old and still perfectly edible, preserved by its low moisture content and natural acidity.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "Failure is impossible.", + "attribution": "Susan B. Anthony" + }, + { + "category": "literature", + "content": "Mr. Sherlock Holmes, who was usually very late in the mornings, save upon those not infrequent occasions when he was up all night, was seated at the breakfast table.", + "attribution": "Arthur Conan Doyle, The Hound of the Baskervilles" + }, + { + "category": "general", + "content": "Bananas are botanically classified as berries, while strawberries are not. The classification depends on how the fruit develops from the flower, not on taste or size.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Evolution by natural selection describes how organisms with traits better suited to their environment tend to survive and reproduce more successfully, gradually shaping a population over many generations.", + "attribution": "Science, after Charles Darwin" + }, + { + "category": "quotes", + "content": "Success is to be measured not so much by the position that one has reached in life as by the obstacles which he has overcome while trying to succeed.", + "attribution": "Booker T. Washington" + }, + { + "category": "programming", + "content": "impl Display for Token { fn fmt(&self, f: &mut Formatter) -> Result { write!(f, \"{}\", self.0) } }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "history", + "content": "The invention of the wheel around 3500 BCE is considered one of the most important technological breakthroughs in human history, transforming transportation and trade.", + "attribution": "History" + }, + { + "category": "nature", + "content": "The world is big and I want to have a good look at it before it gets dark.", + "attribution": "John Muir" + }, + { + "category": "general", + "content": "The first computer programmer is widely considered to be Ada Lovelace, who wrote an algorithm for an early mechanical computer in the eighteen forties, decades before any machine capable of running it was ever built.", + "attribution": "General knowledge" + }, + { + "category": "philosophy", + "content": "Stoicism is a school of ancient Greek philosophy that teaches the practice of virtue as the only true good, and encourages focusing only on what lies within one's own control.", + "attribution": "Philosophy" + }, + { + "category": "programming", + "content": "grep -rn \"TODO\" src/ | awk -F: '{print $1}' | sort -u", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "general", + "content": "Antarctica is technically the largest desert on the planet. Despite being covered in ice, it receives very little precipitation each year, which is the defining characteristic of a desert rather than temperature alone.", + "attribution": "General knowledge" + }, + { + "category": "general", + "content": "The shortest war in recorded history was fought between Britain and Zanzibar in 1896. It lasted around forty minutes before Zanzibar surrendered.", + "attribution": "General knowledge" + }, + { + "category": "nature", + "content": "A single mature tree can absorb around twenty two kilograms of carbon dioxide per year, making forests an important part of regulating the planet's climate.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "Cash flow tracks the actual movement of money into and out of a business, which can differ significantly from reported profit due to timing differences in payments.", + "attribution": "Business" + }, + { + "category": "programming", + "content": "for (int i = 0; i < n; i++) { sum += a[i] * b[i]; }", + "attribution": "C", + "language": "clike" + }, + { + "category": "business", + "content": "Price is what you pay, and value is what you get.", + "attribution": "Warren Buffett" + }, + { + "category": "philosophy", + "content": "The only true wisdom is in knowing you know nothing.", + "attribution": "Socrates" + }, + { + "category": "general", + "content": "The Eiffel Tower grows about six inches taller in the summer, as the iron structure expands slightly in the heat and contracts again once the weather cools.", + "attribution": "General knowledge" + }, + { + "category": "business", + "content": "Return on investment measures the profitability of an investment relative to its cost, expressed as a percentage to make it easy to compare different opportunities.", + "attribution": "Business" + }, + { + "category": "movies", + "content": "The Cannes Film Festival, held annually in France since 1946, is considered one of the most prestigious film festivals in the world.", + "attribution": "Film" + }, + { + "category": "history", + "content": "The signing of the Magna Carta in 1215 limited the power of the English king and established the principle that even a monarch was subject to the law.", + "attribution": "History" + }, + { + "category": "movies", + "content": "A film's runtime is typically decided during editing, where an editor works with hours of raw footage to shape a coherent story within a manageable length.", + "attribution": "Film" + }, + { + "category": "philosophy", + "content": "All men by nature desire to know.", + "attribution": "Aristotle, Metaphysics" + }, + { + "category": "philosophy", + "content": "Waste no more time arguing about what a good man should be. Be one.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "technology", + "content": "A firewall monitors and controls incoming and outgoing network traffic based on predetermined security rules, acting as a barrier between a trusted network and an untrusted one.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "The theory of plate tectonics explains how the Earth's outer shell is broken into large plates that slowly drift over the mantle, causing earthquakes, volcanoes, and mountain formation.", + "attribution": "Science" + }, + { + "category": "history", + "content": "The Great Depression of the 1930s began with the stock market crash of 1929 and led to widespread unemployment and economic hardship across the globe.", + "attribution": "History" + }, + { + "category": "movies", + "content": "The Wizard of Oz was one of the first major films to use Technicolor extensively, transitioning from black and white to vivid color once Dorothy arrives in Oz.", + "attribution": "Film" + }, + { + "category": "quotes", + "content": "You have power over your mind, not outside events. Realize this, and you will find strength.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "literature", + "content": "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn.", + "attribution": "Oscar Wilde, The Picture of Dorian Gray" + }, + { + "category": "science", + "content": "The periodic table arranges chemical elements by increasing atomic number, grouping elements with similar properties into the same columns.", + "attribution": "Science" + }, + { + "category": "programming", + "content": "for i, v := range items { fmt.Printf(\"%d: %v\\n\", i, v) }", + "attribution": "Go", + "language": "clike" + }, + { + "category": "history", + "content": "I will fight no more forever.", + "attribution": "Chief Joseph, Nez Perce, 1877" + }, + { + "category": "programming", + "content": "try: value = int(raw) except ValueError: value = 0", + "attribution": "Python", + "language": "python" + }, + { + "category": "programming", + "content": "fn main() { println!(\"{}\", args().skip(1).collect::<Vec<_>>().join(\" \")); }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "technology", + "content": "Machine learning is a branch of artificial intelligence in which a system improves its performance on a task by learning patterns from data rather than following explicit instructions.", + "attribution": "Technology" + }, + { + "category": "programming", + "content": "document.querySelectorAll('.row').forEach(el => el.classList.toggle('active'));", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "nature", + "content": "Heaven is under our feet as well as over our heads.", + "attribution": "Henry David Thoreau, Walden" + }, + { + "category": "business", + "content": "A monopoly exists when a single company dominates an entire market with no significant competition, often leading regulators to intervene to protect consumers.", + "attribution": "Business" + }, + { + "category": "philosophy", + "content": "Utilitarianism holds that the right action is the one that produces the greatest good for the greatest number of people, judging morality by outcomes rather than intentions.", + "attribution": "Philosophy" + }, + { + "category": "history", + "content": "The Roman Empire at its height stretched from Britain in the north to Egypt in the south, connected by a vast network of roads that still exist in parts of Europe today.", + "attribution": "History" + }, + { + "category": "literature", + "content": "Christmas won't be Christmas without any presents, grumbled Jo, lying on the rug. It's so dreadful to be poor, sighed Meg, looking down at her old dress.", + "attribution": "Louisa May Alcott, Little Women" + }, + { + "category": "technology", + "content": "Artificial intelligence refers to computer systems designed to perform tasks that normally require human intelligence, such as recognizing speech, images, or patterns in data.", + "attribution": "Technology" + }, + { + "category": "literature", + "content": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do. Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.", + "attribution": "Lewis Carroll, Alice's Adventures in Wonderland" + }, + { + "category": "literature", + "content": "A throng of bearded men, in sad-colored garments and gray, steeple-crowned hats, intermixed with women, some wearing hoods, and others bareheaded, was assembled in front of a wooden edifice.", + "attribution": "Nathaniel Hawthorne, The Scarlet Letter" + }, + { + "category": "movies", + "content": "A film's score is often composed after the movie has been edited, allowing the composer to match the music precisely to the pacing and mood of each scene.", + "attribution": "Film" + }, + { + "category": "technology", + "content": "The first practical smartphones combined a mobile phone with computing features such as email, web browsing, and applications, changing how people communicate and work.", + "attribution": "Technology" + }, + { + "category": "quotes", + "content": "To be great is to be misunderstood.", + "attribution": "Ralph Waldo Emerson, Self-Reliance" + }, + { + "category": "technology", + "content": "Robotics combines mechanical engineering, electronics, and computer science to design machines capable of performing tasks autonomously or with minimal human control.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "The word cinema comes from the Greek word for movement, reflecting how early filmmakers were fascinated by the illusion of motion created by a sequence of still images.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "Not all those who wander are lost.", + "attribution": "J.R.R. Tolkien" + }, + { + "category": "nature", + "content": "A rainforest canopy can be so dense that less than two percent of sunlight reaches the forest floor below.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "Buck did not read the newspapers, or he would have known that trouble was brewing, not alone for himself, but for every tide-water dog, strong of muscle and with warm, long hair, from Puget Sound to San Diego.", + "attribution": "Jack London, The Call of the Wild" + }, + { + "category": "literature", + "content": "The Nellie, a cruising yawl, swung to her anchor without a flutter of the sails, and was at rest. The flood had made, the wind was nearly calm, and being bound down the river, the only thing for it was to come to and wait for the turn of the tide.", + "attribution": "Joseph Conrad, Heart of Darkness" + }, + { + "category": "general", + "content": "Sharks existed long before trees. Fossil evidence suggests that sharks have been swimming in the oceans for more than four hundred million years, while the earliest trees did not appear until tens of millions of years later.", + "attribution": "General knowledge" + }, + { + "category": "history", + "content": "The Renaissance began in Italy in the fourteenth century, sparking a renewed interest in classical art, science, and learning that eventually spread across Europe.", + "attribution": "History" + }, + { + "category": "philosophy", + "content": "The beginning is the most important part of the work.", + "attribution": "Plato, The Republic" + }, + { + "category": "quotes", + "content": "I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived.", + "attribution": "Henry David Thoreau, Walden" + }, + { + "category": "history", + "content": "The Silk Road was not a single road but a network of trade routes connecting China to the Mediterranean, carrying silk, spices, and ideas across the ancient world.", + "attribution": "History" + }, + { + "category": "nature", + "content": "Migratory birds can travel thousands of miles between breeding and wintering grounds, navigating using the sun, stars, and the Earth's magnetic field.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "You don't know about me without you have read a book by the name of The Adventures of Tom Sawyer, but that ain't no matter. That book was made by Mr. Mark Twain, and he told the truth, mainly.", + "attribution": "Mark Twain, Adventures of Huckleberry Finn" + }, + { + "category": "philosophy", + "content": "The impediment to action advances action. What stands in the way becomes the way.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "nature", + "content": "Rivers shape the landscape around them over thousands of years, carving canyons and depositing sediment that forms fertile floodplains.", + "attribution": "Nature" + }, + { + "category": "movies", + "content": "The first feature length narrative film, The Story of the Kelly Gang, was made in Australia in 1906 and ran for about an hour.", + "attribution": "Film" + }, + { + "category": "programming", + "content": "const { id, name = 'anonymous', ...rest } = await request.json();", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "science", + "content": "DNA carries the genetic instructions for every living organism, encoded in a sequence of four chemical bases that pair together to form the famous double helix shape.", + "attribution": "Science" + }, + { + "category": "literature", + "content": "The Time Traveller, for so it will be convenient to speak of him, was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated.", + "attribution": "H.G. Wells, The Time Machine" + }, + { + "category": "quotes", + "content": "Stay hungry, stay foolish.", + "attribution": "Steve Jobs" + }, + { + "category": "technology", + "content": "Moore's Law observed that the number of transistors on a computer chip tends to double roughly every two years, driving decades of rapid improvement in computing power.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "Photosynthesis is the process by which plants convert light energy into chemical energy, using carbon dioxide and water to produce glucose and release oxygen as a byproduct.", + "attribution": "Science" + }, + { + "category": "philosophy", + "content": "A journey of a thousand miles begins with a single step.", + "attribution": "Lao Tzu, Tao Te Ching" + }, + { + "category": "literature", + "content": "1801. I have just returned from a visit to my landlord, the solitary neighbour that I shall be troubled with. This is certainly a beautiful country. In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society.", + "attribution": "Emily Bronte, Wuthering Heights" + }, + { + "category": "literature", + "content": "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning, but since dinner the cold winter wind had brought with it clouds so sombre, and a rain so penetrating.", + "attribution": "Charlotte Bronte, Jane Eyre" + }, + { + "category": "general", + "content": "Simplicity is the soul of efficiency.", + "attribution": "Austin Freeman" + }, + { + "category": "nature", + "content": "Mycorrhizal fungi form partnerships with the roots of most plants, helping them absorb water and nutrients in exchange for sugars produced by photosynthesis.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "A black hole forms when a massive star collapses under its own gravity, packing so much mass into so small a space that not even light can escape its pull.", + "attribution": "Science" + }, + { + "category": "quotes", + "content": "That man over there says that women need to be helped into carriages, and lifted over ditches, and to have the best place everywhere. Nobody ever helps me into carriages, or over mud puddles, or gives me any best place, and ain't I a woman?", + "attribution": "Sojourner Truth, Ain't I a Woman?" + }, + { + "category": "general", + "content": "The quick brown fox jumps over the lazy dog.", + "attribution": "Traditional pangram" + }, + { + "category": "general", + "content": "Venus is the hottest planet in the solar system, even though Mercury sits closer to the sun. A thick blanket of carbon dioxide traps heat at the surface.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "int *buf = malloc(len * sizeof(int)); if (!buf) return -1;", + "attribution": "C", + "language": "clike" + }, + { + "category": "movies", + "content": "Citizen Kane, released in 1941, is frequently cited by critics and filmmakers as one of the greatest films ever made, praised for its innovative camera work and narrative structure.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet.", + "attribution": "Mary Shelley, Frankenstein" + }, + { + "category": "technology", + "content": "Quantum computing uses the principles of quantum mechanics to process information in ways that classical computers cannot, potentially solving certain problems far faster.", + "attribution": "Technology" + }, + { + "category": "philosophy", + "content": "It does not matter how slowly you go as long as you do not stop.", + "attribution": "Confucius, Analects" + }, + { + "category": "programming", + "content": "public static void main(String[] args) { System.out.println(\"ready\"); }", + "attribution": "Java", + "language": "clike" + }, + { + "category": "quotes", + "content": "Our greatest glory is not in never falling, but in rising every time we fall.", + "attribution": "Confucius" + }, + { + "category": "history", + "content": "The Apollo 11 mission landed the first humans on the Moon in July of 1969, when astronauts Neil Armstrong and Buzz Aldrin walked on the lunar surface.", + "attribution": "History" + }, + { + "category": "literature", + "content": "The year 1866 was signalised by a remarkable incident, a mysterious and inexplicable phenomenon, which doubtless no one has yet forgotten. Ships had met with a huge thing, a long object, spindle-shaped, and at times phosphorescent.", + "attribution": "Jules Verne, Twenty Thousand Leagues Under the Sea" + }, + { + "category": "programming", + "content": "squares = [n * n for n in range(1, 21) if n % 3 != 0]", + "attribution": "Python", + "language": "python" + }, + { + "category": "philosophy", + "content": "It's not what happens to you, but how you react to it that matters.", + "attribution": "Epictetus" + }, + { + "category": "nature", + "content": "Coral reefs cover less than one percent of the ocean floor, yet they support roughly a quarter of all marine species.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "Newton's first law of motion states that an object at rest stays at rest, and an object in motion stays in motion, unless acted on by an outside force.", + "attribution": "Science" + }, + { + "category": "technology", + "content": "Fiber optic cables transmit data as pulses of light through thin strands of glass, allowing information to travel at extremely high speeds over long distances.", + "attribution": "Technology" + } +]
\ No newline at end of file diff --git a/texts.txt b/texts.txt new file mode 100644 index 0000000..3baa8dd --- /dev/null +++ b/texts.txt @@ -0,0 +1,13 @@ +The quick brown fox jumps over the lazy dog. +Pack my box with five dozen liquor jugs. +How vexingly quick daft zebras jump! +The five boxing wizards jump quickly. +Sphinx of black quartz, judge my vow. + +Programming is the art of telling another human what one wants the computer to do. + +Rust is a systems programming language that runs blazingly fast, prevents segfaults, and guarantees thread safety. + +The best way to predict the future is to implement it yourself. + +Code is like humor. When you have to explain it, it's bad. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..afe57aa --- /dev/null +++ b/web/README.md @@ -0,0 +1,67 @@ +# TyperPunk Web + +A modern web-based typing test application, in plain HTML, CSS, and JavaScript, backed by the shared Rust/WASM game core. No npm packages, no bundler, no build step. This avoids pulling in the npm dependency tree entirely, which removes the main supply-chain attack surface a typical React/Vite frontend carries. + +Features: + +- Real-time WPM and accuracy tracking +- Ghost text typing interface +- Light/dark mode support +- Responsive design +- Modern UI with cyberpunk-inspired theme + +## Prerequisites + +- Rust and `cargo` (for the WASM game core) +- `wasm-pack` (installed automatically by `launch.sh` if missing) +- Node.js (used only to run the zero-dependency dev server and dataset scripts; no npm packages are installed) + +## Getting Started + +```bash +# From repo root: builds WASM, merges the dataset, and starts the dev server +./web/launch.sh +``` + +Opens http://localhost:4173 + +## Project Structure + +``` +web/ +├── src/ +│ ├── screens/ # Screen renderers (main menu, typing game, end screen) +│ ├── app.js # Top-level screen controller +│ ├── game.js # WASM game instance lifecycle +│ ├── chart.js # Canvas-based WPM/accuracy graph +│ ├── stats.js # WPM/accuracy calculation +│ ├── theme.js # Light/dark theme state +│ ├── main.js # Application entry point +│ └── styles.css # Global styles +├── wasm/ # WASM bindings, copied here by launch.sh (gitignored) +├── index.html # HTML entry point +└── serve.mjs # Zero-dependency static file server +``` + +## Development + +The project uses: + +- Vanilla JavaScript (ES modules), no framework or bundler +- The browser's native `<canvas>` API for the results graph +- Node's built-in `http` module for local serving (`serve.mjs`) +- Plain CSS for styling + +Because there is no build step, editing a file under `src/` and reloading the page is the whole workflow. + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7a32152 --- /dev/null +++ b/web/index.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html lang="en" data-theme="dark"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>TyperPunk - Typing Test</title> + <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Cpolyline points='6,9 14,16 6,23' fill='none' stroke='%2300ff9d' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3Crect x='17' y='20' width='10' height='3' fill='%2300ff9d'/%3E%3C/svg%3E"> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="/src/styles.css"> +</head> +<body> + <script> + // Local dev only: the static file server (4173) and typerpunk-server + // (8787) are different origins here. A production deployment behind + // a reverse proxy that routes /api/* to the backend needs neither -- + // same-origin is the default (see web/src/api.js). + if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { + window.TYPERPUNK_API_URL = `${location.protocol}//${location.hostname}:8787`; + } + </script> + <div id="root"></div> + <script type="module" src="/src/main.js"></script> +</body> +</html> diff --git a/web/launch.sh b/web/launch.sh new file mode 100755 index 0000000..bcde2d6 --- /dev/null +++ b/web/launch.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color +YELLOW='\033[0;33m' + +echo -e "${BLUE}Starting TyperPunk Web...${NC}" + +# Resolve directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="${SCRIPT_DIR}/.." + +# Ensure wasm-pack is available (auto-install if possible) +if ! command -v wasm-pack &> /dev/null; then + echo -e "${YELLOW}wasm-pack not found. Attempting to install via cargo...${NC}" + if command -v cargo &> /dev/null; then + cargo install wasm-pack || { + echo -e "${RED}Failed to install wasm-pack automatically. Please install manually: cargo install wasm-pack${NC}" + exit 1 + } + else + echo -e "${RED}Rust cargo is not installed. Please install Rust and then run: cargo install wasm-pack${NC}" + exit 1 + fi +fi + +# Ensure node is available (used only to run scripts, no npm packages required) +if ! command -v node &> /dev/null; then + echo -e "${RED}Node.js is not installed. Please install Node.js to run the dataset merge and dev server scripts.${NC}" + exit 1 +fi + +# Ensure dataset exists by merging packs at repo root (best-effort, no npm needed) +echo "Ensuring dataset (texts.json) exists by merging packs..." +(cd "$ROOT_DIR" && node scripts/merge_packs.js) \ + && echo "Merged packs into texts.json" \ + || echo -e "${YELLOW}Warning:${NC} Could not merge packs; continuing with existing texts.json" + +# Build the WASM module +echo "Building WASM module..." +cd "$ROOT_DIR/crates/wasm" + +# Clean previous build +rm -rf pkg target + +# Build with wasm-pack +wasm-pack build --target web --release + +# Check if build was successful +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to build WASM module${NC}" + exit 1 +fi + +cd "$SCRIPT_DIR" + +# Copy the built WASM bindings into web/wasm for the browser to import directly +mkdir -p wasm +cp "$ROOT_DIR/crates/wasm/pkg/typerpunk_wasm.js" wasm/ +cp "$ROOT_DIR/crates/wasm/pkg/typerpunk_wasm_bg.wasm" wasm/ +echo "Copied WASM bindings into web/wasm/" + +# Copy shared texts.json into web/src/data if present +mkdir -p src/data +if [ -f "$ROOT_DIR/texts.json" ]; then + cp "$ROOT_DIR/texts.json" src/data/texts.json + echo "Copied shared texts.json into web/src/data/" +else + echo -e "${YELLOW}Warning:${NC} ../texts.json not found. Using fallback web/src/data/texts.json" +fi + +# Start the static file server (no bundler, no npm dependencies) +echo -e "${GREEN}Starting development server...${NC}" +echo -e "${GREEN}Website will be available at: http://localhost:4173${NC}" +node serve.mjs diff --git a/web/serve.mjs b/web/serve.mjs new file mode 100644 index 0000000..64d6339 --- /dev/null +++ b/web/serve.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Zero-dependency static file server for the TyperPunk web app. +// Replaces the Vite dev server so the web app has no npm runtime dependency. +import { createServer } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url))); +const PORT = Number(process.env.PORT) || 4173; +const HOST = process.env.HOST || '0.0.0.0'; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.wasm': 'application/wasm', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +async function resolveFile(urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath.split('?')[0]); + } catch { + return null; + } + const safePath = normalize(join(ROOT, decoded)); + if (safePath !== ROOT && !safePath.startsWith(ROOT + sep)) return null; + + let target = safePath; + try { + const info = await stat(target); + if (info.isDirectory()) target = join(target, 'index.html'); + } catch { + return null; + } + + try { + await stat(target); + return target; + } catch { + return null; + } +} + +const server = createServer(async (req, res) => { + const filePath = (await resolveFile(req.url)) || (await resolveFile('/index.html')); + if (!filePath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + try { + const body = await readFile(filePath); + const type = MIME_TYPES[extname(filePath)] || 'application/octet-stream'; + // This replaces a dev server, and the app is under active + // development - no cache headers at all left the browser free to + // hold onto old JS/CSS indefinitely with no way to tell it changed, + // which has already caused a real "why don't I see the fix" report. + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' }); + res.end(body); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal server error'); + console.error(err); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`TyperPunk web running at http://localhost:${PORT}`); +}); diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..3651067 --- /dev/null +++ b/web/src/api.js @@ -0,0 +1,43 @@ +// Thin fetch wrapper for the typerpunk-server backend. Base URL is +// same-origin by default (a reverse proxy is expected to route /api/* to the +// backend in that setup); window.TYPERPUNK_API_URL overrides it for local +// dev or any split frontend/backend hosting, mirroring the existing +// window.TYPERPUNK_TEXTS_URL override pattern already used for the dataset. +const BASE_URL = (typeof window !== 'undefined' && window.TYPERPUNK_API_URL) || ''; + +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +async function request(method, path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + credentials: 'include', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + let data = null; + try { data = await res.json(); } catch { /* empty body, e.g. logout */ } + + if (!res.ok) { + throw new ApiError(res.status, (data && data.error) || `request failed (${res.status})`); + } + return data; +} + +export const api = { + get: path => request('GET', path), + post: (path, body) => request('POST', path, body ?? {}), + delete: path => request('DELETE', path), +}; + +// For the rare case a caller needs a real page navigation instead of a +// fetch - the Spotify OAuth login link has to be an actual top-level +// navigation so the browser can load Spotify's own consent screen. +export function apiUrl(path) { + return `${BASE_URL}${path}`; +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..fac89d4 --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,362 @@ +import { renderMainMenu } from './screens/mainMenu.js'; +import { renderTypingGame } from './screens/typingGame.js'; +import { renderEndScreen } from './screens/endScreen.js'; +import { renderPassiveMode } from './screens/passiveMode.js'; +import { renderStatsScreen } from './screens/statsScreen.js'; +import { renderPlaceholderScreen } from './screens/placeholderScreen.js'; +import { renderAccountScreen } from './screens/accountScreen.js'; +import { renderLeaderboardScreen } from './screens/leaderboardScreen.js'; +import { renderFriendsScreen } from './screens/friendsScreen.js'; +import { renderMultiplayerScreen } from './screens/multiplayerScreen.js'; +import { renderLyricsScreen } from './screens/lyricsScreen.js'; +import { renderPublicProfileScreen } from './screens/publicProfileScreen.js'; +import { renderStoreScreen } from './screens/storeScreen.js'; +import { createGame, freeGame } from './game.js'; +import { getSettings } from './settings.js'; +import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js'; +import { getWeakChars } from './keyStats.js'; + +const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' }; + +function uniqueCategories(items) { + const set = new Set(); + for (const t of items) if (t.category) set.add(t.category); + return Array.from(set).sort(); +} + +function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function getRandomTextItem(items, category) { + const pool = category && category !== 'random' ? items.filter(t => t.category === category) : items; + return pool.length ? pickRandom(pool) : FALLBACK_TEXT; +} + +export function startApp(root, localTexts) { + let allTexts = localTexts; + let categories = uniqueCategories(localTexts); + let selectedCategory = localStorage.getItem('typerpunk:last_mode') || 'time'; + let customText = null; // { name, chunks: [{content, time}], language, timed } + let session = { type: 'random', customIndex: 0 }; + let game = null; + let cleanupScreen = null; + + const onlineUrl = window.TYPERPUNK_TEXTS_URL; + if (onlineUrl) { + fetch(onlineUrl, { cache: 'no-store' }) + .then(res => (res.ok ? res.json() : null)) + .then(data => { + if (Array.isArray(data)) { + allTexts = data; + categories = uniqueCategories(data); + } + }) + .catch(() => {}); + } + + function teardown() { + if (cleanupScreen) { + cleanupScreen(); + cleanupScreen = null; + } + } + + // Which screen is showing, and whether leaving it costs the user + // something. Escape is a global "get me out of here", but walking out of a + // live race drops you from it for everyone else in the room too, so that + // one case asks first. + let currentScreenName = 'menu'; + let escapeIsCostly = () => false; + + function setScreen(name, costly = () => false) { + currentScreenName = name; + escapeIsCostly = costly; + } + + function handleGlobalEscape(e) { + if (e.key !== 'Escape') return; + // Let an open popover close itself first - one Escape should not both + // dismiss a menu and navigate away from the screen behind it. + const openPopover = document.querySelector( + '.mode-popover:not([hidden]), .rail-settings-panel:not([hidden]), .lang-popover:not([hidden]), .theme-gallery-popover:not([hidden])'); + if (openPopover) { + openPopover.hidden = true; + return; + } + if (currentScreenName === 'menu') return; + if (escapeIsCostly() && !window.confirm('Leave the race? Your opponents will see you drop out.')) return; + showMainMenu(); + } + document.addEventListener('keydown', handleGlobalEscape); + + function showMainMenu() { + teardown(); + freeGame(game); + game = null; + setScreen('menu'); + cleanupScreen = renderMainMenu(root, { + onStartGame: () => { session = { type: 'random', customIndex: 0 }; startGame(); }, + categories, + selectedCategory, + onSelectCategory: cat => { + selectedCategory = cat; + try { localStorage.setItem('typerpunk:last_mode', cat); } catch {} + showMainMenu(); + }, + customText, + onLoadCustom: loaded => { + customText = loaded; + showMainMenu(); + }, + onClearCustom: () => { + customText = null; + showMainMenu(); + }, + onStartCustom: () => { + session = { type: 'custom', customIndex: 0 }; + startGame(); + }, + onStartPassive: startPassive, + onShowStats: showStats, + onShowPlaceholder: showPlaceholder, + onShowAccount: showAccount, + onShowLeaderboard: showLeaderboard, + onShowFriends: showFriends, + onShowMultiplayer: showMultiplayer, + onShowStore: showStore, + onShowLyrics: showLyrics, + onSimulateTest: simulateTest, + }); + } + + // Dev-only shortcut: jumps straight to the end screen with synthetic + // results, no actual typing needed - for iterating on the end screen's + // design without retyping a full passage every time. + function simulateTest() { + teardown(); + const text = 'The quick brown fox jumps over the lazy dog while typing very quickly today, right now, in this exact moment.'; + const wpm = 70 + Math.random() * 90; + const accuracy = 85 + Math.random() * 15; + const time = 8 + Math.random() * 15; + const charTimings = []; + const keypressHistory = []; + for (let i = 0; i < text.length; i++) { + const t = (i / text.length) * time; + const isCorrect = Math.random() * 100 < accuracy; + charTimings.push({ time: t, isCorrect, index: i }); + keypressHistory.push({ time: t, isCorrect, index: i }); + } + const incorrectChars = charTimings.filter(c => !c.isCorrect).length; + const stats = { + wpm, rawWpm: wpm * 1.08, accuracy, time, + correctChars: text.length - incorrectChars, incorrectChars, totalChars: text.length, + currentStreak: 0, bestStreak: Math.floor(text.length / 3), + }; + showEndScreen({ stats, text, userInput: text, charTimings, keypressHistory, modeKey: null }); + } + + function showStats() { + teardown(); + setScreen('stats'); + cleanupScreen = renderStatsScreen(root, { onBack: showMainMenu, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showPlaceholder(title, description) { + teardown(); + setScreen('placeholder'); + cleanupScreen = renderPlaceholderScreen(root, { title, description, onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showAccount() { + teardown(); + setScreen('account'); + cleanupScreen = renderAccountScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showLeaderboard() { + teardown(); + setScreen('leaderboard'); + cleanupScreen = renderLeaderboardScreen(root, { onBack: showMainMenu, onShowPublicProfile: showPublicProfile, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showPublicProfile(username) { + teardown(); + setScreen('profile'); + cleanupScreen = renderPublicProfileScreen(root, { username, onBack: showLeaderboard, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showFriends() { + teardown(); + setScreen('friends'); + cleanupScreen = renderFriendsScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + + function showMultiplayer() { + teardown(); + setScreen('multiplayer', () => !!document.querySelector('.mp-opponents')); + cleanupScreen = renderMultiplayerScreen(root, { onBack: showMainMenu, onFinish: showEndScreen, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowStore: showStore }); + } + + function showStore() { + teardown(); + setScreen('store'); + cleanupScreen = renderStoreScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer }); + } + + function showLyrics() { + teardown(); + setScreen('lyrics'); + cleanupScreen = renderLyricsScreen(root, { + onBack: showMainMenu, + onLyricsReady: loaded => { + customText = loaded; + session = { type: 'custom', customIndex: 0 }; + startGame(); + }, + onShowStats: showStats, + onShowPlaceholder: showPlaceholder, + onShowAccount: showAccount, + onShowLeaderboard: showLeaderboard, + onShowFriends: showFriends, + onShowMultiplayer: showMultiplayer, + onShowStore: showStore, + }); + } + + async function startGame() { + teardown(); + let content; + let attribution; + let language; + let progress; + let timeLimit; + // Custom text has no stable, repeatable challenge to compare against, + // so it's excluded from personal-best tracking (stays undefined). + let modeKey; + + if (session.type === 'custom' && customText) { + const chunk = customText.chunks[session.customIndex]; + content = chunk.content; + attribution = customText.name; + language = customText.language; + progress = `${customText.name} · segment ${session.customIndex + 1}/${customText.chunks.length}`; + } else if (selectedCategory === 'words') { + const s = getSettings(); + content = generateWordStream(s.wordCount || 25, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language }); + modeKey = `words-${s.wordCount || 25}`; + } else if (selectedCategory === 'time') { + const s = getSettings(); + timeLimit = s.timeDuration || 30; + content = generateWordStream(wordCountForDuration(timeLimit), { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language }); + modeKey = `time-${timeLimit}`; + } else if (selectedCategory === 'zen') { + // No timer, no fixed length - type until you choose to stop (the + // visible Finish button in the typing screen). No modeKey: like + // custom text, there's no fixed-length challenge to compare a + // "personal best" against since every session covers a different + // amount of text. + const s = getSettings(); + content = generateWordStream(800, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language }); + } else if (selectedCategory === 'practice') { + // Adaptive, personal, and different every time by nature - no + // modeKey, same reasoning as Zen/Custom: there's no fixed + // challenge here to compare a "personal best" against. + const s = getSettings(); + const weakChars = getWeakChars(); + content = generateWeakKeyStream(wordCountForDuration(30), weakChars, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers }); + } else { + const item = getRandomTextItem(allTexts, selectedCategory); + content = item.content; + attribution = item.attribution; + // Code snippets highlight themselves; prose packs have no language + // and fall through as plain text. + language = item.language || undefined; + modeKey = `quote-${selectedCategory || 'random'}`; + } + + try { + game = await createGame(); + game.set_text(content); + } catch (err) { + console.error('Failed to start game:', err); + showMainMenu(); + return; + } + setScreen('typing'); + cleanupScreen = renderTypingGame(root, { + game, + text: content, + attribution, + language, + progress, + timeLimit, + modeKey, + zenMode: selectedCategory === 'zen' && !(session.type === 'custom' && customText), + onFinish: showEndScreen, + onMainMenu: showMainMenu, + onRestart: startGame, + onShowStats: showStats, + onShowPlaceholder: showPlaceholder, + onShowAccount: showAccount, + onShowLeaderboard: showLeaderboard, + onShowFriends: showFriends, + onShowMultiplayer: showMultiplayer, + onShowStore: showStore, + }); + } + + function playAgain() { + if (session.type === 'custom' && customText) { + if (session.customIndex + 1 < customText.chunks.length) { + session.customIndex += 1; + } else { + showMainMenu(); + return; + } + } + startGame(); + } + + function showEndScreen(result) { + teardown(); + setScreen('end'); + cleanupScreen = renderEndScreen(root, { + ...result, + onPlayAgain: playAgain, + onMainMenu: showMainMenu, + onShowStats: showStats, + onShowPlaceholder: showPlaceholder, + onShowAccount: showAccount, + onShowLeaderboard: showLeaderboard, + onShowFriends: showFriends, + onShowMultiplayer: showMultiplayer, + onShowStore: showStore, + }); + } + + function startPassive() { + teardown(); + if (!customText) { + showMainMenu(); + return; + } + setScreen('passive'); + cleanupScreen = renderPassiveMode(root, { + name: customText.name, + chunks: customText.chunks, + onExit: showMainMenu, + }); + } + + const { skipMenu, favoriteMode } = getSettings(); + const bootMode = favoriteMode || selectedCategory; + if (skipMenu && bootMode !== 'custom') { + selectedCategory = bootMode; + try { localStorage.setItem('typerpunk:last_mode', bootMode); } catch {} + session = { type: 'random', customIndex: 0 }; + startGame(); + } else { + showMainMenu(); + } +} diff --git a/web/src/auth.js b/web/src/auth.js new file mode 100644 index 0000000..d4913ca --- /dev/null +++ b/web/src/auth.js @@ -0,0 +1,75 @@ +import { api, ApiError } from './api.js'; +import { mergeRemoteBests } from './pb.js'; + +// Mirrors theme.js's pattern: one module-level value plus a subscriber set, +// so every screen showing sign-in state (the corner rail, the main menu's +// bottom-left widget, the Account screen) repaints from the same source +// instead of each tracking its own copy. +let currentUser = null; +let resolved = false; +const listeners = new Set(); + +function setUser(user) { + currentUser = user; + resolved = true; + for (const fn of listeners) fn(currentUser); + // Reconciles local personal bests against the server's on every + // sign-in (including the session restored on page load, not just an + // explicit login) - a pure max(local, remote) merge, so running it + // every time is harmless and keeps a second device's progress visible + // here without a dedicated "sync now" action. + if (user) { + api.get('/api/stats/me').then(({ personal_bests }) => mergeRemoteBests(personal_bests)).catch(() => {}); + } +} + +// Fire-and-forget on module load: restores sign-in state from the session +// cookie (if any) without the caller needing to await anything up front. +const initial = api.get('/api/auth/me').then(user => setUser(user)).catch(() => setUser(null)); + +export function getUser() { + return currentUser; +} + +/** True once the initial /api/auth/me check has resolved either way. */ +export function isAuthResolved() { + return resolved; +} + +export function onAuthChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +export function whenAuthReady() { + return initial; +} + +export async function register(username, password) { + const user = await api.post('/api/auth/register', { username, password }); + setUser(user); + return user; +} + +export async function login(username, password) { + const user = await api.post('/api/auth/login', { username, password }); + setUser(user); + return user; +} + +// Fire-and-forget: called after every finished test regardless of sign-in +// state, so callers (endScreen.js) don't need their own "am I logged in" +// branch. No-ops for guests; a failed upload (session expired mid-test, +// network hiccup) is silently dropped rather than surfacing an error over a +// result the player has already seen and moved past. +export function submitTestResult(result) { + if (!currentUser) return; + api.post('/api/stats', result).catch(() => {}); +} + +export async function logout() { + try { await api.post('/api/auth/logout'); } catch { /* cookie may already be gone */ } + setUser(null); +} + +export { ApiError }; diff --git a/web/src/chart.js b/web/src/chart.js new file mode 100644 index 0000000..b1189eb --- /dev/null +++ b/web/src/chart.js @@ -0,0 +1,420 @@ +export function buildGraphPoints(charTimings, stats, keypressHistory) { + // Prefer keypressHistory: it's an append-only log of every keystroke as + // it actually happened, so a mistake you later backspaced away still + // shows up as a real event at the time it occurred. charTimings is + // current-state-only - backspacing overwrites the slot for that + // position, so a corrected mistake leaves no trace by the end of the + // test, and raw silently collapses onto wpm for the whole graph. + let timings = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings; + if (!timings || timings.length === 0) { + if (!(stats.time > 0 && stats.totalChars > 0)) return []; + const total = stats.correctChars + stats.incorrectChars; + timings = []; + for (let i = 0; i < total; i++) { + timings.push({ time: (i / total) * stats.time, isCorrect: i < stats.correctChars }); + } + } + + const maxTime = Math.max(timings[timings.length - 1].time, stats.time); + const seconds = Math.max(1, Math.ceil(maxTime)); + // Cap the number of plotted buckets regardless of how long the test + // actually took wall-clock-wise (nothing stops a test from sitting open, + // unfinished, for hours) by widening the bucket instead of the point + // count. For any normal-length test (<= 300s) this divides out to 1s + // buckets exactly as before - pure safety net, no behavior change. + const MAX_BUCKETS = 300; + const bucketSeconds = Math.max(1, Math.ceil(seconds / MAX_BUCKETS)); + const bucketCount = Math.ceil(seconds / bucketSeconds); + + const buckets = Array.from({ length: bucketCount }, () => ({ total: 0, correct: 0 })); + for (const c of timings) { + const idx = Math.min(bucketCount - 1, Math.floor(c.time / bucketSeconds)); + if (idx < 0) continue; + buckets[idx].total++; + if (c.isCorrect) buckets[idx].correct++; + } + + // Each point is a sliding window over the last ~3 seconds, not just its + // own isolated bucket. A single 1-second bucket with zero keystrokes is + // completely normal human behavior (the brief pause between words), but + // read in isolation it computes as a hard 0 WPM - a sharp, unrealistic + // spike to the graph's floor that doesn't happen on MonkeyType/TypeRacer + // because they smooth over exactly this kind of momentary gap. A real + // multi-second stall still shows up as a real dip; it just isn't a + // razor to zero from one quiet second. + const minutesPerBucket = bucketSeconds / 60; + const windowBuckets = Math.max(1, Math.round(3 / bucketSeconds)); + return buckets.map((_, i) => { + const start = Math.max(0, i - windowBuckets + 1); + let correctSum = 0; + let totalSum = 0; + for (let j = start; j <= i; j++) { + correctSum += buckets[j].correct; + totalSum += buckets[j].total; + } + const windowMinutes = (i - start + 1) * minutesPerBucket; + return { + time: (i + 1) * bucketSeconds, + wpm: (correctSum / 5) / windowMinutes, + raw: (totalSum / 5) / windowMinutes, + }; + }); +} + +/// Consistency: how steady the WPM stayed across the test, expressed as a +/// percentage (100 = perfectly even pace). Same coefficient-of-variation +/// approach MonkeyType uses: lower spread relative to the average pace +/// scores higher. +export function calculateConsistency(graphPoints) { + const values = graphPoints.map(p => p.wpm).filter(v => v > 0); + if (values.length < 2) return 100; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + if (mean === 0) return 100; + const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length; + const stddev = Math.sqrt(variance); + const coefficientOfVariation = stddev / mean; + return Math.max(0, Math.min(100, Math.round((1 - coefficientOfVariation) * 100))); +} + +export function buildErrorPoints(graphPoints, keypressHistory, charTimings) { + const source = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings; + if (!source || source.length === 0 || graphPoints.length === 0) return []; + // One marker per bucket carrying how many errors landed in it, rather + // than one marker per error sitting on top of the wpm line. Two mistakes + // in the same second used to draw two dots at the same coordinates, so + // the graph could not show that a moment was worse than another. + const byBucket = new Map(); + for (const { time, isCorrect, index } of source) { + if (isCorrect) continue; + const closest = graphPoints.reduce((prev, curr) => + Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]); + const entry = byBucket.get(closest.time); + if (entry) entry.count += 1; + else byBucket.set(closest.time, { x: closest.time, count: 1, index }); + } + return [...byBucket.values()].sort((a, b) => a.x - b.x); +} + +export function niceStep(maxValue, plotSize, pxPerLabel) { + const maxLabels = Math.max(2, Math.floor(plotSize / pxPerLabel)); + const rawStep = (maxValue || 1) / maxLabels; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const residual = rawStep / magnitude; + const niceResidual = residual > 5 ? 10 : residual > 2 ? 5 : residual > 1 ? 2 : 1; + return Math.max(1, niceResidual * magnitude); +} + +/// Shared plot geometry so hit-testing (hover) can map mouse coordinates the +/// same way drawChart maps data coordinates. +export function getPlotGeometry(canvas, graphPoints, xMax, errorPoints = []) { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + // Room on both sides for a rotated axis title plus its tick labels. + const margin = { top: 14, right: 52, bottom: 42, left: 52 }; + const plotW = Math.max(1, width - margin.left - margin.right); + const plotH = Math.max(1, height - margin.top - margin.bottom); + const maxWpm = Math.max(1, ...graphPoints.map(p => Math.max(p.wpm, p.raw)), 1); + const yStep = niceStep(maxWpm, plotH, 28); + const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep); + + // Errors get their own scale. Plotted against the WPM axis they were + // pinned to whatever the line happened to be doing, which said nothing + // about how many errors there were - and on a fast test they all bunched + // near the top of the chart. + const maxErrors = Math.max(1, ...errorPoints.map(p => p.count || 1)); + // Whole numbers only: half an error does not exist. + const errStep = Math.max(1, Math.ceil(maxErrors / 4)); + // One step of headroom above the worst bucket, so the tallest marker never + // sits on the top edge of the plot or underneath the legend. + const errMax = Math.max(errStep, (Math.ceil(maxErrors / errStep) + 1) * errStep); + + return { + xToPx: t => margin.left + (t / (xMax || 1)) * plotW, + yToPx: v => margin.top + plotH - (v / yMax) * plotH, + yErrToPx: v => margin.top + plotH - (v / errMax) * plotH, + margin, plotW, plotH, yMax, errMax, errStep, + }; +} + +// Canvas fillStyle/strokeStyle can't take a CSS var() directly, so the +// chart's colors are read from the active theme's actual custom properties +// instead of being hardcoded - otherwise every non-default theme (Dracula, +// Nord, etc.) would show a graph in colors that don't match anything else +// on the page. +export function themeColors() { + const style = getComputedStyle(document.documentElement); + const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback; + return { + primary: get('--primary-color', '#00ff9d'), + secondary: get('--secondary-color', '#00cc8f'), + error: get('--error-color', '#ff3b3b'), + sub: get('--sub-color', '#646669'), + }; +} + +export function drawChart(canvas, { graphPoints, errorPoints, xMax }) { + const dpr = window.devicePixelRatio || 1; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + if (width === 0 || height === 0) return; + canvas.width = width * dpr; + canvas.height = height * dpr; + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + if (graphPoints.length === 0) return; + + const { xToPx, yToPx, yErrToPx, margin, yMax, errMax, errStep } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints); + const yStep = niceStep(yMax, canvas.clientHeight - margin.top - margin.bottom, 28); + const colors = themeColors(); + + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.strokeStyle = 'rgba(100,102,105,0.15)'; + ctx.fillStyle = colors.sub; + ctx.lineWidth = 1; + for (let v = 0; v <= yMax; v += yStep) { + const y = yToPx(v); + ctx.beginPath(); + ctx.moveTo(margin.left, y); + ctx.lineTo(width - margin.right, y); + ctx.stroke(); + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(Math.round(v)), margin.left - 6, y); + } + + // Start at xStep, not 0 - the y-axis already prints its own "0" right + // at the origin corner, and a second "0" from the x-axis lands on top + // of it. + const xStep = niceStep(xMax, canvas.clientWidth - margin.left - margin.right, 40); + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let t = xStep; t <= xMax; t += xStep) { + ctx.fillText(String(t), xToPx(t), height - margin.bottom + 6); + } + + // Right-hand error axis. Its own scale, so a spike in mistakes is legible + // regardless of how fast the run was. + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + for (let v = 0; v <= errMax; v += errStep) { + ctx.fillText(String(v), width - margin.right + 6, yErrToPx(v)); + } + + // Axis titles. The x axis had none at all, so its numbers could have been + // seconds, words or buckets. + drawAxisTitle(ctx, 'words per minute', 12, margin.top + (height - margin.top - margin.bottom) / 2, -Math.PI / 2, colors.sub); + drawAxisTitle(ctx, 'errors', width - 10, margin.top + (height - margin.top - margin.bottom) / 2, Math.PI / 2, colors.sub); + drawAxisTitle(ctx, 'seconds', margin.left + (width - margin.left - margin.right) / 2, height - 6, 0, colors.sub); + + const drawLine = (key, color, dashed) => { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = dashed ? 1.5 : 2; + ctx.setLineDash(dashed ? [6, 5] : []); + const pts = graphPoints.map(p => ({ x: xToPx(p.time), y: yToPx(p[key]) })); + strokeSmooth(ctx, pts); + ctx.stroke(); + ctx.setLineDash([]); + }; + drawLine('raw', colors.secondary, true); + drawLine('wpm', colors.primary, false); + + const baselineY = yErrToPx(0); + for (const p of errorPoints) { + const ex = xToPx(p.x); + const ey = yErrToPx(p.count || 1); + // Stem first, so the dot sits on top of it. + ctx.beginPath(); + ctx.strokeStyle = colors.error; + ctx.globalAlpha = 0.45; + ctx.lineWidth = 1; + ctx.moveTo(ex, baselineY); + ctx.lineTo(ex, ey); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.fillStyle = colors.error; + ctx.arc(ex, ey, 3, 0, Math.PI * 2); + ctx.fill(); + } + + drawLegend(ctx, width, colors, margin); +} + +// A rotated axis caption. Canvas has no text-orientation, so the context is +// rotated around the label's own centre and restored immediately. +function drawAxisTitle(ctx, text, x, y, angle, color) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.fillText(text, 0, 0); + ctx.restore(); + ctx.font = '11px "JetBrains Mono", monospace'; +} + +// Centripetal-ish Catmull-Rom through the points, emitted as bezier segments. +// A raw polyline made every one-second bucket a hard corner, which read as +// far noisier than the underlying pace actually was. +function strokeSmooth(ctx, pts) { + if (pts.length === 0) return; + ctx.moveTo(pts[0].x, pts[0].y); + if (pts.length < 3) { + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + return; + } + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] || pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] || p2; + // 1/6 keeps the curve close to the data; a larger factor overshoots + // into visible bulges around sharp changes of pace. + ctx.bezierCurveTo( + p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6, + p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6, + p2.x, p2.y, + ); + } +} + +function drawLegend(ctx, width, colors, margin) { + const entries = [ + { label: 'wpm', color: colors.primary, dashed: false }, + { label: 'raw', color: colors.secondary, dashed: true }, + { label: 'error', color: colors.error, dot: true }, + ]; + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const swatchW = 16; + const gap = 6; + const groupGap = 18; + // Left-aligned inside the plot rather than flush to the right edge, where + // it sat on top of the error axis's tick labels. + let x = margin.left + 8; + const y = 8; + for (const e of entries) { + if (e.dot) { + ctx.fillStyle = e.color; + ctx.beginPath(); + ctx.arc(x + swatchW / 2, y, 3, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.strokeStyle = e.color; + ctx.lineWidth = e.dashed ? 1.5 : 2; + ctx.setLineDash(e.dashed ? [4, 3] : []); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + swatchW, y); + ctx.stroke(); + ctx.setLineDash([]); + } + ctx.fillStyle = colors.sub; + ctx.fillText(e.label, x + swatchW + gap, y); + x += swatchW + gap + ctx.measureText(e.label).width + groupGap; + } +} + +// WPM across recent completed tests (x-axis is test order, not time within a +// single test) - a different shape from drawChart above, which plots one +// test's speed over its own elapsed seconds. Kept separate rather than +// bent to fit drawChart's per-test contract (its legend assumes raw/error +// series that don't exist here). +export function drawProgressChart(canvas, history) { + const dpr = window.devicePixelRatio || 1; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + if (width === 0 || height === 0) return; + canvas.width = width * dpr; + canvas.height = height * dpr; + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + if (history.length === 0) return; + + const margin = { top: 12, right: 16, bottom: 22, left: 36 }; + const plotW = Math.max(1, width - margin.left - margin.right); + const plotH = Math.max(1, height - margin.top - margin.bottom); + const maxWpm = Math.max(1, ...history.map(h => h.wpm)); + const yStep = niceStep(maxWpm, plotH, 28); + const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep); + const xMax = Math.max(1, history.length - 1); + const xToPx = i => margin.left + (i / xMax) * plotW; + const yToPx = v => margin.top + plotH - (v / yMax) * plotH; + + const colors = themeColors(); + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.strokeStyle = 'rgba(100,102,105,0.15)'; + ctx.fillStyle = colors.sub; + ctx.lineWidth = 1; + for (let v = 0; v <= yMax; v += yStep) { + const y = yToPx(v); + ctx.beginPath(); + ctx.moveTo(margin.left, y); + ctx.lineTo(width - margin.right, y); + ctx.stroke(); + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(Math.round(v)), margin.left - 6, y); + } + + ctx.beginPath(); + ctx.strokeStyle = colors.primary; + ctx.lineWidth = 2; + history.forEach((h, i) => { + const x = xToPx(i); + const y = yToPx(h.wpm); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = colors.sub; + ctx.fillText('oldest', margin.left, height - margin.bottom + 6); + ctx.textAlign = 'right'; + ctx.fillText('latest', width - margin.right, height - margin.bottom + 6); +} + +/// Returns the graph point nearest to mouseX (by time), for a "hover +/// anywhere on the line" readout - not just the error dots. +export function hitTestLine(canvas, graphPoints, xMax, mouseX) { + if (graphPoints.length === 0) return null; + const { xToPx } = getPlotGeometry(canvas, graphPoints, xMax); + let closest = graphPoints[0]; + let closestDist = Math.abs(xToPx(closest.time) - mouseX); + for (const p of graphPoints) { + const dist = Math.abs(xToPx(p.time) - mouseX); + if (dist < closestDist) { + closestDist = dist; + closest = p; + } + } + return closest; +} + +/// Returns the closest error point to (mouseX, mouseY) in canvas-local +/// pixels, if within `radius` px, else null. Used to make the error dots +/// respond to hover instead of being purely decorative. +export function hitTestError(canvas, errorPoints, graphPoints, xMax, mouseX, mouseY, radius = 8) { + if (errorPoints.length === 0) return null; + const { xToPx, yErrToPx } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints); + let closest = null; + let closestDist = radius; + for (const p of errorPoints) { + const px = xToPx(p.x); + const py = yErrToPx(p.count || 1); + const dist = Math.hypot(px - mouseX, py - mouseY); + if (dist <= closestDist) { + closestDist = dist; + closest = p; + } + } + return closest; +} diff --git a/web/src/cornerRail.js b/web/src/cornerRail.js new file mode 100644 index 0000000..6859a3b --- /dev/null +++ b/web/src/cornerRail.js @@ -0,0 +1,125 @@ +import { STATS_ICON, SETTINGS_ICON, MULTIPLAYER_ICON, LEADERBOARD_ICON, STORE_ICON } from './screens/icons.js'; +import { attachTooltips } from './tooltip.js'; +import { getSettings, updateSettings } from './settings.js'; +import { onCountsChange, getCounts } from './counts.js'; + +const SOUND_THEMES = ['off', 'click', 'mech']; +function soundThemeLabel(theme) { + if (theme === 'click') return 'Click'; + if (theme === 'mech') return 'Mechanical'; + return 'Off'; +} + +// The icon rails, in the same places on every screen - the main menu builds +// the identical clusters inline (see mainMenu.js) because it owns its own +// Settings panel, so the two must stay visually in step. +// +// Top-left, under the wordmark: what customises the app (Settings, Store). +// Bottom-right: numbers and play (Stats, Leaderboard, Multiplayer). Identity +// - Account and Friends - lives in the top-right rail beside the language +// and theme controls (see topRail.js), and the bottom-left corner is +// deliberately left empty so the passage and its stats have room. +// +// Multiplayer carries a live count, so you can see whether anyone is around +// to race without opening the screen. +export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowMultiplayer, onShowStore }) { + const settings = getSettings(); + const left = document.createElement('div'); + left.className = 'corner-rail-left'; + left.innerHTML = ` + <div class="rail-settings-group"> + <button class="corner-icon-button" data-action="rail-settings" aria-label="Settings" data-tooltip="Settings">${SETTINGS_ICON}</button> + <div class="rail-settings-panel" hidden> + <div class="settings-hint">Typing</div> + <div class="settings-row-group"> + <button class="menu-button small" data-action="rail-toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button> + <button class="menu-button small" data-action="rail-toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="rail-toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="rail-cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${soundThemeLabel(settings.soundTheme)}</button> + </div> + </div> + </div> + + ${onShowStore ? `<button class="corner-icon-button" data-action="rail-store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button>` : ''} + `; + + const right = document.createElement('div'); + right.className = 'corner-rail-right'; + right.innerHTML = ` + ${onShowStats ? `<button class="corner-icon-button" data-action="rail-stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button>` : ''} + ${onShowLeaderboard ? `<button class="corner-icon-button" data-action="rail-leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button>` : ''} + ${onShowMultiplayer ? `<button class="corner-icon-button has-badge" data-action="rail-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}<span class="rail-badge accent" data-badge="online" hidden></span></button>` : ''} + `; + + root.appendChild(left); + root.appendChild(right); + attachTooltips(left); + attachTooltips(right); + + const statsBtn = right.querySelector('[data-action="rail-stats"]'); + if (statsBtn) statsBtn.addEventListener('click', onShowStats); + + const settingsGroup = left.querySelector('.rail-settings-group'); + const settingsPanel = left.querySelector('.rail-settings-panel'); + left.querySelector('[data-action="rail-settings"]').addEventListener('click', e => { + e.stopPropagation(); + settingsPanel.hidden = !settingsPanel.hidden; + }); + const liveStatsBtn = left.querySelector('[data-action="rail-toggle-live-stats"]'); + liveStatsBtn.addEventListener('click', () => { + const next = !getSettings().hideLiveStats; + updateSettings({ hideLiveStats: next }); + liveStatsBtn.textContent = `Live Stats: ${next ? 'Off' : 'On'}`; + }); + const caretBlinkBtn = left.querySelector('[data-action="rail-toggle-caret-blink"]'); + caretBlinkBtn.addEventListener('click', () => { + const next = !getSettings().caretBlink; + updateSettings({ caretBlink: next }); + caretBlinkBtn.textContent = `Blink Caret: ${next ? 'On' : 'Off'}`; + }); + const blindModeBtn = left.querySelector('[data-action="rail-toggle-blind-mode"]'); + blindModeBtn.addEventListener('click', () => { + const next = !getSettings().blindMode; + updateSettings({ blindMode: next }); + blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`; + }); + const soundThemeBtn = left.querySelector('[data-action="rail-cycle-sound-theme"]'); + soundThemeBtn.addEventListener('click', () => { + const current = getSettings().soundTheme; + const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length]; + updateSettings({ soundTheme: next }); + soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; + }); + + const multiplayerBtn = right.querySelector('[data-action="rail-multiplayer"]'); + if (multiplayerBtn) multiplayerBtn.addEventListener('click', onShowMultiplayer); + const leaderboardBtn = right.querySelector('[data-action="rail-leaderboard"]'); + if (leaderboardBtn) leaderboardBtn.addEventListener('click', onShowLeaderboard); + const storeBtn = left.querySelector('[data-action="rail-store"]'); + if (storeBtn) storeBtn.addEventListener('click', onShowStore); + + // Badges ----------------------------------------------------------- + const onlineBadge = right.querySelector('[data-badge="online"]'); + function paintBadges({ online }) { + if (onlineBadge) { + onlineBadge.hidden = !online; + onlineBadge.textContent = String(online); + const btn = onlineBadge.closest('.corner-icon-button'); + if (btn) btn.dataset.tooltip = online === 1 ? '1 player racing now' : `${online} players racing now`; + } + } + paintBadges(getCounts()); + const unsubscribeCounts = onCountsChange(paintBadges); + + const handleOutsideClick = e => { + if (!settingsPanel.hidden && !settingsGroup.contains(e.target)) settingsPanel.hidden = true; + }; + document.addEventListener('click', handleOutsideClick); + + return () => { + unsubscribeCounts(); + document.removeEventListener('click', handleOutsideClick); + left.remove(); + right.remove(); + }; +} diff --git a/web/src/counts.js b/web/src/counts.js new file mode 100644 index 0000000..1b98bbe --- /dev/null +++ b/web/src/counts.js @@ -0,0 +1,61 @@ +import { api } from './api.js'; +import { getOnlineCount } from './multiplayer.js'; +import { getUser, onAuthChange } from './auth.js'; + +// Live counts for the badges on the Friends and Multiplayer controls: how +// many people you can race right now, and how many friends you have. +// +// Shared and cached rather than fetched per control. The rail is rebuilt on +// every screen change, and without this each navigation fired a fresh pair of +// requests - and the main menu's Multiplayer button would have fired its own +// on top of that, asking the server the same question twice per screen. +const REFRESH_MS = 30000; + +let cache = { online: 0, friends: 0 }; +const listeners = new Set(); +let timer = null; +let inFlight = false; + +async function refresh() { + if (inFlight) return; + inFlight = true; + try { + const online = await getOnlineCount().then(r => r.players).catch(() => cache.online); + // Friends only exist for a signed-in user; asking while signed out is + // a guaranteed 401, so skip it rather than log noise on every poll. + const friends = getUser() + ? await api.get('/api/friends').then(r => (r.friends || []).length).catch(() => cache.friends) + : 0; + cache = { online, friends }; + for (const fn of listeners) fn(cache); + } finally { + inFlight = false; + } +} + +export function getCounts() { + return cache; +} + +// Returns an unsubscribe function. Polling runs only while something is +// listening, so a screen with no badges costs nothing. +export function onCountsChange(fn) { + listeners.add(fn); + if (!timer) { + refresh(); + timer = setInterval(refresh, REFRESH_MS); + } + return () => { + listeners.delete(fn); + if (listeners.size === 0 && timer) { + clearInterval(timer); + timer = null; + } + }; +} + +// Signing in or out changes the friend count immediately - waiting up to the +// full poll interval would leave a stale badge behind. +onAuthChange(() => { + if (listeners.size > 0) refresh(); +}); diff --git a/web/src/customText.js b/web/src/customText.js new file mode 100644 index 0000000..dc74541 --- /dev/null +++ b/web/src/customText.js @@ -0,0 +1,155 @@ +const LANGUAGE_BY_EXT = { + js: 'javascript', jsx: 'javascript', mjs: 'javascript', ts: 'javascript', tsx: 'javascript', + py: 'python', + rs: 'rust', + c: 'clike', h: 'clike', cpp: 'clike', hpp: 'clike', cc: 'clike', java: 'clike', go: 'clike', cs: 'clike', + sh: 'shell', bash: 'shell', zsh: 'shell', +}; + +const KEYWORDS = { + javascript: new Set(['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: new Set(['def', 'return', 'if', 'elif', 'else', 'for', 'while', 'class', 'import', 'from', 'as', 'try', 'except', 'with', 'lambda', 'None', 'True', 'False', 'pass', 'break', 'continue', 'yield', 'self']), + rust: new Set(['fn', 'let', 'mut', 'return', 'if', 'else', 'for', 'while', 'loop', 'match', 'struct', 'enum', 'impl', 'trait', 'pub', 'use', 'mod', 'self', 'Self', 'true', 'false', 'const', 'static']), + clike: new Set(['int', 'float', 'double', 'char', 'void', 'if', 'else', 'for', 'while', 'return', 'struct', 'class', 'public', 'private', 'static', 'const', 'new', 'true', 'false', 'null']), + shell: new Set(['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'function', 'echo', 'export', 'local']), +}; + +const LINE_COMMENT = { javascript: '//', rust: '//', clike: '//', shell: '#', python: '#' }; + +export function languageForFilename(filename) { + const ext = (filename || '').split('.').pop().toLowerCase(); + return LANGUAGE_BY_EXT[ext] || null; +} + +export function highlightClasses(text, language) { + const classes = new Array(text.length).fill(null); + if (!language || !KEYWORDS[language]) return classes; + const keywords = KEYWORDS[language]; + const commentMarker = LINE_COMMENT[language]; + const tokenRegex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\/\/[^\n]*|#[^\n]*|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z0-9_]*)/g; + let match; + while ((match = tokenRegex.exec(text)) !== null) { + const token = match[0]; + const start = match.index; + let cls = null; + if (token[0] === '"' || token[0] === "'" || token[0] === '`') { + cls = 'syn-string'; + } else if ((token.startsWith('//') || token.startsWith('#')) && token.startsWith(commentMarker || '\0')) { + cls = 'syn-comment'; + } else if (/^\d/.test(token)) { + cls = 'syn-number'; + } else if (keywords.has(token)) { + cls = 'syn-keyword'; + } + if (cls) { + for (let i = 0; i < token.length; i++) classes[start + i] = cls; + } + } + return classes; +} + +function splitLong(str, maxLen = 400) { + if (str.length <= maxLen) return [str]; + const parts = []; + let start = 0; + while (start < str.length) { + const end = Math.min(start + maxLen, str.length); + const slice = str.slice(start, end); + const lastNewline = slice.lastIndexOf('\n'); + const lastSpace = slice.lastIndexOf(' '); + const minCut = Math.floor(maxLen * 0.4); + const cut = lastNewline > minCut ? lastNewline + 1 : (lastSpace > minCut ? lastSpace + 1 : slice.length); + parts.push(str.slice(start, start + cut)); + start += cut; + } + return parts; +} + +export function chunkPlainText(raw) { + const normalized = raw.replace(/\r\n/g, '\n').trim(); + if (!normalized) return []; + const paragraphs = normalized.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); + const source = paragraphs.length > 1 ? paragraphs : normalized.split('\n').map(l => l.trim()).filter(Boolean); + const chunks = []; + for (const block of source) { + for (const piece of splitLong(block)) { + const trimmed = piece.trim(); + if (trimmed) chunks.push({ content: trimmed, time: null }); + } + } + return chunks; +} + +function timeToSeconds(str) { + // "00:00:01,000" / "00:00:01.000" (h:mm:ss) or "00:12.34" (mm:ss, lrc-style) + const parts = str.replace(',', '.').split(':').map(Number); + if (parts.some(Number.isNaN)) return null; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parts[0] ?? null; +} + +function parseSrt(raw) { + const blocks = raw.replace(/\r\n/g, '\n').trim().split(/\n\s*\n/); + const chunks = []; + for (const block of blocks) { + const lines = block.split('\n').filter(Boolean); + let i = 0; + if (/^\d+$/.test((lines[i] || '').trim())) i += 1; + let time = null; + const timing = lines[i] || ''; + const startMatch = timing.match(/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->/); + if (startMatch) { + time = timeToSeconds(startMatch[1]); + i += 1; + } + const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim(); + if (text) chunks.push({ content: text, time }); + } + return chunks; +} + +function parseVtt(raw) { + const body = raw.replace(/\r\n/g, '\n').replace(/^WEBVTT[^\n]*\n/, '').trim(); + const blocks = body.split(/\n\s*\n/); + const chunks = []; + for (const block of blocks) { + const lines = block.split('\n').filter(Boolean); + let i = 0; + if (lines[i] && !/-->/.test(lines[i])) i += 1; // optional cue identifier + let time = null; + const timing = lines[i] || ''; + const startMatch = timing.match(/(\d{2}:)?(\d{2}:\d{2}[.,]\d{3})\s*-->/); + if (startMatch) { + time = timeToSeconds((startMatch[1] || '') + startMatch[2]); + i += 1; + } + const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim(); + if (text) chunks.push({ content: text, time }); + } + return chunks; +} + +function parseLrc(raw) { + const lines = raw.replace(/\r\n/g, '\n').split('\n'); + const chunks = []; + for (const line of lines) { + const match = line.match(/^((?:\[\d{2}:\d{2}(?:\.\d{2,3})?\])+)(.*)$/); + if (!match) continue; + const text = match[2].trim(); + if (!text) continue; + const firstStamp = match[1].match(/\[(\d{2}:\d{2}(?:\.\d{2,3})?)\]/); + const time = firstStamp ? timeToSeconds(firstStamp[1]) : null; + chunks.push({ content: text, time }); + } + return chunks; +} + +export function parseCustomContent(raw, filename) { + const ext = (filename || '').split('.').pop().toLowerCase(); + if (ext === 'srt') return { chunks: parseSrt(raw), language: null, timed: true }; + if (ext === 'vtt') return { chunks: parseVtt(raw), language: null, timed: true }; + if (ext === 'lrc') return { chunks: parseLrc(raw), language: null, timed: true }; + const language = languageForFilename(filename); + return { chunks: chunkPlainText(raw), language, timed: false }; +} diff --git a/web/src/data/texts.json b/web/src/data/texts.json new file mode 100644 index 0000000..0aef950 --- /dev/null +++ b/web/src/data/texts.json @@ -0,0 +1,978 @@ +[ + { + "category": "history", + "content": "The Cold War divided much of the world into two opposing blocs for nearly half a century, one aligned with the United States and the other with the Soviet Union.", + "attribution": "History" + }, + { + "category": "philosophy", + "content": "Existentialism explores questions about individual freedom, choice, and meaning, arguing that people must create their own purpose in a universe that does not provide one automatically.", + "attribution": "Philosophy" + }, + { + "category": "business", + "content": "The man who dies rich, dies disgraced.", + "attribution": "Andrew Carnegie" + }, + { + "category": "movies", + "content": "Stop motion animation creates the illusion of movement by photographing physical objects one frame at a time and repositioning them slightly between each shot.", + "attribution": "Film" + }, + { + "category": "technology", + "content": "A blockchain is a distributed ledger that records transactions across many computers, making it difficult to alter any single record without changing every subsequent block.", + "attribution": "Technology" + }, + { + "category": "nature", + "content": "Bees pollinate roughly a third of the crops humans rely on for food, making them essential to global agriculture despite their small size.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "A balance sheet summarizes what a company owns and owes at a single point in time, split into assets, liabilities, and shareholders' equity.", + "attribution": "Business" + }, + { + "category": "history", + "content": "The Industrial Revolution transformed economies across Europe and North America during the eighteenth and nineteenth centuries, shifting production from hand tools to machines.", + "attribution": "History" + }, + { + "category": "literature", + "content": "In my younger and more vulnerable years my father gave me some advice that I've been turning over in my mind ever since. Whenever you feel like criticizing anyone, he told me, just remember that all the people in this world haven't had the advantages that you've had.", + "attribution": "F. Scott Fitzgerald, The Great Gatsby" + }, + { + "category": "movies", + "content": "Method acting, popularized in the mid twentieth century, encourages performers to draw on personal memories and emotions to portray a character as realistically as possible.", + "attribution": "Film" + }, + { + "category": "quotes", + "content": "Whatever you are, be a good one.", + "attribution": "Abraham Lincoln" + }, + { + "category": "general", + "content": "A day on Venus is longer than its year. The planet rotates so slowly on its axis that it takes longer to spin around once than it does to complete an entire orbit around the sun.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "If there is no struggle, there is no progress. Those who profess to favor freedom, and yet depreciate agitation, are men who want crops without plowing up the ground.", + "attribution": "Frederick Douglass" + }, + { + "category": "programming", + "content": "def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)", + "attribution": "Python", + "language": "python" + }, + { + "category": "general", + "content": "The Great Wall of China was not built all at once. It was constructed and extended over many centuries by different dynasties, each adding new sections and rebuilding older ones to defend against invasions from the north.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "struct node { int value; struct node *next; };", + "attribution": "C", + "language": "clike" + }, + { + "category": "general", + "content": "The Pacific Ocean covers more of the planet than all the continents combined. It stretches from the icy waters near Antarctica to the Arctic Circle, and its deepest point, the Mariana Trench, plunges nearly eleven kilometers below the surface.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Marie Curie remains the only person to win Nobel Prizes in two different sciences, physics and chemistry, for her pioneering research into radioactivity.", + "attribution": "Science" + }, + { + "category": "literature", + "content": "Call me Ishmael. Some years ago, never mind how long precisely, having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.", + "attribution": "Herman Melville, Moby-Dick" + }, + { + "category": "technology", + "content": "A virtual private network encrypts a device's internet connection, helping to protect data from being intercepted while it travels over a public or untrusted network.", + "attribution": "Technology" + }, + { + "category": "philosophy", + "content": "It is not that we have a short time to live, but that we waste a lot of it.", + "attribution": "Seneca, On the Shortness of Life" + }, + { + "category": "nature", + "content": "Deserts are defined by low precipitation rather than temperature, which is why some of the coldest places on Earth, including parts of Antarctica, are technically deserts.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "Sound travels as a wave of pressure through a medium such as air or water, and it cannot travel through the vacuum of space, since there are no particles to carry the vibration.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "The Amazon rainforest is home to an estimated ten percent of all known species on Earth, making it one of the most biodiverse regions on the planet.", + "attribution": "Nature" + }, + { + "category": "programming", + "content": "with open('data.csv', encoding='utf-8') as f: rows = [line.split(',') for line in f]", + "attribution": "Python", + "language": "python" + }, + { + "category": "quotes", + "content": "The only limit to our realization of tomorrow is our doubts of today.", + "attribution": "Franklin D. Roosevelt" + }, + { + "category": "quotes", + "content": "Genius is one percent inspiration and ninety-nine percent perspiration.", + "attribution": "Thomas Edison" + }, + { + "category": "technology", + "content": "Cloud computing allows businesses and individuals to rent computing power and storage over the internet instead of maintaining their own physical servers.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "Silent films relied on live musicians and title cards to convey dialogue and emotion, since synchronized sound was not introduced into feature films until the late 1920s.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness.", + "attribution": "Charles Dickens, A Tale of Two Cities" + }, + { + "category": "quotes", + "content": "It always seems impossible until it's done.", + "attribution": "Nelson Mandela" + }, + { + "category": "general", + "content": "Bamboo is one of the fastest growing plants on Earth. Some species can grow nearly a meter in a single day under the right conditions, which is why it is used so widely in construction and manufacturing across Asia.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "const unique = [...new Set(values)].sort((a, b) => a - b);", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "nature", + "content": "The Sahara Desert was once a green landscape with lakes and grasslands, before a shift in the Earth's orbit altered rainfall patterns thousands of years ago.", + "attribution": "Nature" + }, + { + "category": "movies", + "content": "Alfred Hitchcock was known for making brief cameo appearances in nearly all of his own films, a habit that became something of a signature.", + "attribution": "Film" + }, + { + "category": "general", + "content": "The invention of the printing press in the fifteenth century transformed how knowledge spread across Europe. Books that once took months to copy by hand could suddenly be reproduced by the hundreds, making literacy far more accessible.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Genetics research has shown that humans share about ninety-nine percent of their DNA with chimpanzees, the closest living relative to our species.", + "attribution": "Science" + }, + { + "category": "programming", + "content": "if err != nil { return fmt.Errorf(\"read config: %w\", err) }", + "attribution": "Go", + "language": "clike" + }, + { + "category": "history", + "content": "The fall of the Berlin Wall in 1989 marked a turning point in the Cold War, as East and West Germany moved toward reunification the following year.", + "attribution": "History" + }, + { + "category": "business", + "content": "The purpose of a business is to create and keep a customer.", + "attribution": "Peter Drucker" + }, + { + "category": "technology", + "content": "Open source software is released with its source code publicly available, allowing anyone to view, modify, and distribute it under the terms of its license.", + "attribution": "Technology" + }, + { + "category": "business", + "content": "A startup's burn rate measures how quickly it spends its available cash, an important number for founders trying to plan how long the company can operate before running out of money.", + "attribution": "Business" + }, + { + "category": "philosophy", + "content": "I think, therefore I am.", + "attribution": "Rene Descartes, Discourse on the Method" + }, + { + "category": "programming", + "content": "export PATH=\"$HOME/.local/bin:$PATH\" && command -v rg >/dev/null || echo missing", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "programming", + "content": "if (typeof value !== 'string' || value.length === 0) { return null; }", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "philosophy", + "content": "Every new beginning comes from some other beginning's end.", + "attribution": "Seneca, Letters from a Stoic" + }, + { + "category": "history", + "content": "Ancient Egyptian civilization flourished along the Nile River for thousands of years, leaving behind monuments such as the pyramids of Giza and the temples of Luxor.", + "attribution": "History" + }, + { + "category": "literature", + "content": "To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex.", + "attribution": "Arthur Conan Doyle, A Scandal in Bohemia" + }, + { + "category": "literature", + "content": "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip.", + "attribution": "Charles Dickens, Great Expectations" + }, + { + "category": "literature", + "content": "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families.", + "attribution": "Jane Austen, Pride and Prejudice" + }, + { + "category": "technology", + "content": "The World Wide Web was invented by Tim Berners-Lee in 1989 as a way to share documents over the internet using hyperlinks.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "The highest grossing films of all time are dominated by franchises built around superheroes, fantasy worlds, and long running science fiction universes.", + "attribution": "Film" + }, + { + "category": "programming", + "content": "match result { Ok(v) => println!(\"{v}\"), Err(e) => eprintln!(\"error: {e}\") }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "programming", + "content": "let total: i64 = values.iter().filter(|v| **v > 0).sum();", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "science", + "content": "Water is one of the few substances that becomes less dense as it freezes, which is why ice floats on top of liquid water instead of sinking to the bottom.", + "attribution": "Science" + }, + { + "category": "movies", + "content": "The Academy Awards, commonly known as the Oscars, have been presented annually since 1929 to honor achievements in the film industry.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "All we have to decide is what to do with the time that is given us.", + "attribution": "J.R.R. Tolkien, The Fellowship of the Ring" + }, + { + "category": "business", + "content": "Market research helps a business understand its customers' needs and preferences before committing resources to a new product or service.", + "attribution": "Business" + }, + { + "category": "general", + "content": "A group of flamingos is called a flamboyance. The birds get their pink color from the pigments in the shrimp and algae they eat.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "for f in *.log; do gzip -9 \"$f\" && echo \"packed $f\"; done", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "business", + "content": "Customer obsession, rather than competitor focus, is the surest path to long term success.", + "attribution": "Jeff Bezos" + }, + { + "category": "technology", + "content": "A central processing unit executes the instructions of a computer program by performing basic arithmetic, logic, and input and output operations.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "The speed of light in a vacuum is approximately two hundred ninety nine thousand, seven hundred ninety two kilometers per second, and nothing with mass can reach or exceed it.", + "attribution": "Science" + }, + { + "category": "science", + "content": "The human body contains roughly thirty seven trillion cells, each one specialized to carry out one of hundreds of different jobs inside the body.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "In every walk with nature one receives far more than he seeks.", + "attribution": "John Muir" + }, + { + "category": "business", + "content": "Branding is the practice of shaping how customers perceive a company, going beyond a logo to include tone, values, and the overall experience of using a product.", + "attribution": "Business" + }, + { + "category": "technology", + "content": "The transistor, invented in 1947, replaced bulky vacuum tubes and became the foundational building block of nearly every modern electronic device.", + "attribution": "Technology" + }, + { + "category": "programming", + "content": "pub struct Point { pub x: f64, pub y: f64 }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "general", + "content": "Octopuses have three hearts and blue blood. Two of the hearts pump blood to the gills, while the third circulates it to the rest of the body, and the blue color comes from a copper-based protein instead of iron.", + "attribution": "General knowledge" + }, + { + "category": "general", + "content": "The Amazon rainforest produces roughly twenty percent of the oxygen found in the planet's atmosphere, earning it the nickname the lungs of the Earth.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "If I have seen further, it is by standing on the shoulders of giants.", + "attribution": "Isaac Newton" + }, + { + "category": "general", + "content": "A single bolt of lightning can heat the surrounding air to temperatures hotter than the surface of the sun. The rapid expansion of that superheated air is what produces the crack of thunder we hear moments later.", + "attribution": "General knowledge" + }, + { + "category": "business", + "content": "Henry Ford, founder of the Ford Motor Company, is credited with popularizing the moving assembly line, which dramatically reduced the time needed to build an automobile.", + "attribution": "History of Business" + }, + { + "category": "business", + "content": "A merger combines two companies into one, while an acquisition occurs when one company purchases and absorbs another, though the two terms are often used loosely in everyday conversation.", + "attribution": "Business" + }, + { + "category": "movies", + "content": "The special effects in the original Star Wars trilogy were largely achieved with physical models and practical effects, long before computer generated imagery became common.", + "attribution": "Film" + }, + { + "category": "history", + "content": "The printing press, developed by Johannes Gutenberg in the fifteenth century, made it possible to produce books far more quickly and cheaply than copying them by hand.", + "attribution": "History" + }, + { + "category": "science", + "content": "Vaccines work by training the immune system to recognize a weakened or partial version of a pathogen, so the body can respond quickly if it ever encounters the real threat.", + "attribution": "Science" + }, + { + "category": "nature", + "content": "Wolves live and hunt in structured packs, usually led by a breeding pair, and communicate through howls that can be heard for several kilometers.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "Supply and demand describes how the price of a good tends to rise when demand exceeds supply, and fall when supply exceeds demand.", + "attribution": "Business" + }, + { + "category": "nature", + "content": "The deepest part of the ocean, the Mariana Trench, plunges nearly eleven kilometers below the surface, deeper than Mount Everest is tall.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "I read that every known superstition in the world is gathered into the horseshoe of the Carpathians, as if it were the centre of some sort of imaginative whirlpool, and it made me uneasy in a way I could not explain.", + "attribution": "Bram Stoker, Dracula" + }, + { + "category": "general", + "content": "The human brain generates enough electricity to power a small light bulb. Billions of neurons firing electrical signals at once produce roughly the same wattage as a dim household lamp.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.", + "attribution": "Abraham Lincoln, The Gettysburg Address" + }, + { + "category": "programming", + "content": "export async function load(url) { const res = await fetch(url); return res.json(); }", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "science", + "content": "Mitochondria convert nutrients into a usable form of energy for the cell, which is why biology textbooks often call them the powerhouse of the cell.", + "attribution": "Science" + }, + { + "category": "history", + "content": "The Wright brothers achieved the first powered, controlled flight in 1903 near Kitty Hawk, North Carolina, a flight that lasted only twelve seconds.", + "attribution": "History" + }, + { + "category": "literature", + "content": "It was the best of times, it was the worst of times.", + "attribution": "Charles Dickens, A Tale of Two Cities" + }, + { + "category": "movies", + "content": "Pixar's first feature film, Toy Story, released in 1995, was the first full length movie created entirely with computer animation.", + "attribution": "Film" + }, + { + "category": "business", + "content": "Diversification spreads investment risk across different assets, so that a poor outcome in one area does not sink an entire portfolio.", + "attribution": "Business" + }, + { + "category": "quotes", + "content": "It is not the critic who counts; not the man who points out how the strong man stumbles. The credit belongs to the man who is actually in the arena, whose face is marred by dust and sweat and blood.", + "attribution": "Theodore Roosevelt, Citizenship in a Republic" + }, + { + "category": "programming", + "content": "let mut counts: HashMap<String, usize> = HashMap::new();", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "quotes", + "content": "The two most important days in your life are the day you are born and the day you find out why.", + "attribution": "Mark Twain" + }, + { + "category": "philosophy", + "content": "The Socratic method uses a series of questions and answers to expose contradictions in a person's beliefs, encouraging deeper critical thinking rather than simply providing answers.", + "attribution": "Philosophy" + }, + { + "category": "programming", + "content": "const total = items.reduce((acc, item) => acc + item.price, 0);", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "programming", + "content": "counts = {word: text.count(word) for word in set(text.split())}", + "attribution": "Python", + "language": "python" + }, + { + "category": "general", + "content": "Honey never spoils if it is stored properly. Archaeologists have found pots of honey in ancient Egyptian tombs that are thousands of years old and still perfectly edible, preserved by its low moisture content and natural acidity.", + "attribution": "General knowledge" + }, + { + "category": "quotes", + "content": "Failure is impossible.", + "attribution": "Susan B. Anthony" + }, + { + "category": "literature", + "content": "Mr. Sherlock Holmes, who was usually very late in the mornings, save upon those not infrequent occasions when he was up all night, was seated at the breakfast table.", + "attribution": "Arthur Conan Doyle, The Hound of the Baskervilles" + }, + { + "category": "general", + "content": "Bananas are botanically classified as berries, while strawberries are not. The classification depends on how the fruit develops from the flower, not on taste or size.", + "attribution": "General knowledge" + }, + { + "category": "science", + "content": "Evolution by natural selection describes how organisms with traits better suited to their environment tend to survive and reproduce more successfully, gradually shaping a population over many generations.", + "attribution": "Science, after Charles Darwin" + }, + { + "category": "quotes", + "content": "Success is to be measured not so much by the position that one has reached in life as by the obstacles which he has overcome while trying to succeed.", + "attribution": "Booker T. Washington" + }, + { + "category": "programming", + "content": "impl Display for Token { fn fmt(&self, f: &mut Formatter) -> Result { write!(f, \"{}\", self.0) } }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "history", + "content": "The invention of the wheel around 3500 BCE is considered one of the most important technological breakthroughs in human history, transforming transportation and trade.", + "attribution": "History" + }, + { + "category": "nature", + "content": "The world is big and I want to have a good look at it before it gets dark.", + "attribution": "John Muir" + }, + { + "category": "general", + "content": "The first computer programmer is widely considered to be Ada Lovelace, who wrote an algorithm for an early mechanical computer in the eighteen forties, decades before any machine capable of running it was ever built.", + "attribution": "General knowledge" + }, + { + "category": "philosophy", + "content": "Stoicism is a school of ancient Greek philosophy that teaches the practice of virtue as the only true good, and encourages focusing only on what lies within one's own control.", + "attribution": "Philosophy" + }, + { + "category": "programming", + "content": "grep -rn \"TODO\" src/ | awk -F: '{print $1}' | sort -u", + "attribution": "Shell", + "language": "shell" + }, + { + "category": "general", + "content": "Antarctica is technically the largest desert on the planet. Despite being covered in ice, it receives very little precipitation each year, which is the defining characteristic of a desert rather than temperature alone.", + "attribution": "General knowledge" + }, + { + "category": "general", + "content": "The shortest war in recorded history was fought between Britain and Zanzibar in 1896. It lasted around forty minutes before Zanzibar surrendered.", + "attribution": "General knowledge" + }, + { + "category": "nature", + "content": "A single mature tree can absorb around twenty two kilograms of carbon dioxide per year, making forests an important part of regulating the planet's climate.", + "attribution": "Nature" + }, + { + "category": "business", + "content": "Cash flow tracks the actual movement of money into and out of a business, which can differ significantly from reported profit due to timing differences in payments.", + "attribution": "Business" + }, + { + "category": "programming", + "content": "for (int i = 0; i < n; i++) { sum += a[i] * b[i]; }", + "attribution": "C", + "language": "clike" + }, + { + "category": "business", + "content": "Price is what you pay, and value is what you get.", + "attribution": "Warren Buffett" + }, + { + "category": "philosophy", + "content": "The only true wisdom is in knowing you know nothing.", + "attribution": "Socrates" + }, + { + "category": "general", + "content": "The Eiffel Tower grows about six inches taller in the summer, as the iron structure expands slightly in the heat and contracts again once the weather cools.", + "attribution": "General knowledge" + }, + { + "category": "business", + "content": "Return on investment measures the profitability of an investment relative to its cost, expressed as a percentage to make it easy to compare different opportunities.", + "attribution": "Business" + }, + { + "category": "movies", + "content": "The Cannes Film Festival, held annually in France since 1946, is considered one of the most prestigious film festivals in the world.", + "attribution": "Film" + }, + { + "category": "history", + "content": "The signing of the Magna Carta in 1215 limited the power of the English king and established the principle that even a monarch was subject to the law.", + "attribution": "History" + }, + { + "category": "movies", + "content": "A film's runtime is typically decided during editing, where an editor works with hours of raw footage to shape a coherent story within a manageable length.", + "attribution": "Film" + }, + { + "category": "philosophy", + "content": "All men by nature desire to know.", + "attribution": "Aristotle, Metaphysics" + }, + { + "category": "philosophy", + "content": "Waste no more time arguing about what a good man should be. Be one.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "technology", + "content": "A firewall monitors and controls incoming and outgoing network traffic based on predetermined security rules, acting as a barrier between a trusted network and an untrusted one.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "The theory of plate tectonics explains how the Earth's outer shell is broken into large plates that slowly drift over the mantle, causing earthquakes, volcanoes, and mountain formation.", + "attribution": "Science" + }, + { + "category": "history", + "content": "The Great Depression of the 1930s began with the stock market crash of 1929 and led to widespread unemployment and economic hardship across the globe.", + "attribution": "History" + }, + { + "category": "movies", + "content": "The Wizard of Oz was one of the first major films to use Technicolor extensively, transitioning from black and white to vivid color once Dorothy arrives in Oz.", + "attribution": "Film" + }, + { + "category": "quotes", + "content": "You have power over your mind, not outside events. Realize this, and you will find strength.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "literature", + "content": "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn.", + "attribution": "Oscar Wilde, The Picture of Dorian Gray" + }, + { + "category": "science", + "content": "The periodic table arranges chemical elements by increasing atomic number, grouping elements with similar properties into the same columns.", + "attribution": "Science" + }, + { + "category": "programming", + "content": "for i, v := range items { fmt.Printf(\"%d: %v\\n\", i, v) }", + "attribution": "Go", + "language": "clike" + }, + { + "category": "history", + "content": "I will fight no more forever.", + "attribution": "Chief Joseph, Nez Perce, 1877" + }, + { + "category": "programming", + "content": "try: value = int(raw) except ValueError: value = 0", + "attribution": "Python", + "language": "python" + }, + { + "category": "programming", + "content": "fn main() { println!(\"{}\", args().skip(1).collect::<Vec<_>>().join(\" \")); }", + "attribution": "Rust", + "language": "rust" + }, + { + "category": "technology", + "content": "Machine learning is a branch of artificial intelligence in which a system improves its performance on a task by learning patterns from data rather than following explicit instructions.", + "attribution": "Technology" + }, + { + "category": "programming", + "content": "document.querySelectorAll('.row').forEach(el => el.classList.toggle('active'));", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "nature", + "content": "Heaven is under our feet as well as over our heads.", + "attribution": "Henry David Thoreau, Walden" + }, + { + "category": "business", + "content": "A monopoly exists when a single company dominates an entire market with no significant competition, often leading regulators to intervene to protect consumers.", + "attribution": "Business" + }, + { + "category": "philosophy", + "content": "Utilitarianism holds that the right action is the one that produces the greatest good for the greatest number of people, judging morality by outcomes rather than intentions.", + "attribution": "Philosophy" + }, + { + "category": "history", + "content": "The Roman Empire at its height stretched from Britain in the north to Egypt in the south, connected by a vast network of roads that still exist in parts of Europe today.", + "attribution": "History" + }, + { + "category": "literature", + "content": "Christmas won't be Christmas without any presents, grumbled Jo, lying on the rug. It's so dreadful to be poor, sighed Meg, looking down at her old dress.", + "attribution": "Louisa May Alcott, Little Women" + }, + { + "category": "technology", + "content": "Artificial intelligence refers to computer systems designed to perform tasks that normally require human intelligence, such as recognizing speech, images, or patterns in data.", + "attribution": "Technology" + }, + { + "category": "literature", + "content": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do. Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.", + "attribution": "Lewis Carroll, Alice's Adventures in Wonderland" + }, + { + "category": "literature", + "content": "A throng of bearded men, in sad-colored garments and gray, steeple-crowned hats, intermixed with women, some wearing hoods, and others bareheaded, was assembled in front of a wooden edifice.", + "attribution": "Nathaniel Hawthorne, The Scarlet Letter" + }, + { + "category": "movies", + "content": "A film's score is often composed after the movie has been edited, allowing the composer to match the music precisely to the pacing and mood of each scene.", + "attribution": "Film" + }, + { + "category": "technology", + "content": "The first practical smartphones combined a mobile phone with computing features such as email, web browsing, and applications, changing how people communicate and work.", + "attribution": "Technology" + }, + { + "category": "quotes", + "content": "To be great is to be misunderstood.", + "attribution": "Ralph Waldo Emerson, Self-Reliance" + }, + { + "category": "technology", + "content": "Robotics combines mechanical engineering, electronics, and computer science to design machines capable of performing tasks autonomously or with minimal human control.", + "attribution": "Technology" + }, + { + "category": "movies", + "content": "The word cinema comes from the Greek word for movement, reflecting how early filmmakers were fascinated by the illusion of motion created by a sequence of still images.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "Not all those who wander are lost.", + "attribution": "J.R.R. Tolkien" + }, + { + "category": "nature", + "content": "A rainforest canopy can be so dense that less than two percent of sunlight reaches the forest floor below.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "Buck did not read the newspapers, or he would have known that trouble was brewing, not alone for himself, but for every tide-water dog, strong of muscle and with warm, long hair, from Puget Sound to San Diego.", + "attribution": "Jack London, The Call of the Wild" + }, + { + "category": "literature", + "content": "The Nellie, a cruising yawl, swung to her anchor without a flutter of the sails, and was at rest. The flood had made, the wind was nearly calm, and being bound down the river, the only thing for it was to come to and wait for the turn of the tide.", + "attribution": "Joseph Conrad, Heart of Darkness" + }, + { + "category": "general", + "content": "Sharks existed long before trees. Fossil evidence suggests that sharks have been swimming in the oceans for more than four hundred million years, while the earliest trees did not appear until tens of millions of years later.", + "attribution": "General knowledge" + }, + { + "category": "history", + "content": "The Renaissance began in Italy in the fourteenth century, sparking a renewed interest in classical art, science, and learning that eventually spread across Europe.", + "attribution": "History" + }, + { + "category": "philosophy", + "content": "The beginning is the most important part of the work.", + "attribution": "Plato, The Republic" + }, + { + "category": "quotes", + "content": "I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived.", + "attribution": "Henry David Thoreau, Walden" + }, + { + "category": "history", + "content": "The Silk Road was not a single road but a network of trade routes connecting China to the Mediterranean, carrying silk, spices, and ideas across the ancient world.", + "attribution": "History" + }, + { + "category": "nature", + "content": "Migratory birds can travel thousands of miles between breeding and wintering grounds, navigating using the sun, stars, and the Earth's magnetic field.", + "attribution": "Nature" + }, + { + "category": "literature", + "content": "You don't know about me without you have read a book by the name of The Adventures of Tom Sawyer, but that ain't no matter. That book was made by Mr. Mark Twain, and he told the truth, mainly.", + "attribution": "Mark Twain, Adventures of Huckleberry Finn" + }, + { + "category": "philosophy", + "content": "The impediment to action advances action. What stands in the way becomes the way.", + "attribution": "Marcus Aurelius, Meditations" + }, + { + "category": "nature", + "content": "Rivers shape the landscape around them over thousands of years, carving canyons and depositing sediment that forms fertile floodplains.", + "attribution": "Nature" + }, + { + "category": "movies", + "content": "The first feature length narrative film, The Story of the Kelly Gang, was made in Australia in 1906 and ran for about an hour.", + "attribution": "Film" + }, + { + "category": "programming", + "content": "const { id, name = 'anonymous', ...rest } = await request.json();", + "attribution": "JavaScript", + "language": "javascript" + }, + { + "category": "science", + "content": "DNA carries the genetic instructions for every living organism, encoded in a sequence of four chemical bases that pair together to form the famous double helix shape.", + "attribution": "Science" + }, + { + "category": "literature", + "content": "The Time Traveller, for so it will be convenient to speak of him, was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated.", + "attribution": "H.G. Wells, The Time Machine" + }, + { + "category": "quotes", + "content": "Stay hungry, stay foolish.", + "attribution": "Steve Jobs" + }, + { + "category": "technology", + "content": "Moore's Law observed that the number of transistors on a computer chip tends to double roughly every two years, driving decades of rapid improvement in computing power.", + "attribution": "Technology" + }, + { + "category": "science", + "content": "Photosynthesis is the process by which plants convert light energy into chemical energy, using carbon dioxide and water to produce glucose and release oxygen as a byproduct.", + "attribution": "Science" + }, + { + "category": "philosophy", + "content": "A journey of a thousand miles begins with a single step.", + "attribution": "Lao Tzu, Tao Te Ching" + }, + { + "category": "literature", + "content": "1801. I have just returned from a visit to my landlord, the solitary neighbour that I shall be troubled with. This is certainly a beautiful country. In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society.", + "attribution": "Emily Bronte, Wuthering Heights" + }, + { + "category": "literature", + "content": "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning, but since dinner the cold winter wind had brought with it clouds so sombre, and a rain so penetrating.", + "attribution": "Charlotte Bronte, Jane Eyre" + }, + { + "category": "general", + "content": "Simplicity is the soul of efficiency.", + "attribution": "Austin Freeman" + }, + { + "category": "nature", + "content": "Mycorrhizal fungi form partnerships with the roots of most plants, helping them absorb water and nutrients in exchange for sugars produced by photosynthesis.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "A black hole forms when a massive star collapses under its own gravity, packing so much mass into so small a space that not even light can escape its pull.", + "attribution": "Science" + }, + { + "category": "quotes", + "content": "That man over there says that women need to be helped into carriages, and lifted over ditches, and to have the best place everywhere. Nobody ever helps me into carriages, or over mud puddles, or gives me any best place, and ain't I a woman?", + "attribution": "Sojourner Truth, Ain't I a Woman?" + }, + { + "category": "general", + "content": "The quick brown fox jumps over the lazy dog.", + "attribution": "Traditional pangram" + }, + { + "category": "general", + "content": "Venus is the hottest planet in the solar system, even though Mercury sits closer to the sun. A thick blanket of carbon dioxide traps heat at the surface.", + "attribution": "General knowledge" + }, + { + "category": "programming", + "content": "int *buf = malloc(len * sizeof(int)); if (!buf) return -1;", + "attribution": "C", + "language": "clike" + }, + { + "category": "movies", + "content": "Citizen Kane, released in 1941, is frequently cited by critics and filmmakers as one of the greatest films ever made, praised for its innovative camera work and narrative structure.", + "attribution": "Film" + }, + { + "category": "literature", + "content": "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet.", + "attribution": "Mary Shelley, Frankenstein" + }, + { + "category": "technology", + "content": "Quantum computing uses the principles of quantum mechanics to process information in ways that classical computers cannot, potentially solving certain problems far faster.", + "attribution": "Technology" + }, + { + "category": "philosophy", + "content": "It does not matter how slowly you go as long as you do not stop.", + "attribution": "Confucius, Analects" + }, + { + "category": "programming", + "content": "public static void main(String[] args) { System.out.println(\"ready\"); }", + "attribution": "Java", + "language": "clike" + }, + { + "category": "quotes", + "content": "Our greatest glory is not in never falling, but in rising every time we fall.", + "attribution": "Confucius" + }, + { + "category": "history", + "content": "The Apollo 11 mission landed the first humans on the Moon in July of 1969, when astronauts Neil Armstrong and Buzz Aldrin walked on the lunar surface.", + "attribution": "History" + }, + { + "category": "literature", + "content": "The year 1866 was signalised by a remarkable incident, a mysterious and inexplicable phenomenon, which doubtless no one has yet forgotten. Ships had met with a huge thing, a long object, spindle-shaped, and at times phosphorescent.", + "attribution": "Jules Verne, Twenty Thousand Leagues Under the Sea" + }, + { + "category": "programming", + "content": "squares = [n * n for n in range(1, 21) if n % 3 != 0]", + "attribution": "Python", + "language": "python" + }, + { + "category": "philosophy", + "content": "It's not what happens to you, but how you react to it that matters.", + "attribution": "Epictetus" + }, + { + "category": "nature", + "content": "Coral reefs cover less than one percent of the ocean floor, yet they support roughly a quarter of all marine species.", + "attribution": "Nature" + }, + { + "category": "science", + "content": "Newton's first law of motion states that an object at rest stays at rest, and an object in motion stays in motion, unless acted on by an outside force.", + "attribution": "Science" + }, + { + "category": "technology", + "content": "Fiber optic cables transmit data as pulses of light through thin strands of glass, allowing information to travel at extremely high speeds over long distances.", + "attribution": "Technology" + } +]
\ No newline at end of file diff --git a/web/src/deviceDetect.js b/web/src/deviceDetect.js new file mode 100644 index 0000000..bef49dc --- /dev/null +++ b/web/src/deviceDetect.js @@ -0,0 +1,10 @@ +// Coarse touch/mobile detection for the "match/rank against desktop only" +// preference - not trying to be a precise device fingerprint, just enough +// to separate "typed on a physical keyboard" from "typed on a touchscreen" +// for fairness purposes (see anticheat.rs's comment on why this is opt-in +// tagging, not a default exclusion). +export function detectDeviceType() { + const coarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches; + const touchPoints = navigator.maxTouchPoints > 0; + return coarsePointer && touchPoints ? 'mobile' : 'desktop'; +} diff --git a/web/src/game.js b/web/src/game.js new file mode 100644 index 0000000..2e8681c --- /dev/null +++ b/web/src/game.js @@ -0,0 +1,17 @@ +import init, { TyperPunkGame } from '../wasm/typerpunk_wasm.js'; + +const ready = init(); + +export async function createGame() { + await ready; + return new TyperPunkGame(''); +} + +export function freeGame(game) { + if (!game) return; + try { + game.free(); + } catch (err) { + console.error('Error freeing game:', err); + } +} diff --git a/web/src/keySounds.js b/web/src/keySounds.js new file mode 100644 index 0000000..1a20087 --- /dev/null +++ b/web/src/keySounds.js @@ -0,0 +1,42 @@ +import { getSettings } from './settings.js'; + +// Synthesized rather than sampled - a couple of short tones via the Web +// Audio API instead of shipping/loading actual click-sample audio files, +// so the feature has zero asset dependencies. +const THEMES = { + click: { freq: 1800, duration: 0.03, type: 'square' }, + mech: { freq: 180, duration: 0.05, type: 'triangle' }, +}; + +let audioCtx = null; +function getContext() { + if (!audioCtx) { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + audioCtx = new AudioContextClass(); + } + return audioCtx; +} + +// Browsers refuse to start an AudioContext before a user gesture - the +// first keystroke that reaches here always follows one (focusing/typing +// into the input), so resuming inline rather than requiring a separate +// "enable sound" click. +export function playKeySound() { + const { soundTheme } = getSettings(); + const theme = THEMES[soundTheme]; + if (!theme) return; + + const ctx = getContext(); + if (ctx.state === 'suspended') ctx.resume(); + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = theme.type; + osc.frequency.value = theme.freq; + gain.gain.setValueAtTime(0.12, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + theme.duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + theme.duration); +} diff --git a/web/src/keyStats.js b/web/src/keyStats.js new file mode 100644 index 0000000..9db0106 --- /dev/null +++ b/web/src/keyStats.js @@ -0,0 +1,59 @@ +const STORAGE_KEY = 'typerpunk:keystats'; +// Below this many samples a character's average latency/error rate is too +// noisy to act on - a key typed twice that happened to be wrong once looks +// identical to a genuinely hard key without this floor. +const MIN_SAMPLES = 5; + +function load() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return raw && typeof raw === 'object' ? raw : {}; + } catch { + return {}; + } +} + +function save(data) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {} +} + +// Called once per typed character (correct or not) with how long it took +// since the previous keystroke. Skips whitespace - word gaps have highly +// variable timing for reasons unrelated to how hard a character is to type +// (thinking about the next word, punctuation pauses), and would otherwise +// dominate the latency signal for every character adjacent to a space. +export function recordKeystroke(char, isCorrect, latencyMs) { + if (!char || /\s/.test(char)) return; + const data = load(); + const entry = data[char] || { attempts: 0, errors: 0, totalLatency: 0 }; + entry.attempts += 1; + if (!isCorrect) entry.errors += 1; + if (latencyMs != null && latencyMs >= 0 && latencyMs < 5000) entry.totalLatency += latencyMs; + data[char] = entry; + save(data); +} + +// Ranks every character with enough samples by a weak-ness score (error +// rate weighted heaviest, since a mistake matters more than being merely +// slow) and returns the worst `limit`. Empty until enough real typing has +// happened - callers should fall back to plain random text until then. +export function getWeakChars(limit = 12) { + const data = load(); + const scored = Object.entries(data) + .filter(([, e]) => e.attempts >= MIN_SAMPLES) + .map(([char, e]) => ({ + char, + errorRate: e.errors / e.attempts, + avgLatency: e.totalLatency / e.attempts, + })); + if (scored.length === 0) return []; + + const maxLatency = Math.max(...scored.map(s => s.avgLatency), 1); + scored.forEach(s => { s.score = s.errorRate * 0.7 + (s.avgLatency / maxLatency) * 0.3; }); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map(s => s.char); +} + +export function hasEnoughData() { + return getWeakChars(1).length > 0; +} diff --git a/web/src/languages.js b/web/src/languages.js new file mode 100644 index 0000000..b5bbba9 --- /dev/null +++ b/web/src/languages.js @@ -0,0 +1,205 @@ +// Typing languages: which vocabulary the generated-word modes (Words, Timed, +// Zen, Practice) draw from. This is the text being typed, not the language of +// the interface - the same distinction MonkeyType and 10FastFingers make. +// +// Each list is that language's own high-frequency vocabulary, not a +// translation of the English one. Accented characters are kept as written; +// typing them is the point of picking the language. +// +// Adding a language means adding an entry here and nothing else. + +const EN = [ + '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', +]; + +const ES = [ + 'el', 'la', 'de', 'que', 'y', 'a', 'en', 'un', 'ser', 'se', + 'no', 'haber', 'por', 'con', 'su', 'para', 'como', 'estar', 'tener', 'le', + 'lo', 'todo', 'pero', 'más', 'hacer', 'o', 'poder', 'decir', 'este', 'ir', + 'otro', 'ese', 'si', 'me', 'ya', 'ver', 'porque', 'dar', 'cuando', 'muy', + 'sin', 'vez', 'mucho', 'saber', 'qué', 'sobre', 'mi', 'alguno', 'mismo', 'yo', + 'también', 'hasta', 'año', 'dos', 'querer', 'entre', 'así', 'primero', 'desde', 'grande', + 'eso', 'ni', 'nos', 'llegar', 'pasar', 'tiempo', 'ella', 'sí', 'día', 'uno', + 'bien', 'poco', 'deber', 'entonces', 'poner', 'cosa', 'tanto', 'hombre', 'parecer', 'nuestro', + 'tan', 'donde', 'ahora', 'parte', 'después', 'vida', 'quedar', 'siempre', 'creer', 'hablar', + 'llevar', 'dejar', 'nada', 'cada', 'seguir', 'menos', 'nuevo', 'encontrar', 'algo', 'sólo', +]; + +const FR = [ + 'le', 'de', 'un', 'être', 'et', 'à', 'il', 'avoir', 'ne', 'je', + 'son', 'que', 'se', 'qui', 'ce', 'dans', 'en', 'du', 'elle', 'au', + 'pour', 'pas', 'que', 'vous', 'par', 'sur', 'faire', 'plus', 'dire', 'me', + 'on', 'mon', 'lui', 'nous', 'comme', 'mais', 'pouvoir', 'avec', 'tout', 'y', + 'aller', 'voir', 'bien', 'où', 'sans', 'tu', 'ou', 'leur', 'homme', 'si', + 'deux', 'mari', 'moi', 'vouloir', 'te', 'même', 'venir', 'quand', 'grand', 'celui', + 'si', 'notre', 'devoir', 'là', 'jour', 'prendre', 'très', 'peu', 'encore', 'aussi', + 'quelque', 'dont', 'tout', 'mer', 'trouver', 'donner', 'temps', 'ça', 'peut', 'chose', + 'vie', 'année', 'monde', 'main', 'jamais', 'sous', 'passer', 'toujours', 'rien', 'falloir', + 'parler', 'sembler', 'depuis', 'moment', 'partir', 'penser', 'enfant', 'entre', 'aimer', 'mot', +]; + +const DE = [ + 'der', 'die', 'und', 'in', 'den', 'von', 'zu', 'das', 'mit', 'sich', + 'des', 'auf', 'für', 'ist', 'im', 'dem', 'nicht', 'ein', 'eine', 'als', + 'auch', 'es', 'an', 'werden', 'aus', 'er', 'hat', 'dass', 'sie', 'nach', + 'bei', 'um', 'noch', 'wie', 'über', 'nur', 'oder', 'aber', 'vor', 'bis', + 'mehr', 'durch', 'man', 'sein', 'wurde', 'sei', 'wir', 'was', 'wenn', 'haben', + 'kann', 'ihre', 'dann', 'unter', 'wieder', 'zwei', 'schon', 'jahr', 'diese', 'wird', + 'kein', 'immer', 'zeit', 'doch', 'ihr', 'weil', 'ganz', 'zwischen', 'leben', 'sehr', + 'selbst', 'gegen', 'stadt', 'drei', 'hier', 'gut', 'wo', 'ohne', 'seine', 'jetzt', + 'weiter', 'menschen', 'land', 'welt', 'tag', 'hand', 'frage', 'seit', 'gehen', 'kommen', + 'große', 'teil', 'arbeit', 'ende', 'haus', 'frau', 'mann', 'kind', 'wasser', 'wort', +]; + +const IT = [ + 'il', 'di', 'che', 'e', 'la', 'in', 'un', 'essere', 'a', 'per', + 'non', 'avere', 'con', 'si', 'da', 'come', 'io', 'questo', 'lo', 'ma', + 'del', 'più', 'fare', 'al', 'tutto', 'anche', 'su', 'lui', 'se', 'dire', + 'me', 'quando', 'lei', 'suo', 'poi', 'o', 'perché', 'cosa', 'ora', 'solo', + 'molto', 'bene', 'dove', 'ancora', 'sempre', 'niente', 'volta', 'grande', 'due', 'vita', + 'uomo', 'anno', 'giorno', 'casa', 'tempo', 'mondo', 'parte', 'mano', 'occhio', 'donna', + 'padre', 'madre', 'lavoro', 'città', 'paese', 'strada', 'notte', 'acqua', 'parola', 'amico', +]; + +const PT = [ + 'o', 'de', 'que', 'e', 'a', 'do', 'da', 'em', 'um', 'para', + 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', + 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'à', 'seu', 'sua', 'ou', + 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'até', 'isso', + 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'seus', 'quem', 'nas', 'me', + 'ano', 'dia', 'vez', 'casa', 'tempo', 'vida', 'homem', 'mundo', 'parte', 'lugar', + 'trabalho', 'cidade', 'noite', 'água', 'palavra', 'olho', 'mão', 'pessoa', 'coisa', 'forma', +]; + +const NL = [ + 'de', 'en', 'van', 'het', 'een', 'in', 'is', 'dat', 'op', 'te', + 'zijn', 'met', 'voor', 'niet', 'aan', 'er', 'maar', 'om', 'ook', 'als', + 'dan', 'je', 'die', 'was', 'uit', 'door', 'over', 'ze', 'nog', 'naar', + 'heeft', 'hij', 'kan', 'zich', 'bij', 'onder', 'wij', 'wat', 'meer', 'deze', + 'jaar', 'dag', 'tijd', 'mens', 'werk', 'land', 'huis', 'stad', 'water', 'kind', + 'vrouw', 'man', 'hand', 'weg', 'woord', 'leven', 'wereld', 'nacht', 'oog', 'deel', +]; + +const SV = [ + 'och', 'att', 'det', 'i', 'en', 'som', 'är', 'på', 'för', 'med', + 'av', 'den', 'till', 'har', 'de', 'inte', 'om', 'ett', 'han', 'men', + 'var', 'jag', 'sig', 'från', 'vi', 'så', 'kan', 'man', 'när', 'du', + 'ut', 'över', 'efter', 'bara', 'mycket', 'nu', 'här', 'där', 'eller', 'också', + 'år', 'dag', 'tid', 'liv', 'hand', 'land', 'hus', 'stad', 'vatten', 'barn', + 'kvinna', 'man', 'arbete', 'värld', 'natt', 'ord', 'väg', 'del', 'plats', 'öga', +]; + +const PL = [ + 'w', 'i', 'z', 'na', 'do', 'się', 'nie', 'to', 'że', 'a', + 'o', 'jak', 'po', 'ale', 'za', 'jest', 'co', 'tak', 'od', 'przez', + 'przy', 'ten', 'już', 'tylko', 'bardzo', 'gdy', 'może', 'być', 'mieć', 'ona', + 'oni', 'my', 'wy', 'ja', 'ty', 'jego', 'jej', 'nasz', 'kiedy', 'gdzie', + 'rok', 'dzień', 'czas', 'praca', 'dom', 'człowiek', 'życie', 'woda', 'miasto', 'kraj', + 'ręka', 'słowo', 'noc', 'droga', 'świat', 'część', 'miejsce', 'oko', 'dziecko', 'kobieta', +]; + +const TR = [ + 've', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ne', 'o', 'çok', + 'daha', 'var', 'gibi', 'ama', 'her', 'sonra', 'kadar', 'en', 'ben', 'sen', + 'biz', 'siz', 'onlar', 'olarak', 'değil', 'kendi', 'zaman', 'göre', 'şey', 'yok', + 'iyi', 'büyük', 'küçük', 'yeni', 'eski', 'uzun', 'kısa', 'önce', 'şimdi', 'burada', + 'yıl', 'gün', 'insan', 'ev', 'su', 'şehir', 'ülke', 'el', 'söz', 'gece', + 'yol', 'dünya', 'yer', 'göz', 'çocuk', 'kadın', 'adam', 'iş', 'hayat', 'baba', +]; + +const ID = [ + 'yang', 'dan', 'di', 'itu', 'dengan', 'untuk', 'tidak', 'ini', 'dari', 'dalam', + 'akan', 'pada', 'juga', 'ke', 'karena', 'ada', 'oleh', 'bisa', 'saya', 'kami', + 'mereka', 'dia', 'kita', 'sudah', 'atau', 'saat', 'lebih', 'tetapi', 'bagi', 'hanya', + 'banyak', 'besar', 'kecil', 'baru', 'lama', 'baik', 'orang', 'tahun', 'hari', 'waktu', + 'rumah', 'air', 'kota', 'negara', 'tangan', 'kata', 'malam', 'jalan', 'dunia', 'bagian', + 'tempat', 'mata', 'anak', 'perempuan', 'laki', 'kerja', 'hidup', 'nama', 'jika', 'sangat', +]; + +const RO = [ + 'de', 'și', 'la', 'în', 'un', 'o', 'cu', 'este', 'pe', 'nu', + 'care', 'ce', 'din', 'se', 'pentru', 'că', 'sunt', 'mai', 'dar', 'sau', + 'am', 'ai', 'are', 'eu', 'tu', 'el', 'ea', 'noi', 'voi', 'ei', + 'când', 'unde', 'cum', 'foarte', 'bine', 'acum', 'aici', 'după', 'fără', 'între', + 'an', 'zi', 'timp', 'casă', 'apă', 'oraș', 'țară', 'mână', 'cuvânt', 'noapte', + 'drum', 'lume', 'parte', 'loc', 'ochi', 'copil', 'femeie', 'om', 'muncă', 'viață', +]; + +const DA = [ + 'og', 'i', 'at', 'det', 'en', 'den', 'til', 'er', 'som', 'på', + 'de', 'med', 'han', 'af', 'for', 'ikke', 'der', 'var', 'mig', 'sig', + 'men', 'et', 'har', 'om', 'vi', 'min', 'havde', 'ham', 'hun', 'nu', + 'over', 'da', 'fra', 'du', 'ud', 'sin', 'dem', 'os', 'op', 'man', + 'år', 'dag', 'tid', 'liv', 'hånd', 'land', 'hus', 'by', 'vand', 'barn', + 'kvinde', 'mand', 'arbejde', 'verden', 'nat', 'ord', 'vej', 'del', 'sted', 'øje', +]; + +const CS = [ + 'a', 'v', 'se', 'na', 'je', 'že', 'o', 's', 'z', 'do', + 'to', 'ale', 'i', 'za', 'po', 'jako', 'jsem', 'jsou', 'byl', 'může', + 'který', 'jeho', 'její', 'nebo', 'když', 'kde', 'jak', 'velmi', 'jen', 'už', + 'tak', 'ještě', 'také', 'nic', 'vše', 'proto', 'první', 'nový', 'dobrý', 'velký', + 'rok', 'den', 'čas', 'práce', 'dům', 'člověk', 'život', 'voda', 'město', 'země', + 'ruka', 'slovo', 'noc', 'cesta', 'svět', 'část', 'místo', 'oko', 'dítě', 'žena', +]; + +const FI = [ + 'ja', 'on', 'ei', 'että', 'se', 'oli', 'hän', 'ovat', 'mutta', 'kuin', + 'niin', 'jos', 'kun', 'myös', 'vain', 'sitä', 'tai', 'nyt', 'vielä', 'jo', + 'tämä', 'ne', 'me', 'te', 'minä', 'sinä', 'hyvä', 'suuri', 'pieni', 'uusi', + 'vanha', 'pitkä', 'paljon', 'missä', 'mitä', 'miten', 'koska', 'ennen', 'jälkeen', 'kaikki', + 'vuosi', 'päivä', 'aika', 'talo', 'vesi', 'kaupunki', 'maa', 'käsi', 'sana', 'yö', + 'tie', 'maailma', 'osa', 'paikka', 'silmä', 'lapsi', 'nainen', 'mies', 'työ', 'elämä', +]; + +// Ordered by how widely they are typed, English first as the default. +const AF = [ + 'die', 'en', 'van', 'is', 'in', 'het', 'nie', 'te', 'dat', 'op', + 'vir', 'met', 'was', 'sy', 'hy', 'ek', 'ons', 'jy', 'julle', 'hulle', + 'om', 'as', 'maar', 'ook', 'aan', 'by', 'uit', 'oor', 'na', 'deur', + 'kan', 'moet', 'sal', 'wil', 'weet', 'maak', 'gaan', 'kom', 'sien', 'gee', + 'baie', 'nog', 'weer', 'altyd', 'nooit', 'net', 'so', 'dan', 'toe', 'waar', + 'wat', 'wie', 'hoe', 'wanneer', 'omdat', 'sonder', 'tussen', 'onder', 'voor', 'agter', + 'jaar', 'dag', 'tyd', 'mens', 'werk', 'land', 'huis', 'stad', 'water', 'kind', + 'vrou', 'man', 'hand', 'pad', 'woord', 'lewe', 'wêreld', 'nag', 'oog', 'deel', + 'goed', 'groot', 'klein', 'nuwe', 'ou', 'lank', 'kort', 'eerste', 'plek', 'saak', + 'vriend', 'familie', 'skool', 'boek', 'môre', 'gister', 'vandag', 'mooi', 'sleg', 'help', +]; + +export const LANGUAGES = [ + { id: 'en', label: 'English', words: EN }, + { id: 'es', label: 'Español', words: ES }, + { id: 'fr', label: 'Français', words: FR }, + { id: 'de', label: 'Deutsch', words: DE }, + { id: 'it', label: 'Italiano', words: IT }, + { id: 'pt', label: 'Português', words: PT }, + { id: 'nl', label: 'Nederlands', words: NL }, + { id: 'sv', label: 'Svenska', words: SV }, + { id: 'da', label: 'Dansk', words: DA }, + { id: 'fi', label: 'Suomi', words: FI }, + { id: 'pl', label: 'Polski', words: PL }, + { id: 'cs', label: 'Čeština', words: CS }, + { id: 'ro', label: 'Română', words: RO }, + { id: 'tr', label: 'Türkçe', words: TR }, + { id: 'id', label: 'Bahasa Indonesia', words: ID }, + { id: 'af', label: 'Afrikaans', words: AF }, +]; + +export function languageLabel(id) { + return LANGUAGES.find(l => l.id === id)?.label || 'English'; +} + +// Falls back to English for an unknown id - a language removed from this +// file must not leave a stored setting pointing at nothing. +export function languageWords(id) { + return (LANGUAGES.find(l => l.id === id) || LANGUAGES[0]).words; +} diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..3967db4 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,16 @@ +import { startApp } from './app.js'; + +async function loadLocalTexts() { + try { + const res = await fetch(new URL('./data/texts.json', import.meta.url)); + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch (err) { + console.error('Failed to load local texts:', err); + return []; + } +} + +loadLocalTexts().then(texts => { + startApp(document.getElementById('root'), texts); +}); diff --git a/web/src/multiplayer.js b/web/src/multiplayer.js new file mode 100644 index 0000000..28dba0a --- /dev/null +++ b/web/src/multiplayer.js @@ -0,0 +1,81 @@ +import { api } from './api.js'; +import { detectDeviceType } from './deviceDetect.js'; + +// Same base-URL resolution as api.js (window.TYPERPUNK_API_URL override for +// local dev, same-origin otherwise), just converted to a ws:// URL instead +// of being handed to fetch. +function wsBaseUrl() { + const httpBase = (typeof window !== 'undefined' && window.TYPERPUNK_API_URL) || window.location.origin; + return httpBase.replace(/^http/, 'ws'); +} + +export async function createRoom(deviceFilter) { + const { room_code } = await api.post('/api/multiplayer/rooms', deviceFilter ? { device_filter: deviceFilter } : {}); + return room_code; +} + +// Live count of people sitting in multiplayer rooms, for the header's +// ambient "is anyone around to race" indicator. Unauthenticated, like the +// endpoint behind it. +export async function getOnlineCount() { + return api.get('/api/multiplayer/online'); +} + +// Auto-matchmaking. The server hands back whichever room is still filling, +// or opens a new one when none is - so the player never sees a room code. +// createRoom stays for the deliberate "race my friends on this code" path. +export async function quickMatch(deviceFilter) { + const { room_code } = await api.post('/api/multiplayer/quickmatch', deviceFilter ? { device_filter: deviceFilter } : {}); + return room_code; +} + +// Pub-sub rather than fixed callbacks passed to connectToRoom - the lobby +// screen and the typing screen both need to react to the same connection's +// messages (player list / countdown during the lobby phase, progress / +// finished during the race), and they're two different pieces of code that +// each attach their own listeners as the room moves between phases, rather +// than one caller owning every handler up front. +export function connectToRoom(roomCode, playerName) { + const ws = new WebSocket(`${wsBaseUrl()}/ws/multiplayer/${encodeURIComponent(roomCode)}`); + const listeners = new Map(); + const connection = { playerId: null }; + + function emit(event, ...args) { + for (const fn of listeners.get(event) || []) fn(...args); + } + + ws.addEventListener('open', () => { + ws.send(JSON.stringify({ type: 'Join', name: playerName, device_type: detectDeviceType() })); + }); + + ws.addEventListener('message', event => { + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + switch (msg.type) { + case 'Joined': connection.playerId = msg.player_id; emit('joined', msg.player_id); break; + case 'PlayerList': emit('playerList', msg.players); break; + case 'Countdown': emit('countdown', msg.seconds); break; + case 'Start': emit('start', msg.text); break; + case 'PlayerProgress': emit('playerProgress', msg.player_id, msg.percent, msg.wpm); break; + case 'PlayerFinished': emit('playerFinished', msg.player_id, msg.wpm, msg.accuracy, msg.time, msg.place); break; + case 'RoomClosed': emit('roomClosed', msg.reason); break; + case 'Error': emit('error', msg.message); break; + } + }); + + ws.addEventListener('close', () => emit('close')); + ws.addEventListener('error', () => emit('error', 'connection error')); + + Object.assign(connection, { + on(event, fn) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event).add(fn); + return () => listeners.get(event)?.delete(fn); + }, + ready: () => ws.send(JSON.stringify({ type: 'Ready' })), + sendProgress: (percent, wpm) => ws.send(JSON.stringify({ type: 'Progress', percent, wpm })), + sendFinish: (wpm, accuracy, time) => ws.send(JSON.stringify({ type: 'Finish', wpm, accuracy, time })), + close: () => ws.close(), + }); + return connection; +} diff --git a/web/src/pb.js b/web/src/pb.js new file mode 100644 index 0000000..cd4413c --- /dev/null +++ b/web/src/pb.js @@ -0,0 +1,56 @@ +const STORAGE_KEY = 'typerpunk:pb'; + +function load() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return raw && typeof raw === 'object' ? raw : {}; + } catch { + return {}; + } +} + +export function getPersonalBest(modeKey) { + if (!modeKey) return null; + return load()[modeKey] || null; +} + +// All recorded bests, best-WPM first, for a full stats/profile view rather +// than a single mode's lookup. +export function getAllPersonalBests() { + const all = load(); + return Object.entries(all) + .map(([modeKey, record]) => ({ modeKey, ...record })) + .sort((a, b) => b.wpm - a.wpm); +} + +// Reconciles local bests with the server's on sign-in - takes max(local, +// remote) per mode rather than overwriting, so a best set on this device +// before ever signing in isn't lost just because the server hadn't seen it +// yet. Guests never call this; the account has to exist first. +export function mergeRemoteBests(remoteBests) { + if (!Array.isArray(remoteBests) || remoteBests.length === 0) return; + const all = load(); + for (const { mode_key: modeKey, wpm, date } of remoteBests) { + const local = all[modeKey]; + if (!local || wpm > local.wpm) { + all[modeKey] = { wpm, date: date ? Date.parse(date) || Date.now() : Date.now() }; + } + } + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch {} +} + +// Records a finished test's WPM against its mode's best, updating storage +// only when it's actually a new best. Returns the previous best (or null) +// and whether this run just beat it, so the end screen can show both in one +// read without a separate lookup. +export function recordResult(modeKey, wpm) { + if (!modeKey) return { isNewBest: false, previous: null }; + const all = load(); + const previous = all[modeKey] || null; + const isNewBest = !previous || wpm > previous.wpm; + if (isNewBest) { + all[modeKey] = { wpm, date: Date.now() }; + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch {} + } + return { isNewBest, previous: previous ? previous.wpm : null }; +} diff --git a/web/src/profileStats.js b/web/src/profileStats.js new file mode 100644 index 0000000..c88da15 --- /dev/null +++ b/web/src/profileStats.js @@ -0,0 +1,62 @@ +const STORAGE_KEY = 'typerpunk:profile'; +// Caps how many past results the progress chart can show - unbounded +// growth would slowly bloat localStorage for a long-lived install with no +// benefit, since the chart only ever renders the most recent entries anyway. +const MAX_HISTORY = 100; + +function defaults() { + return { + testsCompleted: 0, + totalTimeSeconds: 0, + totalWpm: 0, + totalAccuracy: 0, + bestWpm: 0, + lastTestDate: null, + streakDays: 0, + history: [], + }; +} + +function load() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return raw && typeof raw === 'object' ? { ...defaults(), ...raw } : defaults(); + } catch { + return defaults(); + } +} + +export function getProfileStats() { + const s = load(); + return { + ...s, + averageWpm: s.testsCompleted ? Math.round(s.totalWpm / s.testsCompleted) : 0, + averageAccuracy: s.testsCompleted ? Math.round(s.totalAccuracy / s.testsCompleted) : 0, + }; +} + +// Called once per finished test (any mode, including custom text) - these +// are lifetime activity totals, not per-mode bests, so unlike pb.js every +// completed test counts, not just new records. +export function recordTest({ wpm, accuracy, time }) { + const s = load(); + s.testsCompleted += 1; + s.totalTimeSeconds += time; + s.totalWpm += wpm; + s.totalAccuracy += accuracy; + s.bestWpm = Math.max(s.bestWpm, wpm); + + const today = new Date().toDateString(); + const lastDay = s.lastTestDate ? new Date(s.lastTestDate).toDateString() : null; + if (lastDay !== today) { + const yesterday = new Date(Date.now() - 86400000).toDateString(); + s.streakDays = lastDay === yesterday ? s.streakDays + 1 : 1; + } + s.lastTestDate = Date.now(); + + s.history.push({ wpm, accuracy, date: s.lastTestDate }); + if (s.history.length > MAX_HISTORY) s.history = s.history.slice(-MAX_HISTORY); + + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch {} + return s; +} diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js new file mode 100644 index 0000000..30161d9 --- /dev/null +++ b/web/src/screens/accountScreen.js @@ -0,0 +1,121 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { getUser, onAuthChange, register, login, logout, ApiError } from '../auth.js'; +import { api, apiUrl } from '../api.js'; + +// onShowAccount is accepted but intentionally not supplied by app.js for this +// screen: the top rail's identity control is already pointing here, so it has +// nothing to navigate to. Declared so the shared rail call below resolves. +export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let mode = 'login'; // 'login' | 'register' + let stopped = false; + // null = checking, true/false once the now-playing probe resolves -- + // there's no dedicated "am I connected" endpoint, but now-playing + // itself 404s specifically when there's no stored Spotify connection, + // so it doubles as the status check. + let spotifyConnected = null; + let spotifyCheckedForUserId = null; + + function checkSpotifyStatus(userId) { + spotifyCheckedForUserId = userId; + spotifyConnected = null; + api.get('/api/spotify/now-playing') + .then(() => { spotifyConnected = true; rerender(); }) + .catch(err => { spotifyConnected = !(err instanceof ApiError && err.status === 404); rerender(); }); + } + + function spotifyStatusMarkup() { + if (spotifyConnected === null) return `<div class="account-spotify-status">Checking...</div>`; + if (spotifyConnected) return `<div class="account-spotify-status">Connected</div>`; + return `<a class="menu-button small" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>`; + } + + function formMarkup(user) { + if (user) { + return ` + <div class="account-signed-in">Signed in as <strong>${escapeHtml(user.username)}</strong></div> + <button class="menu-button" data-action="logout">Log Out</button> + <div class="settings-hint">Spotify</div> + ${spotifyStatusMarkup()} + `; + } + return ` + <div class="account-tabs"> + <button class="menu-button small${mode === 'login' ? '' : ' ghost'}" data-action="tab-login">Log In</button> + <button class="menu-button small${mode === 'register' ? '' : ' ghost'}" data-action="tab-register">Register</button> + </div> + <form class="account-form"> + <input class="account-input" type="text" name="username" placeholder="Username" autocomplete="username" required> + <input class="account-input" type="password" name="password" placeholder="Password" autocomplete="${mode === 'login' ? 'current-password' : 'new-password'}" required> + <button class="menu-button" type="submit">${mode === 'login' ? 'Log In' : 'Create Account'}</button> + <div class="custom-error account-error"></div> + </form> + `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Account</h2> + <div class="account-panel">${formMarkup(user)}</div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const logoutBtn = root.querySelector('[data-action="logout"]'); + if (logoutBtn) logoutBtn.addEventListener('click', async () => { await logout(); }); + + const tabLogin = root.querySelector('[data-action="tab-login"]'); + const tabRegister = root.querySelector('[data-action="tab-register"]'); + if (tabLogin) tabLogin.addEventListener('click', () => { mode = 'login'; rerender(); }); + if (tabRegister) tabRegister.addEventListener('click', () => { mode = 'register'; rerender(); }); + + const form = root.querySelector('.account-form'); + if (form) { + form.addEventListener('submit', async e => { + e.preventDefault(); + const errorEl = form.querySelector('.account-error'); + errorEl.textContent = ''; + const username = form.username.value.trim(); + const password = form.password.value; + try { + if (mode === 'login') await login(username, password); + else await register(username, password); + } catch (err) { + errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.'; + } + }); + } + + if (user && spotifyCheckedForUserId !== user.id) checkSpotifyStatus(user.id); + if (!user) spotifyCheckedForUserId = null; + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + return () => { + cleanupTheme(); + cleanupRail(); + }; + } + + let cleanup = render(); + function rerender() { + if (stopped) return; + cleanup(); + cleanup = render(); + } + const unsubscribeAuth = onAuthChange(rerender); + + return () => { + stopped = true; + unsubscribeAuth(); + cleanup(); + }; +} diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js new file mode 100644 index 0000000..cd2a41d --- /dev/null +++ b/web/src/screens/endScreen.js @@ -0,0 +1,226 @@ +import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js'; +import { escapeHtml } from '../util.js'; +import { attachTooltips } from '../tooltip.js'; +import { recordResult } from '../pb.js'; +import { recordTest } from '../profileStats.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { renderTopRail } from '../topRail.js'; +import { submitTestResult } from '../auth.js'; +import { detectDeviceType } from '../deviceDetect.js'; + +export function renderEndScreen(root, { stats, text, attribution, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + // Monkeytype's four-way split. "Extra" is anything typed past the end of + // the passage, "missed" is passage left untyped - neither is visible in + // a plain correct/incorrect pair, and the two mean very different things + // about a run that ended early. + const typedLen = (userInput || '').length; + const extraChars = Math.max(0, typedLen - (text || '').length); + const missedChars = Math.max(0, (text || '').length - typedLen); + const graphPoints = buildGraphPoints(charTimings, stats, keypressHistory); + const consistency = calculateConsistency(graphPoints); + const { isNewBest, previous } = recordResult(modeKey, stats.wpm); + recordTest({ wpm: stats.wpm, accuracy: stats.accuracy, time: stats.time }); + // Same exclusion as recordResult above: custom text/Zen have no stable, + // repeatable challenge, so there's no meaningful leaderboard entry for + // them either. + if (modeKey) { + // Inter-keystroke gaps (not raw timestamps) - the server's + // anti-cheat check only cares about the *variance* between + // consecutive keystrokes, not when the test started. + const source = keypressHistory && keypressHistory.length > 1 ? keypressHistory : null; + const keystrokeIntervalsMs = source + ? source.slice(1).map((k, i) => (k.time - source[i].time) * 1000).filter(ms => ms >= 0) + : undefined; + submitTestResult({ + mode_key: modeKey, + wpm: stats.wpm, + raw_wpm: stats.rawWpm, + accuracy: stats.accuracy, + time_seconds: stats.time, + device_type: detectDeviceType(), + keystroke_intervals_ms: keystrokeIntervalsMs, + }); + } + // Don't announce "new best" on a mode's very first-ever run - there's + // nothing to have beaten yet, so it just silently becomes the baseline. + const pbLine = !modeKey || previous == null ? '' : isNewBest + ? '<div class="pb-badge pb-new">NEW BEST</div>' + : `<div class="pb-badge">PB ${Math.round(previous)}</div>`; + + root.innerHTML = ` + <div class="end-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <div class="end-screen-text"><div class="text-display"></div></div> + ${attribution ? `<div class="attribution end-screen-attribution">— ${escapeHtml(attribution)}</div>` : ''} + <div class="end-screen-graph-row"> + <div class="endscreen-side-stat wpm"> + <div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div> + ${pbLine} + </div> + <div class="graph-container"><canvas></canvas></div> + <div class="endscreen-side-stat acc"> + <div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div> + </div> + </div> + <div class="end-screen-stat-row"> + <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div> + <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div> + <div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div> + <div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped"> + <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div> + </div> + <div class="end-stat"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div> + </div> + <div class="end-screen-buttons"> + <button class="end-screen-button" data-action="again">Play Again</button> + </div> + </div> + `; + + const textDisplay = root.querySelector('.text-display'); + const inputChars = userInput ? userInput.split('') : []; + const wordMatches = text.match(/[^\s]+\s*/g) || []; + let charIndex = 0; + const html = []; + for (const word of wordMatches) { + html.push('<span>'); + for (const char of word) { + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + className = inputChar === char ? 'correct' : 'incorrect'; + if (className === 'incorrect') displayChar = inputChar; + } + html.push(`<span class="${className}">${escapeHtml(displayChar)}</span>`); + charIndex++; + } + html.push('</span>'); + } + textDisplay.innerHTML = html.join(''); + + attachTooltips(root.querySelector('.end-screen-stat-row')); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu)); + root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain); + + const canvas = root.querySelector('canvas'); + const errorPoints = buildErrorPoints(graphPoints, keypressHistory, charTimings); + const xMax = Math.max(1, Math.ceil(stats.time)); + const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax }); + redraw(); + window.addEventListener('resize', redraw); + + // A long passage wraps to more lines than a short one, growing + // .end-screen-text's height by an amount no fixed CSS margin/graph-height + // budget can account for - a static tuning only ever fits whichever + // passage happened to be on screen when it was measured. This claws the + // room back in stages instead, each one only kicking in if the last + // wasn't enough, so the page itself never needs to scroll to reach + // Play Again: shrink the graph down to a floor, then trim the button's + // top margin, and only as a last resort (a passage long enough that even + // both of those can't absorb it - realistically only reachable by + // pasting the full text-mode word buffer instead of actually typing it + // in the time given) let the passage preview itself scroll internally, + // which keeps every control below it reachable without the page + // scrolling. Runs once after layout settles. + requestAnimationFrame(() => { + const doc = document.documentElement; + // Re-measured after every stage rather than tracked as a running + // subtraction - margin-collapse and sub-pixel rounding meant an + // assumed "reduced height X means Y px less overflow" estimate + // drifted from the DOM's actual scrollHeight, consistently + // undershooting by a fixed amount however much was cut. + const overflowNow = () => doc.scrollHeight - doc.clientHeight; + if (overflowNow() <= 0) return; + + const graphBox = root.querySelector('.graph-container'); + const MIN_GRAPH_HEIGHT = 120; + const graphHeight = graphBox.getBoundingClientRect().height; + if (graphHeight > MIN_GRAPH_HEIGHT) { + const reduceBy = Math.min(overflowNow(), graphHeight - MIN_GRAPH_HEIGHT); + graphBox.style.height = `${graphHeight - reduceBy}px`; + redraw(); + } + if (overflowNow() <= 0) return; + + const buttons = root.querySelector('.end-screen-buttons'); + const buttonsMargin = parseFloat(getComputedStyle(buttons).marginTop); + if (buttonsMargin > 8) { + const reduceBy = Math.min(overflowNow(), buttonsMargin - 8); + buttons.style.marginTop = `${buttonsMargin - reduceBy}px`; + } + if (overflowNow() <= 0) return; + + const textBox = root.querySelector('.end-screen-text'); + const textHeight = textBox.getBoundingClientRect().height; + textBox.style.maxHeight = `${Math.max(80, textHeight - overflowNow() - 4)}px`; + textBox.style.overflowY = 'auto'; + }); + + const tooltip = document.createElement('div'); + tooltip.className = 'chart-tooltip'; + root.querySelector('.graph-container').appendChild(tooltip); + + const handleMove = e => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const errorHit = hitTestError(canvas, errorPoints, graphPoints, xMax, mx, my); + if (errorHit) { + canvas.style.cursor = 'pointer'; + const word = errorHit.index != null ? wordAt(text, errorHit.index) : ''; + const n = errorHit.count || 1; + tooltip.textContent = n > 1 + ? `${n} errors${word ? ` · first at "${word}"` : ''}` + : (word ? `Error: "${word}"` : 'Error'); + tooltip.style.left = `${mx}px`; + tooltip.style.top = `${my}px`; + tooltip.style.display = 'block'; + return; + } + const linePoint = hitTestLine(canvas, graphPoints, xMax, mx); + if (linePoint) { + canvas.style.cursor = 'crosshair'; + tooltip.textContent = `${linePoint.time}s · wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)}`; + tooltip.style.left = `${mx}px`; + tooltip.style.top = `${my}px`; + tooltip.style.display = 'block'; + } else { + canvas.style.cursor = 'default'; + tooltip.style.display = 'none'; + } + }; + const handleLeave = () => { tooltip.style.display = 'none'; }; + canvas.addEventListener('mousemove', handleMove); + canvas.addEventListener('mouseleave', handleLeave); + + // Quick restart: Tab on the results screen is the keyboard equivalent + // of clicking Play Again. + const handleKeydown = e => { + if (e.key === 'Tab') { + e.preventDefault(); + onPlayAgain(); + } + }; + document.addEventListener('keydown', handleKeydown); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + window.removeEventListener('resize', redraw); + canvas.removeEventListener('mousemove', handleMove); + canvas.removeEventListener('mouseleave', handleLeave); + document.removeEventListener('keydown', handleKeydown); + cleanupRail(); + }; +} + +function wordAt(text, index) { + let start = index; + let end = index; + while (start > 0 && text[start - 1] !== ' ') start--; + while (end < text.length && text[end] !== ' ') end++; + return text.slice(start, end).trim(); +} diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js new file mode 100644 index 0000000..89317ad --- /dev/null +++ b/web/src/screens/friendsScreen.js @@ -0,0 +1,149 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser, onAuthChange } from '../auth.js'; + +// onShowFriends is accepted but not supplied by app.js for this screen - the +// top rail's Friends control already points here, so it has nowhere to go. +// Declared so the shared rail call below resolves. +export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore }) { + let data = { friends: [], incoming_requests: [], outgoing_requests: [] }; + let loadError = null; + + async function load() { + if (!getUser()) return; + try { + data = await api.get('/api/friends'); + loadError = null; + } catch (err) { + loadError = err instanceof ApiError ? err.message : 'network error'; + } + } + + function signedOutMarkup() { + return `<div class="stats-empty">Sign in to add friends and compare stats.</div> + <button class="menu-button" data-action="go-account">Sign In</button>`; + } + + function signedInMarkup() { + return ` + <form class="account-form friends-add-form"> + <input class="account-input" type="text" name="username" placeholder="Add by username" required> + <button class="menu-button small" type="submit">Send Request</button> + <div class="custom-error friends-add-error"></div> + </form> + + ${data.incoming_requests.length > 0 ? ` + <h3>Requests</h3> + <div class="leaderboard-list"> + ${data.incoming_requests.map(r => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(r.username)}</div> + <button class="menu-button small" data-action="accept" data-id="${r.friendship_id}">Accept</button> + <button class="menu-button small ghost" data-action="decline" data-id="${r.friendship_id}">Decline</button> + </div> + `).join('')} + </div>` : ''} + + ${data.outgoing_requests.length > 0 ? ` + <h3>Sent</h3> + <div class="leaderboard-list"> + ${data.outgoing_requests.map(r => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(r.username)}</div> + <div class="leaderboard-acc">Pending</div> + <button class="menu-button small ghost" data-action="cancel" data-id="${r.friendship_id}">Cancel</button> + </div> + `).join('')} + </div>` : ''} + + <h3>Friends</h3> + ${data.friends.length > 0 ? ` + <div class="leaderboard-list"> + ${data.friends.map(f => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(f.username)}</div> + <button class="menu-button small ghost" data-action="unfriend" data-id="${f.friendship_id}">Remove</button> + </div> + `).join('')} + </div>` : `<div class="stats-empty">No friends yet - add one by username above.</div>`} + + ${loadError ? `<div class="custom-error">${escapeHtml(loadError)}</div>` : ''} + `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Friends</h2> + ${user ? signedInMarkup() : signedOutMarkup()} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', onShowAccount); + + const addForm = root.querySelector('.friends-add-form'); + if (addForm) { + addForm.addEventListener('submit', async e => { + e.preventDefault(); + const errorEl = addForm.querySelector('.friends-add-error'); + errorEl.textContent = ''; + const username = addForm.username.value.trim(); + try { + await api.post('/api/friends/request', { username }); + addForm.username.value = ''; + await load(); + rerender(); + } catch (err) { + errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.'; + } + }); + } + + root.querySelectorAll('[data-action="accept"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post(`/api/friends/${btn.dataset.id}/accept`).catch(() => {}); + await load(); + rerender(); + }); + }); + root.querySelectorAll('[data-action="decline"], [data-action="cancel"], [data-action="unfriend"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.delete(`/api/friends/${btn.dataset.id}`).catch(() => {}); + await load(); + rerender(); + }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore }); + return () => { + cleanupTheme(); + cleanupRail(); + }; + } + + let cleanup; + function rerender() { + if (cleanup) cleanup(); + cleanup = render(); + } + + rerender(); + load().then(rerender); + const unsubscribeAuth = onAuthChange(() => { load().then(rerender); }); + + return () => { + unsubscribeAuth(); + if (cleanup) cleanup(); + }; +} diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js new file mode 100644 index 0000000..c088c0d --- /dev/null +++ b/web/src/screens/icons.js @@ -0,0 +1,109 @@ +import { isDarkTheme } from '../theme.js'; + +// Angular, straight-line glyphs to match the site's sharp-cornered, +// terminal-styled UI - rounded/organic icons (circles, bezier crescents) +// read as off-brand next to bordered rectangular buttons and monospace text. +const SUN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="8.6" y="8.6" width="6.8" height="6.8" transform="rotate(45 12 12)"/> + <path d="M12 1v3M12 20v3M1 12h3M20 12h3M4.75 4.75l2.1 2.1M17.15 17.15l2.1 2.1M19.25 4.75l-2.1 2.1M7.15 17.15l-2.1 2.1"/> +</svg>`; + +// A true cut-out crescent (fill-rule evenodd punches a hexagonal hole out of +// a larger hexagon) rather than a color-matched overlay, so it renders +// correctly against every theme's background without special-casing. +const MOON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12 2L20 6.5V17.5L12 22L4 17.5V6.5L12 2ZM14.3 5.6L19 8.3V15.7L14.3 18.4L16.2 12L14.3 5.6Z"/> +</svg>`; + +// Shows the icon for the theme currently active (moon while in any +// dark-family theme, sun while in Light) rather than just checking for the +// literal string "dark" - that missed every gallery theme (Dracula, Nord, +// etc.), which are all dark-background but not literally named "dark". +export function themeIcon(theme) { + return isDarkTheme(theme) ? MOON : SUN; +} + +export const STATS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <rect x="3" y="13" width="4" height="8"/> + <rect x="10" y="8" width="4" height="13"/> + <rect x="17" y="3" width="4" height="18"/> +</svg>`; + +// Three sliders (an equalizer/preferences glyph) - the hex-nut-with-teeth +// gear this replaced still read too much like the hexagon family shared by +// Moon/Account/Friends at a glance despite the teeth. This is unambiguous as +// "settings" on its own and shares no shape language with anything else in +// the icon set. +export const SETTINGS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> + <line x1="3" y1="6" x2="21" y2="6"/> + <rect x="13" y="4" width="4" height="4" fill="currentColor" stroke="none"/> + <line x1="3" y1="12" x2="21" y2="12"/> + <rect x="6" y="10" width="4" height="4" fill="currentColor" stroke="none"/> + <line x1="3" y1="18" x2="21" y2="18"/> + <rect x="16" y="16" width="4" height="4" fill="currentColor" stroke="none"/> +</svg>`; + +// Two overlapping triangle "figures" - same flat-geometric language as the +// other corner icons, reads as "two players" without a literal person glyph. +export const MULTIPLAYER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M7 3L12.5 13H1.5L7 3Z"/> + <path d="M17 3L22.5 13H11.5L17 3Z" opacity="0.55"/> +</svg>`; + +export const LEADERBOARD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M6 2H18V9C18 13 15.5 15.5 12 15.5C8.5 15.5 6 13 6 9V2Z"/> + <rect x="10" y="15.5" width="4" height="3"/> + <rect x="6.5" y="18.5" width="11" height="2.5"/> +</svg>`; + +// Two overlapping copies of ACCOUNT_ICON's head-and-shoulders figure - two +// small bare hexagons (an earlier draft) blurred into two indistinct dots at +// this icon's actual render size; a head+shoulders silhouette keeps reading +// as "people" even that small. +export const FRIENDS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M8 2L11 3.9V7.5L8 9.4L5 7.5V3.9L8 2Z"/> + <path d="M2 21L3.6 14.5H12.4L14 21H2Z"/> + <path d="M16.5 5L19.3 6.7V10L16.5 11.7L13.7 10V6.7L16.5 5Z" opacity="0.55"/> + <path d="M11 22L12.4 16.2H20.6L22 22H11Z" opacity="0.55"/> +</svg>`; + +export const ACCOUNT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 2L16.5 4.6V9.8L12 12.4L7.5 9.8V4.6L12 2Z"/> + <path d="M4 22L6.5 15H17.5L20 22H4Z"/> +</svg>`; + +// A price tag - angular throughout (straight edges, a punched square hole +// instead of a round one), matching the rest of the set. +export const STORE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M11 2H21V12L12 21L3 12L11 2ZM14.5 6H17.5V9H14.5V6Z"/> +</svg>`; + +// A stroked chevron - an SVG path centers and scales identically everywhere, +// unlike a unicode "▾" glyph, whose vertical metrics vary by font and don't +// sit centered in a fixed-size box the way this does. +export const CHEVRON_DOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M5 9L12 16L19 9"/> +</svg>`; + +// Cosmetic flair badges (see cosmetics.rs) - angular throughout like every +// other icon here, a 4-point sparkle instead of a rounded star so it +// doesn't stand out as the one curved glyph in the set. +export const FLAIR_ICONS = { + star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L14.2 9.8L22 12L14.2 14.2L12 22L9.8 14.2L2 12L9.8 9.8Z"/></svg>`, + bolt: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4 14H11L10 22L20 9H13L14 2Z"/></svg>`, + skull: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L20 6.5V14.5C20 17 18.5 19 16.5 20L15 22H9L7.5 20C5.5 19 4 17 4 14.5V6.5L12 2Z"/><rect x="8" y="12" width="3" height="3"/><rect x="13" y="12" width="3" height="3"/></svg>`, +}; + +// Typing-language picker. The convention for this control is a glyph pair -- +// one letterform from a non-Latin script beside a Latin "A" - which is what +// 10FastFingers, MonkeyType and browser translate buttons all use. Drawn as +// straight-edged strokes rather than set in a font, so it matches the rest of +// this angular set and does not depend on a CJK face being installed. +export const LANGUAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"> + <path d="M3 5H11"/> + <path d="M7 3V5"/> + <path d="M9.5 8.5C8.5 12 6 14 3.5 15.5"/> + <path d="M5 11C6.5 13.5 8.5 15 10.5 16"/> + <path d="M13 21L17 11L21 21"/> + <path d="M14.4 18H19.6"/> +</svg>`; diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js new file mode 100644 index 0000000..2777b1c --- /dev/null +++ b/web/src/screens/leaderboardScreen.js @@ -0,0 +1,120 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { FLAIR_ICONS } from './icons.js'; + +const WORD_COUNTS = [10, 25, 50, 100]; +const TIME_DURATIONS = [15, 30, 60, 120]; + +// Scoped to Words/Time only, not the open-ended quote categories - those +// grow with the text pack and don't make a stable, comparable leaderboard +// mode the way a fixed word count or time duration does. +const MODES = [ + ...TIME_DURATIONS.map(d => ({ key: `time-${d}`, label: `Time ${d}s` })), + ...WORD_COUNTS.map(w => ({ key: `words-${w}`, label: `Words ${w}` })), +]; + +function formatDate(iso) { + const t = Date.parse(iso); + return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString(); +} + +export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }) { + let selectedMode = MODES[1].key; // time-30, matches the app's own default mode + let desktopOnly = false; + + function rowsMarkup(state) { + if (state.status === 'loading') return `<div class="stats-empty">Loading...</div>`; + if (state.status === 'error') return `<div class="stats-empty">Could not load the leaderboard - ${escapeHtml(state.message)}</div>`; + if (state.entries.length === 0) return `<div class="stats-empty">No results yet for this mode - be the first.</div>`; + return ` + <div class="leaderboard-list"> + ${state.entries.map((e, i) => ` + <div class="leaderboard-row"> + <div class="leaderboard-rank">#${i + 1}</div> + <button class="leaderboard-name" data-username="${escapeHtml(e.username)}">${escapeHtml(e.username)}${e.flair && FLAIR_ICONS[e.flair] ? `<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[e.flair]}</span>` : ''}</button> + ${e.device_type === 'mobile' ? `<span class="leaderboard-device-badge" data-tooltip="Typed on a touchscreen device">mobile</span>` : ''} + <div class="leaderboard-wpm">${Math.round(e.wpm)} wpm</div> + <div class="leaderboard-acc">${Math.round(e.accuracy)}%</div> + <div class="leaderboard-date">${formatDate(e.date)}</div> + </div> + `).join('')} + </div> + `; + } + + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Leaderboard</h2> + <div class="leaderboard-modes"> + ${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')} + </div> + <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button> + <div class="leaderboard-results"></div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const resultsEl = root.querySelector('.leaderboard-results'); + let requestToken = 0; + async function load() { + const token = ++requestToken; + resultsEl.innerHTML = rowsMarkup({ status: 'loading' }); + try { + const params = new URLSearchParams({ mode: selectedMode }); + if (desktopOnly) params.set('device', 'desktop'); + const entries = await api.get(`/api/leaderboard?${params.toString()}`); + if (token !== requestToken) return; // a newer mode pick already superseded this request + resultsEl.innerHTML = rowsMarkup({ status: 'ok', entries }); + // Scoped to resultsEl, not the whole root - attachTooltips + // already ran once for the rest of the screen at render time, + // and re-running it there would double-bind those elements' + // listeners on every reload. + attachTooltips(resultsEl); + } catch (err) { + if (token !== requestToken) return; + const message = err instanceof ApiError ? err.message : 'network error'; + resultsEl.innerHTML = rowsMarkup({ status: 'error', message }); + } + } + + root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => { + btn.addEventListener('click', () => { + selectedMode = btn.dataset.mode; + root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('ghost', b !== btn)); + load(); + }); + }); + + const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]'); + deviceFilterBtn.addEventListener('click', () => { + desktopOnly = !desktopOnly; + deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`; + deviceFilterBtn.classList.toggle('ghost', !desktopOnly); + load(); + }); + + // Delegated rather than attached per-row - resultsEl's rows get + // replaced wholesale on every load(), so a listener attached to a + // specific row would be gone the next time the mode changes. + resultsEl.addEventListener('click', e => { + const btn = e.target.closest('[data-username]'); + if (btn) onShowPublicProfile(btn.dataset.username); + }); + + load(); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js new file mode 100644 index 0000000..ac8b4f4 --- /dev/null +++ b/web/src/screens/lyricsScreen.js @@ -0,0 +1,127 @@ +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, apiUrl, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { parseCustomContent } from '../customText.js'; +import { escapeHtml } from '../util.js'; + +const POLL_INTERVAL_MS = 4000; + +// Connect Spotify -> poll what's currently playing -> fetch synced lyrics +// for it -> hand the result to onLyricsReady, which app.js implements by +// setting it as the active custom text and starting a game exactly like a +// manually-uploaded .lrc file would - this screen only produces that same +// {name, chunks, language, timed} shape, it doesn't play anything itself. +export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let status = 'checking'; + let message = 'Checking sign-in...'; + let lastTrackKey = null; + let pollTimer = null; + let stopped = false; + + function bodyText() { + if (status === 'signed-out') return 'Sign in first - the Spotify connection is tied to your account.'; + if (status === 'not-connected') return 'Connect your Spotify account to type along with whatever you\'re playing.'; + if (status === 'not-configured') return message; + return message; + } + + function render() { + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Lyrics</h2> + <div class="stats-placeholder">${escapeHtml(bodyText())}</div> + ${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''} + ${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); })); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', () => { stopped = true; onShowAccount(); }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + return () => { cleanupTheme(); cleanupRail(); }; + } + + let cleanup = render(); + function rerender() { + if (stopped) return; + cleanup(); + cleanup = render(); + } + + async function poll() { + if (stopped) return; + if (!getUser()) { + status = 'signed-out'; + rerender(); + return; + } + try { + const playing = await api.get('/api/spotify/now-playing'); + if (!playing.is_playing || !playing.track) { + status = 'waiting'; + message = 'Play something on Spotify to get started.'; + rerender(); + } else { + const trackKey = `${playing.artist}::${playing.track}`; + status = 'waiting'; + message = `Now playing: ${playing.track} - ${playing.artist}. Looking for lyrics...`; + rerender(); + if (trackKey !== lastTrackKey) { + lastTrackKey = trackKey; + await fetchLyrics(playing); + } + } + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + status = 'not-connected'; + } else if (err instanceof ApiError && err.status === 401) { + status = 'signed-out'; + } else if (err instanceof ApiError && err.status === 501) { + status = 'not-configured'; + message = err.message; + } else { + status = 'waiting'; + message = 'Could not reach Spotify - retrying...'; + } + rerender(); + } + if (!stopped && status !== 'not-configured') { + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } + } + + async function fetchLyrics(playing) { + try { + const durationSeconds = playing.duration_ms ? Math.round(playing.duration_ms / 1000) : undefined; + const query = new URLSearchParams({ artist: playing.artist, track: playing.track }); + if (durationSeconds) query.set('duration', String(durationSeconds)); + const result = await api.get(`/api/lyrics?${query.toString()}`); + const raw = result.lrc || result.plain; + const filename = result.lrc ? 'lyrics.lrc' : 'lyrics.txt'; + const { chunks, language, timed } = parseCustomContent(raw, filename); + if (chunks.length === 0) return; + stopped = true; + if (pollTimer) clearTimeout(pollTimer); + onLyricsReady({ name: `${playing.track} - ${playing.artist}`, chunks, language, timed }); + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + message = `No lyrics found for "${playing.track}" - try a different song.`; + rerender(); + } + } + } + + poll(); + + return () => { + stopped = true; + if (pollTimer) clearTimeout(pollTimer); + cleanup(); + }; +} diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js new file mode 100644 index 0000000..909740d --- /dev/null +++ b/web/src/screens/mainMenu.js @@ -0,0 +1,531 @@ +import { STATS_ICON, SETTINGS_ICON, CHEVRON_DOWN_ICON, LEADERBOARD_ICON, STORE_ICON } from './icons.js'; +import { parseCustomContent } from '../customText.js'; +import { escapeHtml } from '../util.js'; +import { getSettings, updateSettings } from '../settings.js'; +import { getProfileStats } from '../profileStats.js'; +import { attachTooltips } from '../tooltip.js'; +import { wordListTiers } from '../wordGenerator.js'; +import { renderTopRail } from '../topRail.js'; +import { onCountsChange, getCounts } from '../counts.js'; + +function label(m) { + if (m === 'random') return 'Random'; + if (m === 'words') return 'Words'; + // "Time" read as a duplicate of the Rules panel's "Time: 30s" duration + // control - this is the mode (type until the clock runs out), that's a + // setting (how long the clock runs), so they needed visibly different + // names. + if (m === 'time') return 'Timed'; + if (m === 'custom') return 'Custom'; + return m.charAt(0).toUpperCase() + m.slice(1); +} + +const MODE_DESCRIPTIONS = { + random: 'A random passage from any category below.', + words: 'Type a fixed number of random words (set in Rules).', + time: 'Type as many words as you can before the clock runs out (duration set in Rules).', + general: 'Random passages from the general text collection.', + literature: 'Passages from literature.', + programming: 'Passages of real code.', + quotes: 'Short standalone quotes.', + custom: 'Paste or upload your own text to type.', + zen: 'No timer, no word limit - type for as long as you want, then finish whenever you\'re ready.', + practice: 'Words chosen to target the letters you personally mistype or slow down on most, based on your recent tests.', +}; + +function modeDescription(m) { + return MODE_DESCRIPTIONS[m] || ''; +} + +// Rough binary-file detector: a file that decoded to mostly control chars or +// Unicode replacement characters (U+FFFD, inserted for invalid UTF-8 byte +// sequences) is almost certainly not text, even though `.text()` never +// throws on it. +function looksLikeText(str) { + if (!str) return false; + const sample = str.slice(0, 2000); + let suspicious = 0; + for (const ch of sample) { + const code = ch.codePointAt(0); + if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) suspicious++; + } + return suspicious / sample.length < 0.05; +} + +const WORD_COUNTS = [10, 25, 50, 100]; +const TIME_DURATIONS = [15, 30, 60, 120]; +const SOUND_THEMES = ['off', 'click', 'mech']; +function soundThemeLabel(theme) { + if (theme === 'click') return 'Click'; + if (theme === 'mech') return 'Mechanical'; + return 'Off'; +} + +function wordListTierLabel(tier) { + if (tier === 'extended') return 'Extended'; + if (tier === 'hard') return 'Hard'; + return 'Common'; +} + +export function renderMainMenu(root, props) { + const { onStartGame, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; + const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom']; + // The picker used to be one flat list of 18 entries mixing two unrelated + // things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom) + // and which body of text it draws from (Literature, Science, ...). Split + // and shown side by side, it fits without scrolling and can be scanned. + const GAME_MODES = ['random', 'words', 'time', 'zen', 'practice']; + const gameModes = [...GAME_MODES.filter(m => modes.includes(m)), 'custom']; + const textCategories = modes.filter(m => !gameModes.includes(m)); + const modeItem = m => `<button class="mode-popover-item${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`; + const currentIndex = Math.max(0, modes.indexOf(selectedCategory || 'random')); + const currentMode = modes[currentIndex] || 'random'; + const isCustom = currentMode === 'custom'; + const isWords = currentMode === 'words'; + const isTime = currentMode === 'time'; + + // No "Start:" prefix - it's the one big button in the middle of the + // screen, clicking it obviously starts things; the label's job is just + // to say which mode/text it'll start. + let startLabel = label(currentMode); + if (isCustom) { + startLabel = customText ? customText.name : 'Load Custom Text'; + } + + const settings = getSettings(); + const isFavorite = settings.favoriteMode === currentMode; + const wordCount = WORD_COUNTS.includes(settings.wordCount) ? settings.wordCount : 25; + const timeDuration = TIME_DURATIONS.includes(settings.timeDuration) ? settings.timeDuration : 30; + const profile = getProfileStats(); + + // Shows what Start will actually generate without opening Settings -- + // only Words/Timed have rules to summarize; the quote-based modes and + // Custom don't use word count/punctuation/numbers at all. + let rulesSummary = ''; + if (isWords || isTime) { + const parts = [isWords ? `${wordCount} words` : `${timeDuration}s`]; + if (settings.wordsPunctuation) parts.push('punctuation'); + if (settings.wordsNumbers) parts.push('numbers'); + rulesSummary = parts.join(' · '); + } + + root.innerHTML = ` + <div class="main-menu"> + <h1>TyperPunk</h1> + <div class="menu-options"> + <button class="mode-pill sp-chevron" data-tooltip="Choose what to type"> + <span class="mode-pill-label">${escapeHtml(startLabel)}${rulesSummary ? ` · ${escapeHtml(rulesSummary)}` : ''}</span> + ${CHEVRON_DOWN_ICON} + </button> + <div class="start-group sp-group"> + <button class="menu-button start-main" data-action="start">Single Player</button> + <div class="mode-popover sp-popover" hidden> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Modes</div> + ${gameModes.map(modeItem).join('')} + <button class="mode-popover-item" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button> + </div> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Text</div> + ${textCategories.map(modeItem).join('')} + </div> + </div> + </div> + <div class="start-group mp-group"> + <button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multiplayer</button> + </div> + <div class="start-caption mp-caption" hidden></div> + ${isCustom && customText && customText.timed ? `<button class="menu-button" data-action="start-passive">Passive Mode</button>` : ''} + ${isCustom && customText ? `<button class="menu-button" data-action="clear-custom">Clear Custom Text</button>` : ''} + </div> + + <div class="settings-panel" hidden> + <label class="settings-row"> + <input type="checkbox" class="settings-skip-menu"${settings.skipMenu ? ' checked' : ''} /> + Start directly into a game, skip this menu + </label> + <button class="menu-button small" data-action="toggle-favorite"> + ${isFavorite ? `★ Favorite (${escapeHtml(label(currentMode))})` : `☆ Set "${escapeHtml(label(currentMode))}" as favorite`} + </button> + <div class="settings-columns"> + ${(isWords || isTime) ? ` + <div class="settings-column"> + <div class="settings-hint">Rules</div> + <div class="settings-row-group"> + ${isWords ? `<button class="menu-button small" data-action="cycle-word-count" data-tooltip="How many words the Words mode generates. Click to cycle.">Words: ${wordCount}</button>` : ''} + ${isTime ? `<button class="menu-button small" data-action="cycle-time-duration" data-tooltip="How long the clock runs in Timed mode. Click to cycle.">Time: ${timeDuration}s</button>` : ''} + <button class="menu-button small" data-action="toggle-punctuation" data-tooltip="Include punctuation (commas, periods) in generated text.">Punctuation: ${settings.wordsPunctuation ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="toggle-numbers" data-tooltip="Include numbers in generated text.">Numbers: ${settings.wordsNumbers ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="cycle-word-list-tier" data-tooltip="Common (short, everyday words), Extended (adds longer/less frequent words), or Hard (technical and irregularly-spelled words). Click to cycle.">Word List: ${wordListTierLabel(settings.wordListTier)}</button> + </div> + </div>` : ''} + <div class="settings-column"> + <div class="settings-hint">Typing</div> + <div class="settings-row-group"> + <button class="menu-button small" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button> + <button class="menu-button small" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button> + </div> + </div> + </div> + <div class="settings-hint">Dev</div> + <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test → End Screen</button> + <button class="menu-button small ghost" data-action="close-settings">Close</button> + </div> + + <div class="custom-panel" hidden> + <div class="custom-hint">Paste text, notes, code, subtitles (.srt/.vtt), or lyrics (.lrc) - typed in order, not randomized. Drag a file in, or:</div> + <textarea class="custom-textarea" placeholder="Paste text, notes, code, or lyrics here... (or drop a file anywhere in this box)"></textarea> + <div class="custom-stats" hidden></div> + <div class="custom-panel-row"> + <label class="menu-button small file-label"> + Choose file + <input type="file" class="custom-file-input" accept=".txt,.md,.srt,.vtt,.lrc,.rs,.js,.ts,.jsx,.tsx,.py,.c,.h,.cpp,.hpp,.go,.java,.cs,.sh" hidden /> + </label> + <span class="custom-file-name"></span> + </div> + <div class="custom-panel-row"> + <button class="menu-button small" data-action="use-pasted">Use this text</button> + <button class="menu-button small ghost" data-action="close-custom">Cancel</button> + </div> + <div class="custom-error"></div> + </div> + + <div class="logo">TyperPunk</div> + + <div class="menu-key-hint">Enter to start · Esc for the menu</div> + + <div class="corner-rail-left"> + <button class="corner-icon-button" data-action="settings" aria-label="Settings" data-tooltip="Settings">${SETTINGS_ICON}</button> + <button class="corner-icon-button" data-action="store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button> + </div> + + <div class="corner-rail-right"> + ${profile.testsCompleted > 0 ? `<div class="stats-corner-figures" data-tooltip="Your average WPM across every test, and your best single result."> + <div class="stats-corner-avg">avg ${profile.averageWpm}</div> + <div class="stats-corner-pb">pb ${Math.round(profile.bestWpm)}</div> + </div>` : ''} + <button class="corner-icon-button" data-action="stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button> + <button class="corner-icon-button" data-action="leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button> + </div> + + </div> + `; + + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + // Live count under the Multiplayer button. The Friends badge is the top + // rail's, painted there. + const mpCaptionEl = root.querySelector('.mp-caption'); + function paintCounts({ online }) { + if (mpCaptionEl) { + mpCaptionEl.hidden = !online; + mpCaptionEl.textContent = online === 1 ? '1 racing now' : `${online} racing now`; + } + } + paintCounts(getCounts()); + const unsubscribeCounts = onCountsChange(paintCounts); + + // Clicking a chevron reveals every mode as a direct pick, rather than + // cycling blind through them one click at a time - Custom and the text + // categories were otherwise invisible unless you clicked through the + // whole list first. Shared between the Singleplayer and Multiplayer + // split-buttons instead of duplicated, since both need the exact same + // "position, open downward, cap to available height" behavior. + const outsideClickHandlers = []; + function setupDropdown(groupEl, chevronEl, popoverEl, hideElWhileOpen) { + function position() { + const rect = groupEl.getBoundingClientRect(); + const gap = 8; + const spaceBelow = window.innerHeight - rect.bottom - gap; + popoverEl.style.left = `${rect.left}px`; + popoverEl.style.width = `${rect.width}px`; + popoverEl.style.top = `${rect.bottom + gap}px`; + popoverEl.style.bottom = 'auto'; + popoverEl.style.maxHeight = `${Math.max(100, spaceBelow)}px`; + } + chevronEl.addEventListener('click', e => { + e.stopPropagation(); + const opening = popoverEl.hidden; + popoverEl.hidden = !popoverEl.hidden; + if (opening) position(); + // The popover opens flush against the button's own bottom edge, + // the same spot this occupies in normal flow - left showing, + // the two sat directly on top of each other. + if (hideElWhileOpen) hideElWhileOpen.style.visibility = opening ? 'hidden' : ''; + }); + const handleOutsideClick = e => { + if (!popoverEl.hidden && !groupEl.contains(e.target)) { + popoverEl.hidden = true; + if (hideElWhileOpen) hideElWhileOpen.style.visibility = ''; + } + }; + document.addEventListener('click', handleOutsideClick); + outsideClickHandlers.push(handleOutsideClick); + } + + setupDropdown( + root.querySelector('.sp-group'), + root.querySelector('.sp-chevron'), + root.querySelector('.sp-popover'), + null, + ); + root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => { + item.addEventListener('click', () => onSelectCategory(item.dataset.mode)); + }); + root.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics); + + root.querySelector('[data-action="mode-multiplayer"]').addEventListener('click', onShowMultiplayer); + + const startBtn = root.querySelector('.sp-group .start-main'); + const panel = root.querySelector('.custom-panel'); + startBtn.addEventListener('click', () => { + if (isCustom && !customText) { + panel.hidden = false; + return; + } + if (isCustom) { + onStartCustom(); + } else { + onStartGame(); + } + }); + + // Matches the "Enter to start" hint in the bottom-right corner. Skipped + // while typing into a text field or with any popover/panel open, so it + // doesn't hijack Enter from the custom-text textarea, a form, or a mode + // picker where Enter has no obvious meaning. + const handleEnterToStart = e => { + if (e.key !== 'Enter') return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (root.querySelector('.mode-popover:not([hidden])')) return; + // The theme gallery is owned by renderThemeButton now, not this + // screen's own state - re-queried fresh rather than kept as a + // local reference, since it doesn't exist until that call runs. + const themePopover = root.querySelector('.theme-gallery-popover'); + if (!settingsPanel.hidden || (themePopover && !themePopover.hidden) || !panel.hidden) return; + startBtn.click(); + }; + document.addEventListener('keydown', handleEnterToStart); + + root.querySelector('[data-action="stats"]').addEventListener('click', onShowStats); + + root.querySelector('[data-action="leaderboard"]').addEventListener('click', onShowLeaderboard); + root.querySelector('[data-action="store"]').addEventListener('click', onShowStore); + // Friends and Account are the top rail's controls now, wired there. + + const settingsPanel = root.querySelector('.settings-panel'); + root.querySelector('[data-action="settings"]').addEventListener('click', () => { + settingsPanel.hidden = false; + }); + root.querySelector('[data-action="close-settings"]').addEventListener('click', () => { + settingsPanel.hidden = true; + }); + root.querySelector('.settings-skip-menu').addEventListener('change', e => { + updateSettings({ skipMenu: e.target.checked }); + }); + + const favoriteBtn = root.querySelector('[data-action="toggle-favorite"]'); + favoriteBtn.addEventListener('click', () => { + const nowFavorite = getSettings().favoriteMode !== currentMode; + updateSettings({ favoriteMode: nowFavorite ? currentMode : null }); + favoriteBtn.textContent = nowFavorite + ? `★ Favorite (${label(currentMode)})` + : `☆ Set "${label(currentMode)}" as favorite`; + }); + + // These update settings in place (text content patched directly) rather + // than going through onSelectCategory's full menu re-render - none of + // them change the selected mode, and a full re-render was resetting + // .settings-panel back to hidden on every single click, forcing you to + // reopen Settings after each change. + // The mode pill above the buttons carries the mode name and its rules. + const startSubEl = root.querySelector('.mode-pill-label'); + function refreshRulesSummary() { + if (!startSubEl) return; + const s = getSettings(); + const parts = [isWords ? `${liveWordCount} words` : `${liveTimeDuration}s`]; + if (s.wordsPunctuation) parts.push('punctuation'); + if (s.wordsNumbers) parts.push('numbers'); + startSubEl.textContent = `${startLabel} · ${parts.join(' · ')}`; + } + + let liveWordCount = wordCount; + const wordCountBtn = root.querySelector('[data-action="cycle-word-count"]'); + if (wordCountBtn) { + wordCountBtn.addEventListener('click', () => { + liveWordCount = WORD_COUNTS[(WORD_COUNTS.indexOf(liveWordCount) + 1) % WORD_COUNTS.length]; + updateSettings({ wordCount: liveWordCount }); + wordCountBtn.textContent = `Words: ${liveWordCount}`; + refreshRulesSummary(); + }); + } + + let liveTimeDuration = timeDuration; + const timeDurationBtn = root.querySelector('[data-action="cycle-time-duration"]'); + if (timeDurationBtn) { + timeDurationBtn.addEventListener('click', () => { + liveTimeDuration = TIME_DURATIONS[(TIME_DURATIONS.indexOf(liveTimeDuration) + 1) % TIME_DURATIONS.length]; + updateSettings({ timeDuration: liveTimeDuration }); + timeDurationBtn.textContent = `Time: ${liveTimeDuration}s`; + refreshRulesSummary(); + }); + } + + const punctuationBtn = root.querySelector('[data-action="toggle-punctuation"]'); + if (punctuationBtn) { + punctuationBtn.addEventListener('click', () => { + const next = !getSettings().wordsPunctuation; + updateSettings({ wordsPunctuation: next }); + punctuationBtn.textContent = `Punctuation: ${next ? 'On' : 'Off'}`; + refreshRulesSummary(); + }); + } + const numbersBtn = root.querySelector('[data-action="toggle-numbers"]'); + if (numbersBtn) { + numbersBtn.addEventListener('click', () => { + const next = !getSettings().wordsNumbers; + updateSettings({ wordsNumbers: next }); + numbersBtn.textContent = `Numbers: ${next ? 'On' : 'Off'}`; + refreshRulesSummary(); + }); + } + const wordListTierBtn = root.querySelector('[data-action="cycle-word-list-tier"]'); + if (wordListTierBtn) { + const tiers = wordListTiers(); + wordListTierBtn.addEventListener('click', () => { + const current = getSettings().wordListTier; + const next = tiers[(tiers.indexOf(current) + 1) % tiers.length]; + updateSettings({ wordListTier: next }); + wordListTierBtn.textContent = `Word List: ${wordListTierLabel(next)}`; + }); + } + + const liveStatsBtn = root.querySelector('[data-action="toggle-live-stats"]'); + liveStatsBtn.addEventListener('click', () => { + const next = !getSettings().hideLiveStats; + updateSettings({ hideLiveStats: next }); + liveStatsBtn.textContent = `Live Stats: ${next ? 'Off' : 'On'}`; + }); + const caretBlinkBtn = root.querySelector('[data-action="toggle-caret-blink"]'); + caretBlinkBtn.addEventListener('click', () => { + const next = !getSettings().caretBlink; + updateSettings({ caretBlink: next }); + caretBlinkBtn.textContent = `Blink Caret: ${next ? 'On' : 'Off'}`; + }); + const blindModeBtn = root.querySelector('[data-action="toggle-blind-mode"]'); + blindModeBtn.addEventListener('click', () => { + const next = !getSettings().blindMode; + updateSettings({ blindMode: next }); + blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`; + }); + const soundThemeBtn = root.querySelector('[data-action="cycle-sound-theme"]'); + soundThemeBtn.addEventListener('click', () => { + const current = getSettings().soundTheme; + const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length]; + updateSettings({ soundTheme: next }); + soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; + }); + + root.querySelector('[data-action="simulate-test"]').addEventListener('click', onSimulateTest); + + const passiveBtn = root.querySelector('[data-action="start-passive"]'); + if (passiveBtn) passiveBtn.addEventListener('click', onStartPassive); + + const clearBtn = root.querySelector('[data-action="clear-custom"]'); + if (clearBtn) clearBtn.addEventListener('click', onClearCustom); + + const closeBtn = root.querySelector('[data-action="close-custom"]'); + closeBtn.addEventListener('click', () => { panel.hidden = true; }); + + const fileInput = root.querySelector('.custom-file-input'); + const fileNameLabel = root.querySelector('.custom-file-name'); + const textarea = root.querySelector('.custom-textarea'); + const customStats = root.querySelector('.custom-stats'); + const errorBox = root.querySelector('.custom-error'); + let pickedFile = null; + + // Word/character count of whatever's currently loaded (pasted or a + // picked file) - shown before you commit to it, since a file's content + // isn't otherwise visible until you've already started typing it. + async function refreshCustomStats() { + let raw = pickedFile ? await pickedFile.text() : textarea.value; + if (!raw || !raw.trim()) { + customStats.hidden = true; + return; + } + const words = raw.trim().split(/\s+/).filter(Boolean).length; + const chars = raw.length; + customStats.hidden = false; + customStats.textContent = `${words} words · ${chars} characters`; + } + + function setPickedFile(file) { + pickedFile = file || null; + fileNameLabel.textContent = pickedFile ? pickedFile.name : ''; + if (pickedFile) textarea.value = ''; + refreshCustomStats(); + } + + fileInput.addEventListener('change', () => setPickedFile(fileInput.files[0])); + textarea.addEventListener('input', () => { + if (pickedFile) setPickedFile(null); + else refreshCustomStats(); + }); + + // Drag-and-drop a file directly onto the textarea, instead of only + // through the file picker button. + textarea.addEventListener('dragover', e => { + e.preventDefault(); + textarea.classList.add('drag-over'); + }); + textarea.addEventListener('dragleave', () => textarea.classList.remove('drag-over')); + textarea.addEventListener('drop', e => { + e.preventDefault(); + textarea.classList.remove('drag-over'); + const file = e.dataTransfer.files && e.dataTransfer.files[0]; + if (file) setPickedFile(file); + }); + + root.querySelector('[data-action="use-pasted"]').addEventListener('click', async () => { + errorBox.textContent = ''; + try { + let raw; + let name; + if (pickedFile) { + raw = await pickedFile.text(); + name = pickedFile.name; + } else { + raw = textarea.value; + name = 'Pasted text'; + } + if (!raw || !raw.trim()) { + errorBox.textContent = 'Paste some text or choose a file first.'; + return; + } + if (pickedFile && !looksLikeText(raw)) { + errorBox.textContent = "That file doesn't look like text - try a .txt, .md, or code file."; + return; + } + const { chunks, language, timed } = parseCustomContent(raw, name); + if (chunks.length === 0) { + errorBox.textContent = 'Could not find any typeable text in that content.'; + return; + } + onLoadCustom({ name, chunks, language, timed }); + } catch (err) { + console.error('Failed to load custom text:', err); + errorBox.textContent = 'Failed to read that file.'; + } + }); + + return () => { + cleanupTheme(); + unsubscribeCounts(); + document.removeEventListener('keydown', handleEnterToStart); + outsideClickHandlers.forEach(h => document.removeEventListener('click', h)); + }; +} diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js new file mode 100644 index 0000000..0de0182 --- /dev/null +++ b/web/src/screens/multiplayerScreen.js @@ -0,0 +1,265 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { createRoom, quickMatch, connectToRoom } from '../multiplayer.js'; +import { getUser } from '../auth.js'; +import { createGame, freeGame } from '../game.js'; +import { renderTypingGame } from './typingGame.js'; + +// You are always the theme's own accent; opponents take the --player-N hues +// in the order the server lists them, so the mapping is stable for everyone +// in the room and the same person keeps their colour from lobby to race. +const OPPONENT_COLORS = ['var(--player-2)', 'var(--player-3)', 'var(--player-4)', 'var(--player-5)']; + +function racerColors(players, myId) { + const colors = {}; + let next = 0; + for (const p of players) { + if (p.id === myId) { + colors[p.id] = 'var(--primary-color)'; + } else { + colors[p.id] = OPPONENT_COLORS[next % OPPONENT_COLORS.length]; + next += 1; + } + } + return colors; +} + +// No account required - a quick race with friends shouldn't need signing +// in, so this only prefills the name field when one exists rather than +// gating the whole feature behind it (unlike Friends/Leaderboard, which +// inherently need an identity to mean anything). +export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }) { + let connection = null; + let cleanupInner = null; + let players = []; + let myId = null; + let game = null; + + function teardownInner() { + if (cleanupInner) { cleanupInner(); cleanupInner = null; } + } + + function leaveRoom() { + connection?.close(); + connection = null; + } + + function renderLanding(landingError) { + teardownInner(); + let deviceFilter = 'everyone'; + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Multiplayer</h2> + <div class="account-panel"> + <input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}"> + <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button> + <button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button> + <div class="settings-hint">or race specific people</div> + <div class="mp-code-row"> + <input class="account-input" type="text" id="mp-room-code" placeholder="Room code"> + <button class="menu-button small ghost" data-action="join">Join</button> + </div> + <button class="menu-button small ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> + <div class="custom-error mp-error">${escapeHtml(landingError || '')}</div> + </div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }); + + const nameInput = root.querySelector('#mp-name'); + const errorEl = root.querySelector('.mp-error'); + + const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]'); + deviceFilterBtn.addEventListener('click', () => { + deviceFilter = deviceFilter === 'everyone' ? 'desktop_only' : 'everyone'; + deviceFilterBtn.textContent = `Match: ${deviceFilter === 'everyone' ? 'Everyone' : 'Desktop only'}`; + }); + + const quickBtn = root.querySelector('[data-action="quick"]'); + quickBtn.addEventListener('click', async () => { + const name = nameInput.value.trim() || 'Player'; + quickBtn.disabled = true; + quickBtn.textContent = 'Finding a race...'; + try { + const roomCode = await quickMatch(deviceFilter); + joinRoom(roomCode, name, true); + } catch { + quickBtn.disabled = false; + quickBtn.textContent = 'Find a Race'; + errorEl.textContent = 'Could not reach matchmaking - try again.'; + } + }); + + root.querySelector('[data-action="create"]').addEventListener('click', async () => { + const name = nameInput.value.trim() || 'Player'; + try { + const roomCode = await createRoom(deviceFilter); + joinRoom(roomCode, name); + } catch { + errorEl.textContent = 'Could not create a room - try again.'; + } + }); + root.querySelector('[data-action="join"]').addEventListener('click', () => { + const name = nameInput.value.trim() || 'Player'; + const code = root.querySelector('#mp-room-code').value.trim().toUpperCase(); + if (!code) { errorEl.textContent = 'Enter a room code.'; return; } + joinRoom(code, name); + }); + + cleanupInner = () => { cleanupTheme(); cleanupRail(); }; + } + + // `auto` marks a quick-match join: the player asked to be put in a race, + // not to sit in a lobby, so they are readied up as soon as the server + // acknowledges the join and the race starts on its own once someone else + // is in. A join by room code stays manual - there the lobby is the point, + // since you are waiting for specific people to arrive. + function joinRoom(roomCode, name, auto = false) { + teardownInner(); + players = []; + myId = null; + connection = connectToRoom(roomCode, name); + renderLobby(roomCode, auto); + } + + function renderLobby(roomCode, auto = false) { + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Room ${escapeHtml(roomCode)}</h2> + <div class="settings-hint">Share this code with whoever you're racing.</div> + <div class="leaderboard-list mp-player-list"></div> + <button class="menu-button" data-action="ready">Ready</button> + <div class="mp-countdown"></div> + <button class="menu-button ghost" data-action="leave">Leave Room</button> + </div> + `; + root.querySelector('[data-action="menu"]').addEventListener('click', () => { leaveRoom(); onBack(); }); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }); + + const readyBtn = root.querySelector('[data-action="ready"]'); + const listEl = root.querySelector('.mp-player-list'); + function paintPlayers() { + const colors = racerColors(players, myId); + listEl.innerHTML = players.map(p => ` + <div class="leaderboard-row" style="--racer-color: ${colors[p.id]}"> + <div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div> + <div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div> + </div> + `).join(''); + } + paintPlayers(); + + const offJoined = connection.on('joined', id => { + myId = id; + paintPlayers(); + // Ready is sent once, here, rather than on every playerList -- + // the server starts a countdown as soon as everyone present is + // ready, and re-sending would restart it. + if (auto) { + connection.ready(); + readyBtn.textContent = 'Waiting for players...'; + readyBtn.disabled = true; + } + }); + const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); }); + const offCountdown = connection.on('countdown', s => { + root.querySelector('.mp-countdown').textContent = s; + }); + const offStart = connection.on('start', text => startRace(text)); + // The server closes the connection right after an Error (e.g. a + // desktop-only room rejecting a mobile joiner) - 'close' always + // follows, so it's what actually routes back to the landing screen; + // this just remembers the message so that screen can show it + // instead of silently dropping the player back with no explanation. + let lastError = null; + const offError = connection.on('error', msg => { lastError = msg; }); + const offClose = connection.on('close', () => renderLanding(lastError)); + + readyBtn.addEventListener('click', () => { + connection.ready(); + readyBtn.textContent = 'Waiting for players...'; + readyBtn.disabled = true; + }); + root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); }); + + cleanupInner = () => { + offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose(); + cleanupTheme(); cleanupRail(); + }; + } + + async function startRace(text) { + teardownInner(); + try { + game = await createGame(); + game.set_text(text); + } catch (err) { + console.error('Failed to start multiplayer race:', err); + renderLanding(); + return; + } + + const opponents = document.createElement('div'); + opponents.className = 'mp-opponents'; + const progressById = {}; + function paintOpponents() { + const colors = racerColors(players, myId); + opponents.innerHTML = players.filter(p => p.id !== myId).map(p => ` + <div class="mp-opponent-row" style="--racer-color: ${colors[p.id]}"> + <span class="mp-opponent-name">${escapeHtml(p.name)}</span> + <div class="mp-opponent-bar"><div class="mp-opponent-bar-fill" style="width:${(progressById[p.id]?.percent || 0)}%"></div></div> + <span class="mp-opponent-wpm">${Math.round(progressById[p.id]?.wpm || 0)}</span> + </div> + `).join(''); + } + paintOpponents(); + + const offProgress = connection.on('playerProgress', (id, percent, wpm) => { + progressById[id] = { percent, wpm }; + paintOpponents(); + }); + // Reserved for a future shared-standings view - for now the local + // player just falls through to the normal end screen on their own + // finish, same as any other mode. + const offFinished = connection.on('playerFinished', () => {}); + + const cleanupTyping = renderTypingGame(root, { + game, text, modeKey: undefined, + multiplayer: { connection }, + onFinish: result => { + offProgress(); offFinished(); + freeGame(game); + game = null; + leaveRoom(); + onFinish(result); + }, + onMainMenu: () => { leaveRoom(); onBack(); }, + onRestart: null, + onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, + }); + root.appendChild(opponents); + + cleanupInner = () => { + offProgress(); offFinished(); + cleanupTyping(); + opponents.remove(); + }; + } + + renderLanding(); + return () => { + teardownInner(); + leaveRoom(); + freeGame(game); + }; +} diff --git a/web/src/screens/passiveMode.js b/web/src/screens/passiveMode.js new file mode 100644 index 0000000..e690d3c --- /dev/null +++ b/web/src/screens/passiveMode.js @@ -0,0 +1,159 @@ +import { getTheme, toggleTheme, onThemeChange } from '../theme.js'; +import { themeIcon } from './icons.js'; +import { escapeHtml } from '../util.js'; + +const PACE_WPM = 130; +const LINE_GAP_SECONDS = 0.6; + +function buildSchedule(chunks) { + const allTimed = chunks.every(c => c.time !== null && c.time !== undefined); + if (allTimed) { + const base = chunks[0].time; + return chunks.map(c => c.time - base); + } + let t = 0; + const offsets = []; + for (const c of chunks) { + offsets.push(t); + const duration = (c.content.length / 5) / (PACE_WPM / 60); + t += duration + LINE_GAP_SECONDS; + } + return offsets; +} + +function renderLine(content, typed) { + const inputChars = typed.split(''); + let html = ''; + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const inputChar = inputChars[i]; + let cls = 'neutral'; + let displayChar = char; + if (i < inputChars.length) { + cls = inputChar === char ? 'correct' : 'incorrect'; + if (cls === 'incorrect') displayChar = inputChar; + } + html += `<span class="${cls}">${escapeHtml(displayChar)}</span>`; + } + return html; +} + +export function renderPassiveMode(root, { name, chunks, onExit }) { + const schedule = buildSchedule(chunks); + const totalDuration = schedule[schedule.length - 1] + 4; // let the last line linger + + root.innerHTML = ` + <div class="passive-mode"> + <div class="logo" data-action="menu">TyperPunk</div> + <div class="passive-source">${escapeHtml(name)} · passive mode</div> + <div class="passive-lines"> + <div class="passive-line prev"></div> + <div class="passive-line active"></div> + <div class="passive-line next"></div> + </div> + <input class="typing-input passive-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" /> + <div class="passive-stats"> + <div class="stat-label">ACCURACY</div> + <div class="stat-value" data-field="acc">100%</div> + </div> + <div class="passive-summary" hidden> + <div class="passive-summary-title">Session complete</div> + <div class="passive-summary-stats"> + <div><span class="stat-label">LINES</span><span class="stat-value" data-field="lines">0</span></div> + <div><span class="stat-label">ACCURACY</span><span class="stat-value" data-field="final-acc">100%</span></div> + </div> + <button class="menu-button" data-action="back">Back to Menu</button> + </div> + <button class="theme-toggle" data-action="theme" aria-label="Toggle theme"></button> + </div> + `; + + const prevLine = root.querySelector('.passive-line.prev'); + const activeLine = root.querySelector('.passive-line.active'); + const nextLine = root.querySelector('.passive-line.next'); + const input = root.querySelector('.passive-input'); + const accField = root.querySelector('[data-field="acc"]'); + const summary = root.querySelector('.passive-summary'); + const themeButton = root.querySelector('.theme-toggle'); + + const paintTheme = () => { themeButton.innerHTML = themeIcon(getTheme()); }; + paintTheme(); + const unsubscribeTheme = onThemeChange(paintTheme); + themeButton.addEventListener('click', toggleTheme); + root.querySelector('[data-action="menu"]').addEventListener('click', onExit); + + let activeIndex = -1; + let typedForActive = ''; + let totalCorrect = 0; + let totalTyped = 0; + let done = false; + const startedAt = Date.now(); + let intervalId = null; + + function paintLines() { + prevLine.textContent = activeIndex > 0 ? chunks[activeIndex - 1].content : ''; + activeLine.innerHTML = activeIndex >= 0 ? renderLine(chunks[activeIndex].content, typedForActive) : ''; + nextLine.textContent = activeIndex + 1 < chunks.length ? chunks[activeIndex + 1].content : ''; + } + + function paintAccuracy() { + const acc = totalTyped === 0 ? 100 : Math.round((totalCorrect / totalTyped) * 100); + accField.textContent = `${acc}%`; + return acc; + } + + function finishSession() { + if (done) return; + done = true; + if (intervalId) clearInterval(intervalId); + input.disabled = true; + const acc = paintAccuracy(); + summary.querySelector('[data-field="lines"]').textContent = String(chunks.length); + summary.querySelector('[data-field="final-acc"]').textContent = `${acc}%`; + summary.hidden = false; + } + + function advanceTo(index) { + // Score whatever was typed for the outgoing line before moving on. + if (activeIndex >= 0) { + const target = chunks[activeIndex].content; + for (let i = 0; i < typedForActive.length; i++) { + totalTyped++; + if (typedForActive[i] === target[i]) totalCorrect++; + } + } + activeIndex = index; + typedForActive = ''; + input.value = ''; + paintLines(); + paintAccuracy(); + } + + input.addEventListener('input', () => { + if (done || activeIndex < 0) return; + typedForActive = input.value; + activeLine.innerHTML = renderLine(chunks[activeIndex].content, typedForActive); + }); + input.addEventListener('blur', () => { + if (!done) setTimeout(() => input.focus(), 10); + }); + + summary.querySelector('[data-action="back"]').addEventListener('click', onExit); + + setTimeout(() => input.focus(), 50); + advanceTo(0); + + intervalId = setInterval(() => { + if (done) return; + const elapsed = (Date.now() - startedAt) / 1000; + let target = activeIndex; + while (target + 1 < schedule.length && elapsed >= schedule[target + 1]) target++; + if (target !== activeIndex) advanceTo(target); + if (elapsed >= totalDuration) finishSession(); + }, 150); + + return () => { + unsubscribeTheme(); + if (intervalId) clearInterval(intervalId); + }; +} diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js new file mode 100644 index 0000000..5d652ce --- /dev/null +++ b/web/src/screens/placeholderScreen.js @@ -0,0 +1,31 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; + +// Shared shell for any feature that has a nav entry point but no real screen +// yet (Multiplayer, Friends - Account and Leaderboard have since become +// real screens and moved out of here) - one implementation to swap out per +// feature as each gets built, instead of a copy of the same "coming soon" +// markup per feature. +export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>${escapeHtml(title)}</h2> + <div class="stats-placeholder">${escapeHtml(description)}</div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js new file mode 100644 index 0000000..5ce50ed --- /dev/null +++ b/web/src/screens/publicProfileScreen.js @@ -0,0 +1,92 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { FLAIR_ICONS } from './icons.js'; + +// modeKey format is set in app.js: "words-25", "time-30", "quote-<category>". +function modeLabel(modeKey) { + const [kind, arg] = modeKey.split('-'); + if (kind === 'words') return `Words · ${arg}`; + if (kind === 'time') return `Time · ${arg}s`; + if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1); + return modeKey; +} + +function formatDate(iso) { + const t = Date.parse(iso); + return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString(); +} + +// Read-only - no settings, no actions on someone else's data, just what +// /api/users/:username/public hands back (the same aggregate shape +// /api/stats/me uses for yourself, minus anything private). +export function renderPublicProfileScreen(root, { username, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + function bodyMarkup(state) { + if (state.status === 'loading') return `<div class="stats-empty">Loading...</div>`; + if (state.status === 'error') return `<div class="stats-empty">${escapeHtml(state.message)}</div>`; + const p = state.profile; + return ` + <div class="stats-summary"> + <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${p.tests_completed}</div></div> + <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${Math.round(p.average_wpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(p.best_wpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${Math.round(p.average_accuracy)}%</div></div> + </div> + <div class="stats-placeholder">Joined ${formatDate(p.joined_at)}</div> + <h3>Personal bests</h3> + ${p.personal_bests.length > 0 ? ` + <div class="stats-pb-list"> + ${p.personal_bests.map(b => ` + <div class="stats-pb-row"> + <div class="stats-pb-mode">${escapeHtml(modeLabel(b.mode_key))}</div> + <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div> + <div class="stats-pb-date">${formatDate(b.date)}</div> + </div> + `).join('')} + </div> + ` : `<div class="stats-empty">No personal bests recorded yet.</div>`} + `; + } + + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2 class="public-profile-heading">${escapeHtml(username)}</h2> + <div class="public-profile-body"></div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const bodyEl = root.querySelector('.public-profile-body'); + const headingEl = root.querySelector('.public-profile-heading'); + bodyEl.innerHTML = bodyMarkup({ status: 'loading' }); + api.get(`/api/users/${encodeURIComponent(username)}/public`) + .then(profile => { + bodyEl.innerHTML = bodyMarkup({ status: 'ok', profile }); + if (profile.flair && FLAIR_ICONS[profile.flair]) { + headingEl.innerHTML = `${escapeHtml(username)}<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[profile.flair]}</span>`; + // Scoped to headingEl, not the whole root - see the same + // note in leaderboardScreen.js's load(). + attachTooltips(headingEl); + } + }) + .catch(err => { + const message = err instanceof ApiError && err.status === 404 + ? `No profile found for "${username}".` + : 'Could not load this profile - try again.'; + bodyEl.innerHTML = bodyMarkup({ status: 'error', message }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js new file mode 100644 index 0000000..a38e193 --- /dev/null +++ b/web/src/screens/statsScreen.js @@ -0,0 +1,85 @@ +import { getProfileStats } from '../profileStats.js'; +import { getAllPersonalBests } from '../pb.js'; +import { escapeHtml } from '../util.js'; +import { drawProgressChart } from '../chart.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; + +// modeKey format is set in app.js: "words-25", "time-30", "quote-<category>". +function modeLabel(modeKey) { + const [kind, arg] = modeKey.split('-'); + if (kind === 'words') return `Words · ${arg}`; + if (kind === 'time') return `Time · ${arg}s`; + if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1); + return modeKey; +} + +function formatDate(ts) { + if (!ts) return ''; + return new Date(ts).toLocaleDateString(); +} + +export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + const profile = getProfileStats(); + const bests = getAllPersonalBests(); + + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Stats</h2> + + ${profile.testsCompleted > 0 ? ` + <div class="stats-summary"> + <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${profile.testsCompleted}</div></div> + <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${profile.averageWpm}</div></div> + <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(profile.bestWpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${profile.averageAccuracy}%</div></div> + <div class="menu-stat"><div class="stat-label">Streak</div><div class="stat-value">${profile.streakDays}d</div></div> + <div class="menu-stat"><div class="stat-label">Time Typed</div><div class="stat-value">${Math.round(profile.totalTimeSeconds / 60)}m</div></div> + </div> + ` : `<div class="stats-empty">No tests completed yet - finish a test to start tracking stats.</div>`} + + <h3>Personal bests</h3> + ${bests.length > 0 ? ` + <div class="stats-pb-list"> + ${bests.map(b => ` + <div class="stats-pb-row"> + <div class="stats-pb-mode">${escapeHtml(modeLabel(b.modeKey))}</div> + <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div> + <div class="stats-pb-date">${formatDate(b.date)}</div> + </div> + `).join('')} + </div> + ` : `<div class="stats-empty">No personal bests recorded yet.</div>`} + + <h3>Progress</h3> + ${profile.history.length > 1 + ? `<div class="stats-chart"><canvas></canvas></div>` + : `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`} + + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const canvas = root.querySelector('.stats-chart canvas'); + let cleanupResize = () => {}; + if (canvas) { + const redraw = () => drawProgressChart(canvas, profile.history); + redraw(); + window.addEventListener('resize', redraw); + cleanupResize = () => window.removeEventListener('resize', redraw); + } + + const cleanupRail = renderCornerRail(root, { onShowStats: null, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupResize(); + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js new file mode 100644 index 0000000..1ab2f84 --- /dev/null +++ b/web/src/screens/storeScreen.js @@ -0,0 +1,145 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { FLAIR_ICONS } from './icons.js'; + +function formatPrice(cents) { + return `$${(cents / 100).toFixed(2)}`; +} + +function categoryLabel(category) { + return category === 'caret' ? 'Caret Colors' : 'Flair'; +} + +// Real purchase/equip flow against the catalog, but purchase is a stub -- +// it grants ownership immediately with no actual charge, since real payment +// processing needs the project owner's own Stripe (or other processor) +// account. Parked as a question rather than assumed: see nightshift +// questions. +export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) { + let catalog = []; + let mine = { owned: [], equipped_caret: null, equipped_flair: null }; + let status = 'loading'; + let message = ''; + let stopped = false; + + function itemMarkup(item) { + const owned = mine.owned.includes(item.id); + const equipped = mine.equipped_caret === item.id || mine.equipped_flair === item.id; + const swatch = item.category === 'caret' + ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>` + : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`; + return ` + <div class="leaderboard-row store-item-row"> + ${swatch} + <div class="leaderboard-name">${escapeHtml(item.name)}</div> + ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''} + ${owned + ? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` + : `<button class="menu-button small" data-action="buy" data-id="${item.id}">Buy</button>`} + </div> + `; + } + + function bodyMarkup() { + if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`; + if (status === 'loading') return `<div class="stats-empty">Loading...</div>`; + if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`; + + const categories = ['caret', 'flair']; + return categories.map(cat => { + const items = catalog.filter(i => i.category === cat); + if (items.length === 0) return ''; + return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`; + }).join('') + `<div class="custom-error store-error"></div>`; + } + + function render() { + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Store</h2> + ${bodyMarkup()} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', onShowAccount); + + const errorEl = root.querySelector('.store-error'); + root.querySelectorAll('[data-action="buy"]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.post(`/api/cosmetics/${btn.dataset.id}/purchase`); + await loadMine(); + rerender(); + } catch (err) { + if (errorEl) errorEl.textContent = err instanceof ApiError ? err.message : 'Could not complete the purchase - try again.'; + } + }); + }); + root.querySelectorAll('[data-action="equip"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {}); + await loadMine(); + rerender(); + }); + }); + root.querySelectorAll('[data-action="unequip"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post('/api/cosmetics/unequip', { category: btn.dataset.category }).catch(() => {}); + await loadMine(); + rerender(); + }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }); + return () => { cleanupTheme(); cleanupRail(); }; + } + + let cleanup; + function rerender() { + if (stopped) return; + if (cleanup) cleanup(); + cleanup = render(); + } + + async function loadCatalog() { + try { + catalog = await api.get('/api/cosmetics'); + } catch { + status = 'error'; + message = 'Could not load the store - try again.'; + } + } + + async function loadMine() { + if (!getUser()) { status = 'signed-out'; return; } + try { + mine = await api.get('/api/cosmetics/me'); + status = 'ok'; + } catch { + status = 'error'; + message = 'Could not load your cosmetics - try again.'; + } + } + + cleanup = render(); + (async () => { + await loadCatalog(); + await loadMine(); + rerender(); + })(); + + return () => { + stopped = true; + if (cleanup) cleanup(); + }; +} diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js new file mode 100644 index 0000000..72442a2 --- /dev/null +++ b/web/src/screens/typingGame.js @@ -0,0 +1,309 @@ +import { calculateStats, emptyStats } from '../stats.js'; +import { escapeHtml } from '../util.js'; +import { highlightClasses } from '../customText.js'; +import { getSettings } from '../settings.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { renderTopRail } from '../topRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { playKeySound } from '../keySounds.js'; +import { recordKeystroke } from '../keyStats.js'; +import { getUser } from '../auth.js'; +import { api } from '../api.js'; + +export function renderTypingGame(root, { game, text, attribution, language, progress, timeLimit, modeKey, zenMode, multiplayer, onFinish, onMainMenu, onRestart, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + const syntaxClasses = language ? highlightClasses(text, language) : null; + const { hideLiveStats, caretBlink, blindMode } = getSettings(); + root.innerHTML = ` + <div class="typing-game${hideLiveStats ? ' hide-live-stats' : ''}${caretBlink ? '' : ' no-caret-blink'}"> + <div class="logo" data-action="menu">TyperPunk</div> + ${progress ? `<div class="segment-progress">${escapeHtml(progress)}</div>` : ''} + <div class="text-container"> + <div class="text-display${language ? ' code' : ''}"></div> + <input class="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" /> + </div> + ${attribution ? `<div class="attribution">- ${escapeHtml(attribution)}</div>` : ''} + <div class="wpm-stat"><div class="stat-label">WPM</div><div class="stat-value" data-field="wpm">0</div></div> + <div class="acc-stat"><div class="stat-label">ACC</div><div class="stat-value" data-field="acc">100%</div></div> + <div class="time-stat"><div class="stat-label">TIME</div><div class="stat-value" data-field="time">0.0</div></div> + ${zenMode ? `<button class="zen-finish-button" data-action="zen-finish">Finish</button>` : ''} + ${onRestart ? `<button class="restart-toggle" data-action="restart" aria-label="Restart" data-tooltip="Restart (Tab)">↻</button>` : ''} + </div> + `; + + const textDisplay = root.querySelector('.text-display'); + const input = root.querySelector('.typing-input'); + const wpmField = root.querySelector('[data-field="wpm"]'); + const accField = root.querySelector('[data-field="acc"]'); + const timeField = root.querySelector('[data-field="time"]'); + + attachTooltips(root); + // Restart and Zen Finish used to fixed-position themselves at hardcoded + // right offsets (4.25rem and 7.5rem) that assumed the theme toggle was the + // only thing to their right. The top rail is wider than that now, so they + // are handed to it as row members instead of guessing where the row ends. + const railExtras = []; + const zenBtn = root.querySelector('[data-action="zen-finish"]'); + if (zenBtn) railExtras.push(zenBtn); + const restartBtn = root.querySelector('[data-action="restart"]'); + if (restartBtn) railExtras.push(restartBtn); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends, extras: railExtras }); + + // Equipped caret color (see cosmetics.rs) overrides the theme's default + // --caret-color on this screen only, via the CSS custom property the + // caret's ::after rule already reads - no equipped caret just leaves + // the theme default in place. + let caretColorTorndown = false; + if (getUser()) { + Promise.all([api.get('/api/cosmetics/me'), api.get('/api/cosmetics')]) + .then(([mine, catalog]) => { + if (caretColorTorndown || !mine.equipped_caret) return; + const item = catalog.find(i => i.id === mine.equipped_caret); + if (item && item.value) { + root.querySelector('.typing-game')?.style.setProperty('--caret-color', item.value); + } + }) + .catch(() => {}); + } + + root.querySelector('[data-action="menu"]').addEventListener('click', onMainMenu); + root.querySelector('[data-action="restart"]')?.addEventListener('click', () => onRestart()); + + let finished = false; + let startTime = null; + let keystrokes = []; + let charTimings = []; + let intervalId = null; + // Persistent, never-decremented count of every forward keystroke made, + // including ones later backspaced away - feeds raw WPM (see stats.js). + let totalKeystrokes = 0; + let lastInputLength = 0; + // Accuracy from the wasm engine is cumulative (tracks every mistake ever + // made, even ones since backspaced away) - the interval below has no + // fresh keystroke to read it from, so it needs its own last-known copy + // instead of falling back to stats.js's current-input-only calculation, + // which forgets a mistake the moment it's corrected. + let lastAccuracy = 100; + // Idle/AFK timeout: a test left running with no keystrokes for this long + // auto-finishes with whatever was typed so far, instead of sitting open + // indefinitely. Applies to every standard mode (quote/words/time) -- + // there's no reason a stalled tab should keep accumulating elapsed time + // and (before the chart's own bucket cap) inflating the graph. + const IDLE_TIMEOUT_MS = 180000; + let lastActivityTime = null; + + function renderText(currentInput) { + const inputChars = currentInput.split(''); + const wordMatches = text.match(/[^\s]+\s*/g) || []; + let charIndex = 0; + const html = []; + for (const word of wordMatches) { + html.push('<span>'); + for (const char of word) { + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + // Blind Mode: no correct/incorrect feedback while typing + // (not even which character was actually typed for a + // mistake) - the WASM engine still tracks correctness + // normally underneath for accuracy/stats, this only + // changes what gets rendered live. Revealed as usual on + // the end screen, which does its own independent diff. + if (blindMode) { + className = 'typed'; + } else { + className = inputChar === char ? 'correct' : 'incorrect'; + if (className === 'incorrect') displayChar = inputChar; + } + } else if (syntaxClasses && syntaxClasses[charIndex]) { + className += ` ${syntaxClasses[charIndex]}`; + } + if (!finished && charIndex === currentInput.length) className += ' current'; + html.push(`<span class="${className}">${escapeHtml(displayChar)}</span>`); + charIndex++; + } + html.push('</span>'); + } + textDisplay.innerHTML = html.join(''); + } + + let lastKeystrokeAt = null; + function recordChar(index, isCorrect, time) { + keystrokes.push({ time, index, isCorrect }); + charTimings = charTimings.slice(0, index); + charTimings.push({ time, isCorrect, index }); + + // Skips the very first keystroke of the test - "time since start" + // isn't a meaningful per-key latency the way "time since the last + // keystroke" is for every one after it. + if (lastKeystrokeAt != null) { + recordKeystroke(text[index], isCorrect, (time - lastKeystrokeAt) * 1000); + } + lastKeystrokeAt = time; + } + + function liveStats(currentInput) { + if (!startTime) return emptyStats(); + return calculateStats(currentInput, text, (Date.now() - startTime) / 1000, totalKeystrokes); + } + + function paintStats(stats, accuracy) { + wpmField.textContent = String(Math.round(stats.wpm)); + accField.textContent = `${Math.round(accuracy ?? stats.accuracy)}%`; + // 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. + timeField.textContent = timeLimit + ? Math.max(0, timeLimit - stats.time).toFixed(1) + : stats.time.toFixed(1); + + // Single hook point for every caller of paintStats (each keystroke, + // the 100ms live-stats interval, backspace) rather than scattering a + // sendProgress call at each call site. + if (multiplayer && stats.totalChars > 0) { + const percent = ((stats.correctChars + stats.incorrectChars) / stats.totalChars) * 100; + multiplayer.connection.sendProgress(percent, stats.wpm); + } + } + + function finish(accuracy, mistakes, finalInput) { + finished = true; + input.disabled = true; + if (intervalId) clearInterval(intervalId); + const elapsed = startTime ? (Date.now() - startTime) / 1000 : 0; + // Time/Zen mode's text is a long word buffer sized to outlast the + // clock (or, for Zen, just sized generously since there's no clock + // at all) - trim it to what was actually reached so the end screen + // doesn't try to render hundreds of untyped words. + const finalText = (timeLimit || zenMode) ? text.slice(0, finalInput.length) : text; + const stats = calculateStats(finalInput, finalText, elapsed, totalKeystrokes); + stats.accuracy = accuracy; + stats.incorrectChars = mistakes; + if (multiplayer) multiplayer.connection.sendFinish(stats.wpm, accuracy, elapsed); + onFinish({ stats, text: finalText, attribution, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey }); + } + + function handleChange() { + if (finished || !game) return; + const value = input.value; + if (value.length > text.length) { + input.value = value.slice(0, text.length); + return; + } + if (!startTime) startTime = Date.now(); + lastActivityTime = Date.now(); + const elapsed = (Date.now() - startTime) / 1000; + + try { + game.handle_input(value); + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + input.value = wasmInput; + if (wasmInput.length > lastInputLength) { + totalKeystrokes += wasmInput.length - lastInputLength; + playKeySound(); + } + lastInputLength = wasmInput.length; + if (wasmInput.length > 0) { + const idx = wasmInput.length - 1; + recordChar(idx, wasmInput[idx] === text[idx], elapsed); + } + renderText(wasmInput); + lastAccuracy = accuracy; + paintStats(liveStats(wasmInput), accuracy); + + if (game.is_finished()) { + finish(accuracy, mistakes, wasmInput); + } + } catch (err) { + console.error('WASM input error:', err); + } + } + + async function handleKeydown(e) { + if (finished || !game) return; + if (e.key === 'Tab') { + // Quick restart: abandon the current attempt and start a fresh + // one of the same mode, without reaching for the mouse. + e.preventDefault(); + if (onRestart) onRestart(); + return; + } + const ctrlOrMeta = e.ctrlKey || e.metaKey; + // Some platforms bind native readline-style line-editing to text + // inputs (Ctrl+U "clear to start of line", Ctrl+K "clear to end"). + // Left unblocked, these silently wipe the whole typed input via the + // browser's own text-field editing, bypassing our backspace rules + // entirely - block them outright rather than let them fire. + if (ctrlOrMeta && (e.key === 'u' || e.key === 'U' || e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + return; + } + if (e.key !== 'Backspace') return; + e.preventDefault(); + try { + const ctrl = ctrlOrMeta; + const success = await game.handle_backspace(ctrl); + if (success) { + lastActivityTime = Date.now(); + const [wasmInput, accuracy] = await game.get_stats_and_input(); + input.value = wasmInput; + lastInputLength = wasmInput.length; + charTimings = charTimings.slice(0, wasmInput.length); + renderText(wasmInput); + lastAccuracy = accuracy; + paintStats(liveStats(wasmInput), accuracy); + } + } catch (err) { + console.error('WASM backspace error:', err); + } + } + + input.addEventListener('input', handleChange); + input.addEventListener('keydown', handleKeydown); + input.addEventListener('blur', () => { + if (!finished) setTimeout(() => input.focus(), 10); + }); + + // Zen has no timer and no fixed length, so unlike every other mode it + // never ends on its own - this is the only way to actually finish one. + const zenFinishBtn = root.querySelector('[data-action="zen-finish"]'); + if (zenFinishBtn) { + zenFinishBtn.addEventListener('click', () => { + if (finished || !game || !startTime) return; + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + }); + } + + renderText(''); + paintStats(emptyStats(), 100); + setTimeout(() => input.focus(), 50); + + intervalId = setInterval(() => { + if (finished || !startTime) return; + const elapsed = (Date.now() - startTime) / 1000; + if (timeLimit && elapsed >= timeLimit) { + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + return; + } + if (lastActivityTime && Date.now() - lastActivityTime >= IDLE_TIMEOUT_MS) { + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + return; + } + paintStats(liveStats(input.value), lastAccuracy); + }, 100); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + // No Tab-restart shortcut mid-multiplayer-race (onRestart is null there, + // same guard the restart-toggle button itself uses) - falls back to + // the plain version stamp instead of advertising a shortcut that does + // nothing. + + return () => { + caretColorTorndown = true; + cleanupTheme(); + if (intervalId) clearInterval(intervalId); + cleanupRail(); + }; +} diff --git a/web/src/settings.js b/web/src/settings.js new file mode 100644 index 0000000..37e10f0 --- /dev/null +++ b/web/src/settings.js @@ -0,0 +1,40 @@ +const STORAGE_KEY = 'typerpunk:settings'; + +function load() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return raw && typeof raw === 'object' ? raw : {}; + } catch { + return {}; + } +} + +let settings = { + skipMenu: false, + favoriteMode: null, + wordCount: 25, + timeDuration: 30, + wordsPunctuation: false, + wordsNumbers: false, + caretBlink: true, + hideLiveStats: false, + blindMode: false, + soundTheme: 'off', + wordListTier: 'common', + // Which vocabulary the generated-word modes draw from (see languages.js). + language: 'en', + ...load(), +}; + +function save() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch {} +} + +export function getSettings() { + return { ...settings }; +} + +export function updateSettings(patch) { + settings = { ...settings, ...patch }; + save(); +} diff --git a/web/src/stats.js b/web/src/stats.js new file mode 100644 index 0000000..00d4db6 --- /dev/null +++ b/web/src/stats.js @@ -0,0 +1,66 @@ +export function emptyStats() { + return { + wpm: 0, + rawWpm: 0, + keystrokes: 0, + accuracy: 100, + time: 0, + correctChars: 0, + incorrectChars: 0, + totalChars: 0, + currentStreak: 0, + bestStreak: 0, + }; +} + +// `totalKeystrokes` is a persistent, never-decremented count of every forward +// keystroke made during the test, including ones later backspaced away. It +// must be tracked externally (see typingGame.js) because a backspaced +// mistake is no longer present in `input` by the time this runs - without +// it, raw WPM would silently collapse to equal WPM whenever the final input +// happens to be fully correct, hiding every corrected mistake. Falls back to +// `input.length` (final-state only) when the caller doesn't track it. +export function calculateStats(input, text, elapsedTime, totalKeystrokes) { + let correct = 0; + let totalErrors = 0; + let currentStreak = 0; + let bestStreak = 0; + const hasStartedTyping = input.length > 0; + + for (let i = 0; i < input.length; i++) { + if (i < text.length && input[i] === text[i]) { + correct++; + currentStreak++; + bestStreak = Math.max(bestStreak, currentStreak); + } else { + totalErrors++; + currentStreak = 0; + } + } + + const totalTyped = input.length; + const accuracy = !hasStartedTyping ? 100 : Math.max(0, Math.min(100, (correct / totalTyped) * 100)); + const wpm = elapsedTime === 0 ? 0 : (correct / 5) / (elapsedTime / 60); + // Raw WPM: same formula as WPM, but counting every keystroke ever made + // (right, wrong, or later corrected) instead of only correct ones -- + // this is how MonkeyType/TypeRacer define it: raw speed ignores accuracy + // entirely, including keystrokes you fixed with backspace. + const rawCount = totalKeystrokes ?? totalTyped; + const rawWpm = elapsedTime === 0 ? 0 : (rawCount / 5) / (elapsedTime / 60); + + return { + wpm, + rawWpm, + accuracy, + time: elapsedTime, + correctChars: correct, + incorrectChars: totalErrors, + totalChars: text.length, + // Every forward keystroke of the test, corrected-away ones included -- + // the same figure rawWpm is derived from. Reported on the end screen + // in its own right, the way 10FastFingers does. + keystrokes: rawCount, + currentStreak, + bestStreak, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..b556038 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,2440 @@ +:root { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #c8ccd0; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #8b8e92; + /* Unified top offset below fixed header/toggle */ + --header-offset: 3.5rem; + /* App padding variable so we can compensate where needed */ + --app-padding: 1rem; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="light"] { + --primary-color: #067a4e; + --secondary-color: #05663f; + --background-color: #f6f6f4; + --text-color: #2b2b2b; + --error-color: #b3323f; + --correct-color: #067a4e; + --neutral-color: #8a8d90; + --caret-color: #067a4e; + --sub-color: #5c5f61; + --record-color: #9a6b00; + --warning-color: #a35b00; + --player-2: #1667c2; + --player-3: #b45309; + --player-4: #7c3aed; + --player-5: #be123c; +} + +[data-theme="dark"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #c8ccd0; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #8b8e92; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="dracula"] { + --primary-color: #bd93f9; + --secondary-color: #ff79c6; + --background-color: #282a36; + --text-color: #f8f8f2; + --error-color: #ff5555; + --correct-color: #bd93f9; + --neutral-color: #6272a4; + --caret-color: #bd93f9; + --sub-color: #9aa5c4; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="nord"] { + --primary-color: #88c0d0; + --secondary-color: #81a1c1; + --background-color: #2e3440; + --text-color: #d8dee9; + --error-color: #bf616a; + --correct-color: #88c0d0; + --neutral-color: #4c566a; + --caret-color: #88c0d0; + --sub-color: #93a1b8; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="solarized"] { + --primary-color: #268bd2; + --secondary-color: #2aa198; + --background-color: #002b36; + --text-color: #93a1a1; + --error-color: #dc322f; + --correct-color: #268bd2; + --neutral-color: #586e75; + --caret-color: #268bd2; + --sub-color: #839496; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="gruvbox"] { + --primary-color: #fabd2f; + --secondary-color: #b8bb26; + --background-color: #282828; + --text-color: #ebdbb2; + --error-color: #fb4934; + --correct-color: #fabd2f; + --neutral-color: #928374; + --caret-color: #fabd2f; + --sub-color: #bdae93; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="monokai"] { + --primary-color: #a6e22e; + --secondary-color: #66d9ef; + --background-color: #272822; + --text-color: #f8f8f2; + --error-color: #f92672; + --correct-color: #a6e22e; + --neutral-color: #75715e; + --caret-color: #a6e22e; + --sub-color: #b0a98d; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + padding: 0; + font-family: 'JetBrains Mono', monospace; + background-color: var(--background-color); + color: var(--text-color); + height: 100vh; + /* Vertical scroll is allowed (and only appears when actually needed) -- + a fixed "overflow: hidden" here silently clipped any screen whose + content got taller than the viewport (a wide-but-short window, a + longer passage, extra stat rows) with literally no way to reach the + clipped part. Horizontal stays clipped since nothing should ever + need to scroll sideways. */ + overflow-x: hidden; + overflow-y: auto; + /* Cursor is set per-theme in JS (theme.js) - a hardcoded green crosshair + here would look wrong on Dracula/Nord/etc. */ +} + +.app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; /* avoid vertical centering that pushes content down */ + height: 100vh; + padding: var(--app-padding); + overflow: hidden; +} + +.logo { + position: fixed; + top: 1rem; + left: 1rem; + font-size: 1.2rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; + cursor: pointer; + transition: color 0.2s ease; +} + +.logo:hover { + color: var(--secondary-color); +} + +/* Main Menu */ +.main-menu { + display: flex; + flex-direction: column; + align-items: center; + /* Pinned near the top rather than vertically centered - centered left + barely more room below the Start button than the mode popover needs, + so on anything but a tall viewport the list ran past the bottom edge. + Fixed higher, there's always real room underneath it. */ + justify-content: flex-start; + padding-top: 6rem; + height: 100vh; + width: 100%; + max-width: 700px; + margin: 0 auto; + padding-left: 2rem; + padding-right: 2rem; + padding-bottom: 2rem; + overflow-y: auto; + box-sizing: border-box; +} + +.main-menu h1 { + font-size: 4rem; + color: var(--primary-color); + margin-bottom: 2rem; + text-transform: uppercase; + letter-spacing: 4px; +} + +.settings-row-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.settings-row-group .menu-button.small { + width: 230px; +} + +.menu-stat .stat-label { + font-size: 0.7rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.2rem; +} + +.menu-stat .stat-value { + font-size: 1.3rem; + color: var(--primary-color); + font-weight: bold; +} + +/* Stats Screen */ +.stats-screen { + width: 100%; + max-width: 700px; + margin: 0 auto; + padding: 2rem; + margin-top: calc(var(--header-offset) - var(--app-padding)); + min-height: calc(100vh - var(--header-offset)); + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + /* Vertically centred when the content is short - these screens were + top-heavy, ending around y=510 on a 900px viewport with the rest of + the page empty. "safe" keeps it flex-start once the content is taller + than the viewport, so a long list is never cut off above the scroll + origin; browsers that do not know the keyword ignore the declaration + and keep the old flex-start behaviour. */ + justify-content: safe center; + overflow-y: auto; +} + +.stats-screen h2 { + font-size: 1.8rem; + color: var(--primary-color); + text-transform: uppercase; + letter-spacing: 3px; + margin: 1.5rem 0 1.5rem; +} + +.stats-screen h3 { + font-size: 1rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 2px; + align-self: flex-start; + margin: 2rem 0 0.75rem; +} + +.stats-summary { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 1.25rem 1rem; + text-align: center; +} + +.stats-empty { + color: var(--sub-color); + font-size: 0.9rem; + align-self: flex-start; +} + +.stats-pb-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stats-pb-row { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.6rem 1rem; +} + +.stats-pb-mode { + flex: 1; + text-align: left; +} + +.stats-pb-wpm { + color: var(--primary-color); + font-weight: bold; +} + +.stats-pb-date { + color: var(--sub-color); + font-size: 0.8rem; + min-width: 90px; + text-align: right; +} + +.leaderboard-modes { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.leaderboard-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.leaderboard-row { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.6rem 1rem; +} + +.leaderboard-rank { + color: var(--sub-color); + min-width: 2.5rem; +} + +.leaderboard-name { + flex: 1; + text-align: left; + /* A <button> now (links to the player's public profile) - reset away + the browser's default button chrome so it still just reads as text. */ + background: none; + border: none; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; +} + +.leaderboard-name:hover { + color: var(--primary-color); + text-decoration: underline; +} + +.leaderboard-wpm { + color: var(--primary-color); + font-weight: bold; +} + +.leaderboard-acc { + color: var(--sub-color); + min-width: 3.5rem; + text-align: right; +} + +.leaderboard-date { + color: var(--sub-color); + font-size: 0.8rem; + min-width: 90px; + text-align: right; +} + +.store-swatch { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 4px; + border: 1px solid var(--neutral-color); +} + +.store-flair-swatch { + color: var(--caret-color); + padding: 0.25rem; +} + +.store-flair-swatch svg { + width: 100%; + height: 100%; +} + +/* Marks a result typed on a touchscreen, which is not comparable with a + desktop one. It sat in the same grey as ordinary secondary text, so the + caveat read as decoration - this is the second and last use of + --warning-color. */ +.leaderboard-device-badge { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--warning-color); + border: 1px solid color-mix(in srgb, var(--warning-color) 45%, transparent); + border-radius: 4px; + padding: 0.1rem 0.35rem; +} + +.leaderboard-flair { + display: inline-flex; + width: 0.9em; + height: 0.9em; + margin-left: 0.35em; + vertical-align: -0.05em; + color: var(--caret-color); +} + +.leaderboard-flair svg { + width: 100%; + height: 100%; +} + +.stats-placeholder { + width: 100%; + border: 1px dashed var(--neutral-color); + border-radius: 8px; + padding: 2rem 1rem; + text-align: center; + color: var(--sub-color); + font-size: 0.85rem; +} + +.stats-chart { + width: 100%; + height: 200px; + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.5rem; + box-sizing: border-box; +} + +.stats-chart canvas { + width: 100%; + height: 100%; + display: block; +} + +.stats-screen .menu-button { + margin-top: 2rem; +} + +.menu-options { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.start-group { + position: relative; + /* Fixed, not content-sized - a content-sized button changed width (and + shifted everything else in the centered column sideways with it) + every time you picked a mode with a longer or shorter name. */ + width: 360px; +} + +/* Sits directly above the Start button it describes, replacing the old + bottom-right corner hint - the keyboard shortcut is more useful read + right next to the button it triggers than in a corner of the screen. */ +/* Fixed width via .start-group above - ellipsis covers a very long custom + text name that would otherwise force the box wider. The chevron overlays + the button's own right edge rather than squeezing the label into a + smaller box, so padding-right reserves space for it - without this, a + long mode name ("Start: Literature") ran its text straight under the + chevron instead of stopping short of it. */ +/* Bigger and bolder than the other menu buttons - now that its label is + just the mode name ("Timed", not "Start: Timed"), its size has to carry + "this is the one you click" on its own instead of the word "Start". */ +/* The two entry points. Single-line labels naming the action, nothing else + inside the box - what a Single Player run will actually start (mode plus + its rules) sits in the .mode-pill above, so the button reads as one clear + word and the mode stays changeable without redrawing the button. */ +.start-main { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* Symmetric. Nothing overlays either button now that the mode picker is + its own control above them, so both labels centre on the same axis. */ + padding-left: 2rem; + padding-right: 2rem; + font-size: 1.5rem; + padding-top: 1.3rem; + padding-bottom: 1.3rem; + border-width: 3px; + letter-spacing: 3px; +} + +/* Mode + rules for the Single Player button. Hidden (not removed) while the + mode popover is open, so the layout does not jump. */ +.start-caption { + font-size: 0.85rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 1px; + margin-top: -0.35rem; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Split button: most of the width starts the game, the chevron on the right + edge (overlaid, not squeezing the label) opens the mode list. Mode used to + be its own separate full-width button - merged here since Start already + displays the current mode in its own label ("Start: Timed"). */ +/* The mode picker, above the two start buttons rather than notched into the + Single Player button's right edge. As a chevron inside the button it cut a + 3px divider through the outline and made the button look clipped, and it + forced the label off-centre to avoid it. As its own quiet pill it also + removes the caption that used to sit between Single Player and Multiplayer, + separating the two things you actually came here to press. */ +.mode-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + max-width: 360px; + background: none; + border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent); + border-radius: 999px; + color: var(--sub-color); + font-family: inherit; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 1px; + padding: 0.45rem 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.mode-pill:hover { + border-color: var(--primary-color); + color: var(--text-color); +} + +.mode-pill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mode-pill svg { + width: 0.9rem; + height: 0.9rem; + flex-shrink: 0; +} + + + + + +/* What Start will actually generate, visible without opening Settings. */ +/* Every mode listed as a direct pick when the button is clicked, instead of + cycling blind through them one click at a time - Custom and the text + categories were otherwise invisible unless you happened to click through + the whole list first. + + position:fixed with JS-computed coordinates (not CSS position:absolute + below the button) - with 8 modes listed, the panel is taller than the + gap between the button and the bottom of the viewport on an ordinary + 900px-tall screen, and a fixed "opens downward" placement ran the last + 1-2 items off the bottom edge with no way to reach them. JS picks + whichever of above/below actually has room, and this only falls back to + its own internal scroll (never the page's) if neither direction has + enough space at all. */ +/* Two columns, one per kind of entry (see mainMenu.js). As a single column + this was 585px of 18 undifferentiated rows on a 900px viewport - it ran + off the bottom into a scroll region and covered the Multiplayer button and + the icon rail. Side by side it is roughly half that and needs no scrolling + at any normal window height. */ +.mode-popover { + position: fixed; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.5rem; + background: var(--background-color); + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.5rem; + z-index: 10; + max-height: calc(100vh - 2rem); + overflow-y: auto; +} + +.mode-popover-group { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1 1 0; + min-width: 0; +} + +/* Same small dim caption as the Settings panel's "Rules"/"Typing" headings, + so the two panels read as the same kind of surface. */ +.mode-popover-heading { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--sub-color); + padding: 0.25rem 0.7rem 0.35rem; + border-bottom: 1px solid var(--neutral-color); + margin-bottom: 0.25rem; +} + +.mode-popover-item { + background: none; + border: none; + color: var(--text-color); + font-family: inherit; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + padding: 0.45rem 0.7rem; + border-radius: 4px; + cursor: pointer; + text-align: left; + flex-shrink: 0; +} + +.mode-popover-item:hover { + background: rgba(128, 128, 128, 0.15); +} + +.mode-popover-item.active { + color: var(--primary-color); + font-weight: bold; +} + +.menu-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; + /* min-width has no effect on an inline element - a plain <button> + defaults to inline-block already, but this class is also used on the + Spotify "Connect" <a> link (an OAuth login has to be a real page + navigation, not a fetch), which needs these spelled out explicitly. */ + display: inline-block; + text-align: center; + text-decoration: none; + box-sizing: border-box; +} + +.menu-button:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +.menu-button.small { + padding: 0.6rem 1.2rem; + font-size: 0.9rem; + min-width: 0; +} + +/* Secondary actions. A dashed outline conventionally means "placeholder" or + "drop target", so Join / Create a Room / the leaderboard filter chips all + read as unfinished. Solid at reduced strength says "secondary" instead, + and still inverts to the filled state on hover like every other button. */ +.menu-button.ghost { + border-color: color-mix(in srgb, var(--primary-color) 45%, transparent); + color: var(--sub-color); +} + +.menu-button.ghost:hover { + border-color: var(--primary-color); + color: var(--background-color); +} + +.custom-hint { + font-size: 0.8rem; + color: var(--neutral-color); + text-align: center; + max-width: 340px; +} + +.settings-panel { + width: 100%; + max-width: 620px; + display: flex; + flex-direction: column; + align-items: center; + /* Tightened alongside the two-column split: .app is overflow:hidden, so + anything past the viewport bottom is clipped rather than scrollable. */ + gap: 0.55rem; + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.85rem; + margin: 1rem auto 0 auto; + text-align: center; +} + +.settings-row { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.9rem; + cursor: pointer; +} + +.settings-hint { + font-size: 0.75rem; + color: var(--neutral-color); + max-width: 340px; +} + +.theme-gallery { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 0.5rem; + width: 100%; +} + +.theme-swatch { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + background: transparent; + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.5rem; + cursor: pointer; + font-family: inherit; +} + +.theme-swatch.active { + border-color: var(--primary-color); +} + +.theme-swatch-preview { + display: block; + width: 100%; + height: 1.5rem; + border-radius: 4px; +} + +.theme-swatch-label { + font-size: 0.7rem; + color: var(--text-color); +} + +.theme-swatch-preview[data-swatch="dark"] { background: linear-gradient(90deg, #000000 60%, #00ff9d 60%); } +.theme-swatch-preview[data-swatch="light"] { background: linear-gradient(90deg, #f6f6f4 60%, #067a4e 60%); } +.theme-swatch-preview[data-swatch="dracula"] { background: linear-gradient(90deg, #282a36 60%, #bd93f9 60%); } +.theme-swatch-preview[data-swatch="nord"] { background: linear-gradient(90deg, #2e3440 60%, #88c0d0 60%); } +.theme-swatch-preview[data-swatch="solarized"] { background: linear-gradient(90deg, #002b36 60%, #268bd2 60%); } +.theme-swatch-preview[data-swatch="gruvbox"] { background: linear-gradient(90deg, #282828 60%, #fabd2f 60%); } +.theme-swatch-preview[data-swatch="monokai"] { background: linear-gradient(90deg, #272822 60%, #a6e22e 60%); } + +.custom-panel { + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 1rem; + margin: 1.5rem auto 0 auto; +} + +.custom-textarea { + width: 100%; + min-height: 120px; + resize: vertical; + background: transparent; + color: var(--text-color); + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.75rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + transition: border-color 0.15s ease; +} + +.custom-textarea.drag-over { + border-color: var(--primary-color); + border-style: dashed; +} + +.custom-stats { + font-size: 0.8rem; + color: var(--sub-color); + text-align: center; +} + +.custom-panel-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.file-label { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.custom-file-name { + font-size: 0.8rem; + color: var(--neutral-color); + word-break: break-all; +} + +.custom-error { + font-size: 0.8rem; + color: var(--error-color); + min-height: 1em; +} + +/* Account screen */ +.account-panel { + width: 100%; + max-width: 360px; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1rem auto 0 auto; +} + +.account-tabs { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +.account-form { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.account-input { + background: transparent; + color: var(--text-color); + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.65rem 0.75rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; +} + +.account-input:focus { + outline: none; + border-color: var(--primary-color); +} + +.account-signed-in { + text-align: center; + font-size: 1rem; +} + +.account-spotify-status { + font-size: 0.85rem; + color: var(--sub-color); + text-align: center; +} + +/* Typing Game */ +.typing-game { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 1300px; + margin: 0 auto; + padding: 1rem; + position: relative; + height: 100vh; + overflow: hidden; +} + +.text-container { + position: relative; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 1rem 2rem 0 2rem; + margin-top: var(--header-offset); /* unified: just below header/toggle */ +} + +.text-display { + font-family: 'JetBrains Mono', monospace; + font-size: 1.5rem; + line-height: 1.5; + min-height: 6rem; + color: var(--text-color); + position: relative; + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: break-word; + /* A whole number of characters wide, not fit-content. When the passage + wraps, fit-content resolves to the full available width - which was + 78.9 character cells, so the leftover 0.9 cell could never be filled + and always sat on the right. The box measured perfectly centred while + the text inside it read as right-heavy. An exact character count means + a full line reaches both edges. (border-box is global, hence the + padding in the calc.) */ + width: min(100%, calc(76ch + 2rem)); + margin: 0 auto; + tab-size: 4; + hyphens: none; + text-align: left; + padding: 1rem; +} + +.text-display span { + white-space: pre; + position: relative; + display: inline-block; +} + +.text-display span.correct { + color: var(--correct-color); +} + +.text-display span.incorrect { + color: var(--error-color); +} + +.text-display span.neutral { + color: var(--neutral-color); +} + +/* Blind Mode: a typed character just looks "passed", no red/green judgment + - distinct from .neutral (not yet reached) so the caret's progress is + still visible, just without correctness feedback. */ +.text-display span.typed { + color: var(--text-color); +} + +.text-display span.current { + position: relative; +} + +.text-display span.current::after { + content: '▏'; + position: absolute; + left: 0; + top: 0; + bottom: 0; + color: var(--caret-color); + animation: blink 1s step-end infinite; +} + +.typing-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + z-index: 1; + cursor: text; + caret-color: transparent; + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.typing-input:focus { + background: transparent !important; + color: transparent !important; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.theme-toggle { + position: fixed; + top: 1rem; + right: 1rem; + background: transparent; + border: 2px solid var(--primary-color); + outline: none; + box-shadow: none; + color: var(--primary-color); + width: 2.5rem; + height: 2.5rem; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 4px; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +.theme-toggle:focus, +.theme-toggle:active { + outline: none; + box-shadow: none; +} + +.theme-toggle:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +/* Wrapper for themeButton.js's standalone control (every screen besides the + main menu, which builds its own .theme-corner-group inline). Fixed itself + so its child button can drop to position:static and still lay out + correctly as the popover's positioned ancestor - same reasoning as + .corner-rail-left .corner-icon-button. */ +/* Kept fixed for any caller that still mounts it standalone; inside .top-rail + it drops to static so the row lays it out (see .top-rail .theme-control). */ +.theme-control { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 100; +} + +.theme-control .theme-toggle { + position: static; +} + +/* Same box as .theme-toggle, sitting just to its left - Tab already + restarts the current test, but that shortcut isn't discoverable on its + own, so it gets a visible button too. */ +.restart-toggle { + background: transparent; + border: 2px solid var(--primary-color); + outline: none; + box-shadow: none; + color: var(--primary-color); + width: 2.5rem; + height: 2.5rem; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 4px; + font-size: 1.3rem; + line-height: 1; + appearance: none; +} + +.restart-toggle:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +/* Zen has no timer and no fixed length, so unlike every other mode nothing + ever ends it automatically - this is the only way to finish one, so it + gets a real labeled button (not just another small icon square) sitting + in the same top-right cluster as Restart/Theme. Deliberately not placed + near TIME at bottom-center: that space is reserved to stay visually + consistent with the end screen's graph position, not a place to add + controls. */ +.zen-finish-button { + background: transparent; + border: 2px solid var(--primary-color); + outline: none; + box-shadow: none; + color: var(--primary-color); + height: 2.5rem; + padding: 0 1rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 4px; + font-family: inherit; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + appearance: none; +} + +.zen-finish-button:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +.theme-toggle svg { + width: 1.5rem; + height: 1.5rem; + transition: color 0.2s; +} + +/* Same visual language as .theme-toggle (which stays a separate, + self-contained class since it's also used stand-alone on the typing/end + screens) - these are the main menu's other two corner buttons. */ +.corner-icon-button { + position: fixed; + background: transparent; + border: 2px solid var(--primary-color); + outline: none; + box-shadow: none; + color: var(--primary-color); + width: 2.5rem; + height: 2.5rem; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 4px; + appearance: none; +} + +.corner-icon-button:hover { + background-color: var(--primary-color); + color: var(--background-color); +} + +.corner-icon-button svg { + width: 1.5rem; + height: 1.5rem; +} + +/* The icon rail. Two bottom corners, identical on every screen - the main + menu builds the same two clusters inline (it owns its own Settings panel), + cornerRail.js injects them everywhere else. This used to be two different + layouts: bottom corners on the menu, a vertical top-right stack elsewhere, + so the icons jumped position on every navigation. + + Left cluster is identity and behavior (Account, Settings, then Friends and + Store); right cluster is numbers (Stats, Leaderboard, Multiplayer). + Horizontal, not vertical - a bottom corner has little headroom before + hitting the screen edge. */ +/* Top-left, stacked under the wordmark: what customises the app. It used to + be a second bottom cluster, which left the bottom edge carrying navigation + on both sides while the top-left held nothing but the logo. A column, not a + row - there is vertical room here and the passage starts below it. */ +.corner-rail-left { + position: fixed; + top: 3.4rem; + left: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.corner-rail-right { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 100; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; +} + +/* .corner-icon-button is position:fixed by default - inside these flex rows + it needs to drop back to a normal flow position, or every button ignores + the layout entirely and stacks on top of the others at whatever its + unstyled static position happens to be. */ +.corner-rail-left .corner-icon-button, +.corner-rail-right .corner-icon-button { + position: static; +} + +/* Only the main menu still draws one, between its two top-left icons. A + horizontal hairline, since that cluster is a column. */ +.rail-divider { + align-self: stretch; + height: 1px; + background: var(--neutral-color); + opacity: 0.4; +} + +.rail-settings-group { + position: relative; +} + +.rail-settings-panel { + position: absolute; + top: calc(100% + 0.6rem); + left: 0; + background: var(--background-color); + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.75rem; + width: 280px; +} + +.theme-gallery-popover { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + background: var(--background-color); + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.75rem; + width: 320px; +} + +/* Lifetime average over best single result, stacked so both read as one + figure block beside the Stats icon rather than competing for corner room. */ +.stats-corner-figures { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.05rem; + line-height: 1.2; +} + +.stats-corner-avg, +.stats-corner-pb { + font-size: 0.85rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 1px; + white-space: nowrap; +} + + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .main-menu h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + } + + .menu-button { + padding: 0.8rem 1.5rem; + font-size: 1rem; + min-width: 200px; + } + + .typing-game { + padding: 1rem; + } + + .text-container { + padding: 1rem; + margin-top: var(--header-offset); + } + + .wpm-stat { + left: 1rem; + } + + .acc-stat { + right: 1rem; + } + + .time-stat { + bottom: 0.5rem; + font-size: 1rem; + } + + .text-display { + font-size: 1.2rem; + } + + .graph-container { + height: 200px; + padding: 1rem; + } + + .graph-axis.x { + left: 1rem; + right: 1rem; + } + + .graph-axis.y { + bottom: 1rem; + } + + .end-screen-buttons { + bottom: 1rem; + } +} + +@media (max-height: 600px) { + .wpm-stat, + .acc-stat { + top: 50%; + } + + .time-stat { + bottom: 0.25rem; + font-size: 0.9rem; + } + + .text-container { + margin-top: 4.5rem; + padding: 1rem; + } + + .text-display { + font-size: 1.2rem; + min-height: 4.5rem; + } + + .graph-container { + height: 200px; + } +} + +@media (max-width: 600px) { + .typing-game { + padding: 0.5rem; + height: 100vh; + min-width: 0; + } + .text-container { + padding: 0.5rem 0.5rem 0 0.5rem; + margin-top: var(--header-offset); + min-width: 0; + } + .text-display { + font-size: 1rem; + padding: 0.5rem; + min-height: 3rem; + max-width: 100vw; + word-break: break-word; + } +} + +.attribution { + max-width: 700px; + width: 100%; + margin: 0.5rem auto 1.75rem auto; + text-align: center; + color: var(--neutral-color); + font-size: 0.95rem; +} + +.segment-progress { + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.8rem; + color: var(--neutral-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +.text-display.code { + text-align: left; + background: rgba(127, 127, 127, 0.06); + border-radius: 8px; +} + +.text-display span.neutral.syn-keyword { color: var(--secondary-color); } +.text-display span.neutral.syn-string { color: #d9a441; } +.text-display span.neutral.syn-comment { color: var(--sub-color); font-style: italic; } +.text-display span.neutral.syn-number { color: #6f9ee8; } + +/* Passive mode */ +.passive-mode { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 2rem; + position: relative; + text-align: center; +} + +.passive-source { + color: var(--neutral-color); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 2rem; +} + +.passive-lines { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + margin-bottom: 2rem; +} + +.passive-line { + font-family: 'JetBrains Mono', monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.passive-line.prev, +.passive-line.next { + color: var(--neutral-color); + opacity: 0.5; + font-size: 1.1rem; +} + +.passive-line.active { + font-size: 1.6rem; + line-height: 1.5; + min-height: 2.4rem; +} + +.passive-input { + position: relative; + width: 100%; + max-width: 500px; + background: transparent; + color: var(--text-color); + border: none; + border-bottom: 2px solid var(--neutral-color); + outline: none; + font-family: 'JetBrains Mono', monospace; + font-size: 1.1rem; + text-align: center; + padding: 0.5rem; + caret-color: var(--caret-color); +} + +.passive-input:focus { + border-bottom-color: var(--primary-color); +} + +.passive-stats { + margin-top: 1.5rem; +} + +.passive-summary { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + margin-top: 2rem; +} + +.passive-summary-title { + font-size: 1.3rem; + color: var(--primary-color); + text-transform: uppercase; + letter-spacing: 2px; +} + +.passive-summary-stats { + display: flex; + gap: 3rem; +} + +.passive-summary-stats > div { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; +} + +/* Fixed, matching width on both sides - "CONSISTENCY" is a much longer + label than "CHARS"/"ERR", so left to auto-size these two blocks end up + different widths, which pushes the graph between them (a flex-grow child + filling the remaining space-between gap) visibly off-center. */ +.endscreen-side-stat.wpm { + flex: 0 0 170px; + text-align: left; +} + +.endscreen-side-stat.acc { + flex: 0 0 170px; + text-align: right; +} + +.pb-badge { + font-size: 0.75rem; + letter-spacing: 0.05em; + color: var(--sub-color); + margin: -0.1rem 0 0.3rem; +} + +/* A new personal best is the payoff moment of a test and had no colour of + its own - it rendered in the same accent as every other figure on the + screen. --record-color is used here and nowhere else, so the gold reads + as "you beat something", not as decoration. */ +.pb-badge.pb-new { + color: var(--record-color); + font-weight: 700; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + font-size: 1.5rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; +} + +#root { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Typing Game */ +.stats-bar { + display: none; +} + +.end-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 1300px; + margin: 0 auto; + /* Same padding and same --header-offset margin as .typing-game + + .text-container, so the passage lands on the identical baseline on + both screens and does not jump when the test ends. This claimed to + "match .text-container exactly" while subtracting --app-padding and + using a 0.25rem pad, which put the end screen's passage 1.75rem + higher than the one the player had just been reading. */ + padding: 1rem; + margin-top: var(--header-offset); + /* min-height must subtract the margin-top above, not just be 100vh -- + margin sits outside the box, so 100vh + this margin always overflowed + the viewport by exactly the margin amount. Invisible before (body was + overflow:hidden), but a real, permanent scrollbar once that changed + to allow scrolling for genuinely tall content. */ + min-height: calc(100vh - var(--header-offset)); + position: relative; + /* Without this, Play Again sits flush against the very bottom edge of + the viewport whenever the passage/graph/time content happens to fill + most of the screen - the auto-fit cascade below still shrinks this + away first if a genuinely tall passage would otherwise force a + scrollbar, so it never fights that logic, it just guarantees normal + breathing room in the common case. */ + padding-bottom: 2rem; + box-sizing: border-box; +} + +.end-screen-buttons { + /* Normal flow, not fixed-to-viewport - fixed positioning stranded this + right at the bottom of the viewport regardless of content height, + leaving a large dead gap above it whenever the passage/graph/time + block didn't happen to fill the screen. */ + margin-top: 1.5rem; + display: flex; + gap: 1.5rem; +} + +.end-screen-button { + background: none; + border: 2px solid var(--primary-color); + color: var(--text-color); + padding: 1rem 2rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 2px; + min-width: 250px; + border-radius: 8px; +} + +.end-screen-button:hover { + background-color: var(--primary-color); + color: var(--background-color); + transform: translateY(-2px); +} + +/* Character styling */ +.correct { + color: var(--correct-color); +} + +.incorrect { + color: var(--error-color); + text-decoration: underline; +} + +.current { + /* A hardcoded rgba(0,0,0,...) darkening tint is a no-op against dark + mode's pure-black background (you can't darken black further) -- + mixing --text-color (already the correct direction per theme: dark + tint on a light background, light tint on a dark one) into + transparent instead makes this actually visible in every theme. */ + background-color: color-mix(in srgb, var(--text-color) 18%, transparent); + border-radius: 2px; +} + +.error-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} + +.error-screen h2 { + margin-bottom: 1rem; +} + +.error-screen p { + margin-bottom: 2rem; +} + +/* WPM/TIME/ACC used to each be position:fixed to a different edge of the + viewport (left-middle, bottom-center, right-middle) - three related + numbers scattered to opposite corners with the text floating alone above + a canyon of empty space between them. They're one dashboard, so they sit + in normal flow as one row directly under the text instead. */ +/* WPM sits left, ACC sits right, TIME sits bottom-center, leaving the + center/center-bottom of the screen clear - that's deliberate, not + incidental: it's the same zone the end screen's graph occupies (flanked + left/right by its own WPM/ACC), so the layout doesn't jump around when a + test finishes and the graph appears there. Don't "fix" this into one + grouped row; the empty center is reserved space, not dead space. */ +.wpm-stat { + position: fixed; + left: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +/* "Live Stats: Off" - WPM/ACC hidden while typing, revealed on the end + screen as normal. TIME stays visible since time mode needs it to know + when the test ends. */ +.typing-game.hide-live-stats .wpm-stat, +.typing-game.hide-live-stats .acc-stat { + display: none; +} + +/* "Blink Caret: Off" - the current-character caret stays solid instead of + blinking (see .text-display span.current::after's animation). */ +.typing-game.no-caret-blink .text-display span.current::after { + animation: none; +} + +.acc-stat { + position: fixed; + right: 2rem; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + width: 6rem; +} + +.time-stat { + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +/* Multiplayer: opponents' live progress during a race. Anchored below the + logo (the only thing already in the top-left corner) rather than the + center, which the typing/end screens keep empty on purpose to match the + end screen's graph zone. */ +.mp-opponents { + position: fixed; + top: 4.5rem; + left: 1rem; + z-index: 100; + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 200px; +} + +.mp-opponent-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--sub-color); +} + +.mp-opponent-name { + color: var(--racer-color, var(--sub-color)); + min-width: 4rem; + max-width: 4rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mp-opponent-bar { + flex: 1; + height: 6px; + background: var(--neutral-color); + border-radius: 3px; + overflow: hidden; +} + +/* Every racer's bar used the one accent colour, so in a five-player quick + match you could not tell which bar was whose. Each row carries its own + --racer-color (assigned in multiplayerScreen.js); you are always the theme + primary, opponents take the --player-N hues in join order. */ +.mp-opponent-bar-fill { + height: 100%; + background: var(--racer-color, var(--primary-color)); + transition: width 0.15s ease; +} + +/* Same colour key in the lobby list, so the mapping is learned before the + race starts rather than during it. */ +.leaderboard-row .mp-racer-dot { + display: inline-block; + width: 0.55rem; + height: 0.55rem; + border-radius: 2px; + margin-right: 0.5rem; + background: var(--racer-color, var(--primary-color)); + flex-shrink: 0; +} + +.mp-opponent-wpm { + min-width: 2rem; + text-align: right; +} + +/* The one genuinely time-critical state in the app: seconds left to get your + hands on the keys. It read in the same accent as every idle figure on the + screen, so it carried no urgency. --warning-color is used here and on the + device badge, nowhere else. */ +.mp-countdown { + font-size: 3rem; + color: var(--warning-color); + text-align: center; + min-height: 4rem; +} + +.stat-label { + font-size: 0.8rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +/* Custom tooltip for [data-tooltip] elements (see tooltip.js) - replaces + the browser's own native title="" popup, which can't be styled at all and + reads as an unpolished default sitting next to an otherwise fully custom + terminal-styled UI. */ +/* Floats over the page, so it has to read as a layer rather than as part of + the layout. It shared the page background with a hairline border, which + made it look like a panel that belonged where it landed - particularly + when it covered the control above the one it describes. The lifted + background and shadow separate it from whatever it overlaps. */ +.app-tooltip { + position: fixed; + background: color-mix(in srgb, var(--text-color) 10%, var(--background-color)); + color: var(--text-color); + border: 1px solid var(--primary-color); + border-radius: 4px; + padding: 0.4rem 0.6rem; + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; + max-width: 260px; + line-height: 1.4; + pointer-events: none; + z-index: 1000; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55); +} + +.chart-tooltip { + position: absolute; + display: none; + transform: translate(-50%, -100%); + margin-top: -10px; + background: rgba(20, 20, 20, 0.95); + color: #fff; + border: 1px solid var(--primary-color); + border-radius: 4px; + padding: 0.3rem 0.6rem; + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; + pointer-events: none; + white-space: nowrap; + z-index: 20; +} + +.graph-container canvas { + width: 100%; + height: 100%; +} + +.graph-axis { + position: absolute; + color: var(--text-color); + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; +} + +.graph-axis.x { + bottom: 0; + left: 2rem; + right: 2rem; + display: flex; + justify-content: space-between; + padding: 0.5rem 0; +} + +.graph-axis.y { + top: 0; + bottom: 2rem; + left: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0 0.5rem; + width: 3rem; + text-align: right; +} + +body, .app, #root { + overflow-x: hidden; + scrollbar-width: none; /* Firefox */ +} +body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +.future-modes-placeholder { + width: 100%; + min-height: 3rem; + background: transparent; + margin-top: 1.5rem; +} + +.end-screen-text { + width: 100%; + max-width: 1200px; + margin: 0 auto 0.5rem auto; + font-family: 'JetBrains Mono', monospace; + font-size: 1.2rem; + color: var(--text-color); + /* See .current's comment - a flat rgba(0,0,0,...) tint only darkens, + so it did nothing at all against dark mode's pure-black background. */ + background: color-mix(in srgb, var(--text-color) 6%, transparent); + border-radius: 6px; + /* Side padding matches .text-container's 2rem so the centred passage + lands on the same left edge as it had on the typing screen. */ + padding: 1rem 2rem; + text-align: left; + word-break: break-word; +} + +.end-screen-graph-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 1200px; + margin: 0 auto 0.5rem auto; + /* Matches .end-screen-text's own 2rem side padding, so the WPM/ACC + blocks line up with the actual text edges above them, not with the + text box's outer (padded) edge. Right side gets extra clearance on + top of that - unlike the typing screen's WPM/ACC blocks (fixed + position with their own deliberate margin), this row is a normal-flow + flex layout, so on any viewport narrower than max-width its own right + edge can land directly under the fixed corner icon rail otherwise. */ + padding: 0 2rem; + padding-right: 4.5rem; +} + +.graph-container { + flex: 1 1 0; + min-width: 0; + margin: 0; + padding: 1rem 0; + height: 240px; + position: relative; + background: color-mix(in srgb, var(--text-color) 3%, transparent); + border-radius: 8px; +} + +.end-screen-graph-row.end-screen-time-row { + display: flex; + justify-content: center; + align-items: flex-start; + margin: 0 auto 2rem auto; +} + +.end-screen-rawwpm { + width: 100%; + max-width: 900px; + margin: 0 auto 2rem auto; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1.5rem; + font-size: 1.3rem; +} +.end-screen-rawwpm .stat-label { + font-size: 1rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 2px; + margin-right: 0.5rem; +} +.end-screen-rawwpm .stat-value { + font-size: 2rem; + color: var(--primary-color); + font-weight: bold; +} + +.end-screen-time { + width: 100%; + max-width: 1100px; + margin: 0 auto; + text-align: center; + padding: 1rem 0; +} + +.end-screen-time .stat-label { + font-size: 0.9rem; + margin-bottom: 0.3rem; +} + +.end-screen-time .stat-value { + font-size: 1.5rem; +} + +/* Width-only now - this used to also trigger on short viewports (max-height: + 800px) to keep normal-flow content clear of the Play Again button, back + when that button was pinned with position:fixed and could overlap it. It's + normal flow now (see .end-screen-buttons), and the page scrolls when + content is taller than the viewport, so there's nothing left for a height + trigger to protect against - it was only ever collapsing the side-by-side + stat layout on perfectly normal laptop screens (1366x768, 1280x800 both + hit max-height:800px despite being completely ordinary resolutions). */ +@media (max-width: 900px) { + .end-screen-graph-row { + flex-direction: column !important; + align-items: stretch !important; + max-width: 100vw !important; + position: relative !important; + /* The extra right clearance is only needed for the desktop + side-by-side layout sharing horizontal space with the fixed icon + rail - stacked vertically here, nothing competes for that + space, so keep it symmetric like every other stacked block. */ + padding-right: 1.5rem !important; + } + .endscreen-side-stat { + position: static !important; + left: unset !important; + right: unset !important; + top: unset !important; + transform: none !important; + width: 100% !important; + margin-bottom: 0.75rem !important; + z-index: 10; + /* Each block is 3 label+value pairs (WPM/RAW/CONSISTENCY or + ACC/ERR/CHARS) - row direction here was flattening them into one + spread-out line (WPM ... 805 ... RAW ... 805 ...) edge to edge. + They need to stay stacked, just centered instead of the desktop + layout's left/right-anchored alignment. */ + display: flex !important; + flex-direction: column !important; + align-items: center !important; + text-align: center !important; + } + .graph-container { + flex: none !important; + max-width: 100vw !important; + min-width: 0 !important; + width: 100% !important; + overflow-x: auto !important; + margin: 0 auto 1rem auto !important; + height: 160px !important; + } + .end-screen-buttons { + position: static !important; + left: unset !important; + bottom: unset !important; + transform: none !important; + width: 100% !important; + margin: 1.5rem 0 0 0 !important; + flex-direction: column !important; + gap: 1rem !important; + z-index: 10; + } + .end-screen-time { + position: static !important; + left: unset !important; + transform: none !important; + bottom: unset !important; + width: 100% !important; + margin-top: 1rem !important; + } +} + +@media (max-width: 600px) { + .end-screen-graph-row { + padding: 0 0.5rem !important; + } + .graph-container { + height: 180px !important; + padding: 0.5rem !important; + } + .endscreen-side-stat.wpm, .endscreen-side-stat.acc { + font-size: 1rem !important; + padding: 0.5rem !important; + } + .end-screen-time .stat-value { + font-size: 1.2rem !important; + } +} + +@media (max-width: 900px) { + .text-container { + padding: 0.5rem !important; + min-height: 120px !important; + } +} +@media (max-width: 600px) { + .text-container { + min-height: 80px !important; + font-size: 1rem !important; + } +} + +@media (max-width: 700px) { + :root { + --header-offset: 2.5rem; + } + body, #root, .app, .end-screen { + overflow-y: auto !important; + overflow-x: hidden !important; + } + .end-screen { + min-height: 0 !important; + height: auto !important; + padding: 0.5rem 0.75rem !important; + padding-top: var(--header-offset) !important; /* align with game text */ + padding-bottom: 0.5rem !important; + display: flex !important; + flex-direction: column !important; + } + .end-screen-buttons { + margin-top: auto !important; + margin-bottom: 0 !important; + width: 100% !important; + } +} +/* Room code + Join on one line: they are a single action split across two + controls, and stacking them read as two unrelated steps. */ +.mp-code-row { + display: flex; + gap: 0.5rem; + width: 100%; + align-items: stretch; +} + +.mp-code-row .account-input { + flex: 1 1 auto; + min-width: 0; +} + +.mp-code-row .menu-button { + flex: 0 0 auto; + white-space: nowrap; +} + +/* The passage's attribution, repeated on the end screen so a quote still + says who wrote it once the test is over. Same centred look as the typing + screen's, just tighter - the graph row follows immediately below. */ +.end-screen-attribution { + max-width: 1200px; + margin: 0.4rem auto 0.5rem auto; + padding: 0 2rem; +} + +/* Rules and Typing side by side. Stacked, the panel ran past the bottom of a + 900px viewport and had to be scrolled to reach the last toggle, with every + row an identically-weighted full-width button. */ +.settings-columns { + display: flex; + gap: 1.25rem; + width: 100%; + align-items: flex-start; +} + +.settings-column { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1 1 0; + min-width: 0; +} + +.settings-column .menu-button.small { + min-width: 0; + width: 100%; +} + +/* One column on narrow windows, where side-by-side would squeeze the labels. */ +@media (max-width: 700px) { + .settings-columns { + flex-direction: column; + gap: 0.5rem; + } +} + +/* Correct/incorrect split under a headline figure - the keystroke count is + the interesting number, the breakdown is the footnote. */ +.stat-breakdown { + display: block; + font-size: 0.7rem; + color: var(--sub-color); + letter-spacing: 1px; + margin-top: 0.15rem; +} + + +/* The top-right rail: typing language, theme, and identity. Everything here + configures the app or says who you are, as opposed to the bottom rail's + navigation. Screens with their own top-right controls (Restart, Zen Finish) + hand them in as `extras` so they join this row rather than fixed-positioning + themselves into it. */ +.top-rail { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 100; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.6rem; +} + +.top-rail .theme-control, +.top-rail .corner-icon-button, +.top-rail .restart-toggle, +.top-rail .zen-finish-button { + position: static; +} + +.lang-group { + position: relative; +} + +/* Opens downward from a top-anchored control, unlike the bottom rail's + Settings panel which opens upward. */ +.lang-popover { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + background: var(--background-color); + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 0.5rem; + width: 210px; + display: flex; + flex-direction: column; + gap: 0.15rem; + z-index: 120; + /* Scrolls once the list outgrows the viewport - the heading and the note + stay put because only the item list between them scrolls. */ + max-height: calc(100vh - 6rem); + overflow-y: auto; +} + +.lang-heading { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--sub-color); + padding: 0.25rem 0.6rem 0.35rem; + border-bottom: 1px solid var(--neutral-color); + margin-bottom: 0.25rem; +} + +.lang-item { + background: none; + border: none; + color: var(--text-color); + font-family: inherit; + font-size: 0.9rem; + text-align: left; + padding: 0.4rem 0.6rem; + border-radius: 4px; + cursor: pointer; +} + +.lang-item:hover { + background: rgba(128, 128, 128, 0.15); +} + +.lang-item.active { + color: var(--primary-color); + font-weight: bold; +} + +.lang-note { + font-size: 0.7rem; + color: var(--sub-color); + padding: 0.45rem 0.6rem 0.15rem; + line-height: 1.35; +} + +.auth-control { + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Text links, not bordered boxes - three outlined squares plus two outlined + buttons in one row was far too much chrome for a corner. */ +.top-rail-link { + background: none; + border: none; + color: var(--sub-color); + font-family: inherit; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + padding: 0.5rem 0.4rem; +} + +.top-rail-link:hover { + color: var(--text-color); +} + +.top-rail-link.accent, +.top-rail-link.signed-in { + color: var(--primary-color); +} + +.top-rail-link.accent:hover, +.top-rail-link.signed-in:hover { + color: var(--secondary-color); +} + +/* Live counts on the Friends and Multiplayer icons. Positioned against the + button's own box, which is why those buttons opt in with .has-badge. */ +.corner-icon-button.has-badge { + position: relative; + overflow: visible; +} + +.corner-rail-left .corner-icon-button.has-badge, +.corner-rail-right .corner-icon-button.has-badge { + position: relative; +} + +.rail-badge { + position: absolute; + top: -0.45rem; + right: -0.45rem; + min-width: 1.05rem; + height: 1.05rem; + padding: 0 0.2rem; + border-radius: 0.55rem; + background: var(--background-color); + border: 1px solid var(--neutral-color); + color: var(--sub-color); + font-family: 'JetBrains Mono', monospace; + font-size: 0.6rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +/* People available to race right now is the one count worth drawing the eye. */ +.rail-badge.accent { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.mp-caption { + color: var(--primary-color); +} + + +.friends-control { + display: flex; + align-items: center; +} + + +/* Keyboard shortcuts, at the foot of the menu rather than wedged between the + title and the buttons where it competed with them for attention. */ +.menu-key-hint { + position: fixed; + bottom: 1.25rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.75rem; + color: var(--sub-color); + letter-spacing: 1px; + z-index: 90; +} + + +/* Secondary figures in one row under the graph, the way 10FastFingers and + MonkeyType both lay them out. Stacked in the two side columns they were a + dim 1.2rem list flanking the chart, competing with WPM and ACC for the same + space and hard to read at a glance. The side columns now carry only the two + headline numbers. */ +.end-screen-stat-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + gap: 2.5rem; + margin-top: 1.25rem; + width: 100%; + max-width: 1200px; + padding: 0 2rem; +} + +.end-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.end-stat .stat-label { + font-size: 0.7rem; + letter-spacing: 1px; + color: var(--sub-color); +} + +.end-stat .stat-value { + font-size: 1.35rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; +} + +@media (max-width: 900px) { + .end-screen-stat-row { + gap: 1.25rem 1.75rem; + padding: 0 1rem; + } +} diff --git a/web/src/theme.js b/web/src/theme.js new file mode 100644 index 0000000..686572b --- /dev/null +++ b/web/src/theme.js @@ -0,0 +1,66 @@ +export const Theme = { Light: 'light', Dark: 'dark' }; + +// Full theme catalog for the gallery. Light/Dark stay the two the corner +// toggle button cycles between; the rest are only reachable from the +// gallery, matching how most typing sites treat "light vs dark" as the +// quick switch and everything else as a deliberate pick. +export const THEMES = [ + { id: 'dark', label: 'Dark', dark: true, primary: '#00ff9d' }, + { id: 'light', label: 'Light', dark: false, primary: '#067a4e' }, + { id: 'dracula', label: 'Dracula', dark: true, primary: '#bd93f9' }, + { id: 'nord', label: 'Nord', dark: true, primary: '#88c0d0' }, + { id: 'solarized', label: 'Solarized', dark: true, primary: '#268bd2' }, + { id: 'gruvbox', label: 'Gruvbox', dark: true, primary: '#fabd2f' }, + { id: 'monokai', label: 'Monokai', dark: true, primary: '#a6e22e' }, +]; + +export function isDarkTheme(id) { + return THEMES.find(t => t.id === id)?.dark ?? true; +} + +const STORAGE_KEY = 'theme'; +const listeners = new Set(); + +let theme = localStorage.getItem(STORAGE_KEY) || Theme.Dark; + +// The custom cursor is a per-theme accent, not a fixed brand mark - a +// hardcoded color would sit oddly on Dracula/Nord/etc, so it's regenerated +// from each theme's own primary color instead. Plus-reticle crosshair, not a +// circle - the rest of the UI (buttons, icons.js's angular sun/moon) is all +// sharp corners, and a round cursor read as off-brand next to it. The gap at +// the center (arms don't meet) is what makes it read as a scope reticle +// rather than a plain plus sign. +function cursorDataUri(color) { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path d="M10 1V8 M10 12V19 M1 10H8 M12 10H19" stroke="${color}" stroke-width="1.6"/><rect x="9.3" y="9.3" width="1.4" height="1.4" fill="${color}"/></svg>`; + return `url("data:image/svg+xml;base64,${btoa(svg)}") 10 10, auto`; +} + +function apply(value) { + document.documentElement.setAttribute('data-theme', value); + if (document.body) { + const t = THEMES.find(x => x.id === value); + document.body.style.cursor = cursorDataUri(t ? t.primary : '#00ff9d'); + } +} +apply(theme); + +export function getTheme() { + return theme; +} + +export function setTheme(id) { + if (!THEMES.some(t => t.id === id)) return; + theme = id; + localStorage.setItem(STORAGE_KEY, theme); + apply(theme); + for (const fn of listeners) fn(theme); +} + +export function toggleTheme() { + setTheme(theme === Theme.Light ? Theme.Dark : Theme.Light); +} + +export function onThemeChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} diff --git a/web/src/themeButton.js b/web/src/themeButton.js new file mode 100644 index 0000000..81ac64e --- /dev/null +++ b/web/src/themeButton.js @@ -0,0 +1,70 @@ +import { getTheme, setTheme, toggleTheme, onThemeChange, THEMES } from './theme.js'; +import { themeIcon } from './screens/icons.js'; +import { escapeHtml } from './util.js'; +import { attachTooltips } from './tooltip.js'; + +const HOLD_MS = 450; + +// Every screen showed a theme button advertising "Hold for more themes" in +// its tooltip, but only the main menu actually wired up the hold gesture -- +// everywhere else it was a plain click-only toggle, so the tooltip was +// lying. One shared implementation instead of six copies of the same +// click/hold/popover logic, so the behavior can't drift out of sync again. +export function renderThemeButton(root) { + const wrap = document.createElement('div'); + wrap.className = 'theme-control'; + wrap.innerHTML = ` + <button class="theme-toggle" data-tooltip="Hold for more themes" aria-label="Toggle theme"></button> + <div class="theme-gallery-popover" hidden> + <div class="theme-gallery"> + ${THEMES.map(t => ` + <button class="theme-swatch${getTheme() === t.id ? ' active' : ''}" data-theme-id="${t.id}"> + <span class="theme-swatch-preview" data-swatch="${t.id}"></span> + <span class="theme-swatch-label">${escapeHtml(t.label)}</span> + </button> + `).join('')} + </div> + </div> + `; + root.appendChild(wrap); + attachTooltips(wrap); + + const button = wrap.querySelector('.theme-toggle'); + const popover = wrap.querySelector('.theme-gallery-popover'); + const paint = () => { button.innerHTML = themeIcon(getTheme()); }; + paint(); + const unsubscribeTheme = onThemeChange(paint); + + let holdTimer = null; + let heldOpen = false; + button.addEventListener('pointerdown', () => { + heldOpen = false; + holdTimer = setTimeout(() => { heldOpen = true; popover.hidden = false; }, HOLD_MS); + }); + const cancelHold = () => clearTimeout(holdTimer); + button.addEventListener('pointerup', cancelHold); + button.addEventListener('pointerleave', cancelHold); + button.addEventListener('click', () => { + if (heldOpen) { heldOpen = false; return; } + toggleTheme(); + }); + + wrap.querySelectorAll('.theme-swatch').forEach(swatch => { + swatch.addEventListener('click', () => { + setTheme(swatch.dataset.themeId); + wrap.querySelectorAll('.theme-swatch').forEach(b => b.classList.toggle('active', b === swatch)); + popover.hidden = true; + }); + }); + + const handleOutsideClick = e => { + if (!popover.hidden && !wrap.contains(e.target)) popover.hidden = true; + }; + document.addEventListener('click', handleOutsideClick); + + return () => { + unsubscribeTheme(); + document.removeEventListener('click', handleOutsideClick); + wrap.remove(); + }; +} diff --git a/web/src/tooltip.js b/web/src/tooltip.js new file mode 100644 index 0000000..e432ac3 --- /dev/null +++ b/web/src/tooltip.js @@ -0,0 +1,66 @@ +// Custom-styled tooltip, replacing the browser's own native title="" popup -- +// that one can't be styled at all (OS-drawn box, inconsistent font/delay +// across browsers), which reads as an unpolished, "browser default" element +// sitting next to an otherwise fully custom UI. +let tooltipEl = null; + +function getTooltipEl() { + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.className = 'app-tooltip'; + tooltipEl.hidden = true; + document.body.appendChild(tooltipEl); + } + return tooltipEl; +} + +// Short delay before showing. Without one, sweeping the pointer across the +// icon rail fires a tooltip per icon passed over, which flickers. +const SHOW_DELAY_MS = 350; +let showTimer = null; + +function showTooltip(target) { + const text = target.dataset.tooltip; + if (!text) return; + const tip = getTooltipEl(); + tip.textContent = text; + tip.hidden = false; + + const rect = target.getBoundingClientRect(); + const tipRect = tip.getBoundingClientRect(); + let left = rect.left + rect.width / 2 - tipRect.width / 2; + left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8)); + let top = rect.top - tipRect.height - 8; + if (top < 8) top = rect.bottom + 8; + tip.style.left = `${left}px`; + tip.style.top = `${top}px`; +} + +function scheduleTooltip(target) { + clearTimeout(showTimer); + showTimer = setTimeout(() => showTooltip(target), SHOW_DELAY_MS); +} + +function hideTooltip() { + clearTimeout(showTimer); + if (tooltipEl) tooltipEl.hidden = true; +} + +// Wires every [data-tooltip] element under root - call once after the +// screen's markup is in the DOM. Hides on scroll too, since a fixed-position +// tooltip left open would otherwise drift away from the element it's +// pointing at. +export function attachTooltips(root) { + root.querySelectorAll('[data-tooltip]').forEach(el => { + el.addEventListener('mouseenter', () => scheduleTooltip(el)); + el.addEventListener('mouseleave', hideTooltip); + // Acting on a control dismisses its tooltip. Without this it stays up + // over whatever the click just opened - the mode popover, or the + // control sitting above the button that was pressed. + el.addEventListener('mousedown', hideTooltip); + el.addEventListener('click', hideTooltip); + el.addEventListener('focus', () => showTooltip(el)); + el.addEventListener('blur', hideTooltip); + }); + window.addEventListener('scroll', hideTooltip, { capture: true, passive: true }); +} diff --git a/web/src/topRail.js b/web/src/topRail.js new file mode 100644 index 0000000..b6b50d7 --- /dev/null +++ b/web/src/topRail.js @@ -0,0 +1,114 @@ +import { LANGUAGE_ICON, FRIENDS_ICON } from './screens/icons.js'; +import { LANGUAGES, languageLabel } from './languages.js'; +import { getSettings, updateSettings } from './settings.js'; +import { renderThemeButton } from './themeButton.js'; +import { getUser, onAuthChange } from './auth.js'; +import { onCountsChange, getCounts } from './counts.js'; +import { attachTooltips } from './tooltip.js'; +import { escapeHtml } from './util.js'; + +// The top-right cluster: the typing language, the theme, and who you are -- +// Friends sits directly beside the account control because the two are the +// same thing, your identity and who it connects you to. These are the app's own chrome -- +// they belong together and away from the bottom rail's per-screen +// navigation. Screens with their own top-right controls (the typing screen's +// Restart and Zen Finish) pass them in as `extras` so everything shares one +// row instead of each fixed-positioning itself into a collision. +export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] } = {}) { + const wrap = document.createElement('div'); + wrap.className = 'top-rail'; + root.appendChild(wrap); + + for (const el of extras) wrap.appendChild(el); + + const langGroup = document.createElement('div'); + langGroup.className = 'lang-group'; + langGroup.innerHTML = ` + <button class="corner-icon-button" data-action="lang" aria-label="Typing language" data-tooltip="Typing language">${LANGUAGE_ICON}</button> + <div class="lang-popover" hidden> + <div class="lang-heading">Typing language</div> + ${LANGUAGES.map(l => ` + <button class="lang-item" data-lang="${escapeHtml(l.id)}">${escapeHtml(l.label)}</button> + `).join('')} + <div class="lang-note">Applies to Words, Timed, Zen and Practice.</div> + </div> + `; + wrap.appendChild(langGroup); + + const cleanupTheme = renderThemeButton(wrap); + + const friends = document.createElement('div'); + friends.className = 'friends-control'; + if (onShowFriends) { + friends.innerHTML = `<button class="corner-icon-button has-badge" data-action="rail-friends" aria-label="Friends" data-tooltip="Friends">${FRIENDS_ICON}<span class="rail-badge" data-badge="friends" hidden></span></button>`; + } + wrap.appendChild(friends); + + const auth = document.createElement('div'); + auth.className = 'auth-control'; + wrap.appendChild(auth); + + attachTooltips(wrap); + + // Language ------------------------------------------------------------ + const langPopover = langGroup.querySelector('.lang-popover'); + function paintLang() { + const current = getSettings().language || 'en'; + langGroup.querySelectorAll('.lang-item').forEach(item => { + item.classList.toggle('active', item.dataset.lang === current); + }); + langGroup.querySelector('[data-action="lang"]').dataset.tooltip = `Typing language: ${languageLabel(current)}`; + } + paintLang(); + langGroup.querySelector('[data-action="lang"]').addEventListener('click', e => { + e.stopPropagation(); + langPopover.hidden = !langPopover.hidden; + }); + langGroup.querySelectorAll('.lang-item').forEach(item => { + item.addEventListener('click', () => { + updateSettings({ language: item.dataset.lang }); + paintLang(); + langPopover.hidden = true; + }); + }); + + // Identity ------------------------------------------------------------ + function paintAuth() { + const user = getUser(); + auth.innerHTML = user + ? `<button class="top-rail-link signed-in" data-action="account" data-tooltip="Your account">${escapeHtml(user.username)}</button>` + : `<button class="top-rail-link" data-action="signin" data-tooltip="Sign in to save stats and join the leaderboard">Sign In</button> + <button class="top-rail-link accent" data-action="signup" data-tooltip="Create an account">Sign Up</button>`; + attachTooltips(auth); + auth.querySelectorAll('button').forEach(btn => { + btn.addEventListener('click', () => onShowAccount?.()); + }); + } + paintAuth(); + const unsubscribeAuth = onAuthChange(paintAuth); + + // Friends ------------------------------------------------------------ + const friendsBtn = friends.querySelector('[data-action="rail-friends"]'); + if (friendsBtn) friendsBtn.addEventListener('click', onShowFriends); + const friendsBadge = friends.querySelector('[data-badge="friends"]'); + function paintCounts({ friends: friendCount }) { + if (!friendsBadge) return; + friendsBadge.hidden = !friendCount; + friendsBadge.textContent = String(friendCount); + } + paintCounts(getCounts()); + const unsubscribeCounts = onCountsChange(paintCounts); + + const handleOutsideClick = e => { + if (!langPopover.hidden && !langGroup.contains(e.target)) langPopover.hidden = true; + }; + document.addEventListener('click', handleOutsideClick); + + return () => { + unsubscribeCounts(); + document.removeEventListener('click', handleOutsideClick); + unsubscribeAuth(); + cleanupTheme(); + wrap.remove(); + }; +} diff --git a/web/src/util.js b/web/src/util.js new file mode 100644 index 0000000..65c718b --- /dev/null +++ b/web/src/util.js @@ -0,0 +1,5 @@ +const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +export function escapeHtml(str) { + return str.replace(/[&<>"']/g, c => ESCAPES[c]); +} diff --git a/web/src/wordGenerator.js b/web/src/wordGenerator.js new file mode 100644 index 0000000..d97ea8e --- /dev/null +++ b/web/src/wordGenerator.js @@ -0,0 +1,185 @@ +import { languageWords } from './languages.js'; + +const COMMON_WORDS = [ + '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', +]; + +// Longer, less-frequent words - layered on top of COMMON_WORDS for the +// "extended" tier rather than replacing it, so that tier is strictly harder +// (more variety, longer words) instead of just different. +const EXTENDED_WORDS = [ + 'available', 'benefit', 'community', 'consider', 'determine', 'discover', 'economic', 'establish', + 'evidence', 'example', 'experience', 'feature', 'function', 'general', 'government', 'however', + 'important', 'include', 'increase', 'individual', 'information', 'international', 'involve', 'language', + 'material', 'measure', 'medical', 'military', 'natural', 'necessary', 'opportunity', 'organization', + 'particular', 'perform', 'physical', 'political', 'population', 'position', 'positive', 'possible', + 'practice', 'prepare', 'present', 'pressure', 'probably', 'produce', 'product', 'program', + 'protect', 'provide', 'quality', 'question', 'reality', 'receive', 'recognize', 'record', + 'relationship', 'remember', 'require', 'resource', 'response', 'result', 'security', 'several', + 'significant', 'similar', 'situation', 'social', 'society', 'special', 'specific', 'standard', + 'structure', 'suggest', 'support', 'system', 'technology', 'therefore', 'together', 'traditional', + 'training', 'travel', 'treatment', 'understand', 'university', 'various', 'through', 'without', +]; + +// Uncommon, technical, or irregularly-spelled words - deliberately picks +// for awkward letter combinations and length rather than just obscurity, so +// "hard" is actually harder to type, not just harder to recognize. +const HARD_WORDS = [ + 'rhythm', 'bureaucracy', 'conscience', 'entrepreneur', 'idiosyncrasy', 'juxtaposition', 'onomatopoeia', + 'paradigm', 'pharaoh', 'phenomenon', 'protagonist', 'quintessential', 'silhouette', 'subtlety', + 'synchronize', 'unprecedented', 'vacuum', 'wednesday', 'xylophone', 'zeitgeist', 'acquaintance', + 'archipelago', 'camaraderie', 'chrysanthemum', 'circumference', 'colonel', 'conscientious', 'exacerbate', + 'exaggerate', 'extraordinary', 'gargantuan', 'hierarchical', 'hypothesis', 'inconsequential', 'labyrinth', + 'maneuver', 'mischievous', 'nostalgia', 'omniscient', 'perpendicular', 'questionnaire', 'reconnaissance', + 'rendezvous', 'sovereignty', 'surveillance', 'threshold', 'tuberculosis', 'ubiquitous', 'vicissitude', + 'wherewithal', +]; + +const WORD_POOLS = { + common: COMMON_WORDS, + extended: [...COMMON_WORDS, ...EXTENDED_WORDS], + hard: [...EXTENDED_WORDS, ...HARD_WORDS], +}; + +export function wordListTiers() { + return Object.keys(WORD_POOLS); +} + +// The tier ladder (common/extended/hard) is built from the English lists, so +// it only applies to English. Every other language ships one high-frequency +// list, and asking for a harder tier in Spanish must not silently drop you +// back into English words - so a non-English language uses its own list at +// every tier rather than falling through to WORD_POOLS. +function poolFor(tier, language) { + if (language && language !== 'en') return languageWords(language); + return WORD_POOLS[tier] || COMMON_WORDS; +} + +function pick(tier, language) { + const pool = poolFor(tier, language); + return pool[Math.floor(Math.random() * pool.length)]; +} + +function capitalize(word) { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +// Builds a random passage of the given word count, MonkeyType-style: a flat +// stream of common words with no fixed meaning, used for count- or +// time-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". +export function generateWordStream(count, options = {}) { + const { punctuation = false, numbers = false, tier = 'common', language = 'en' } = options; + const words = []; + let sinceComma = 0; + let sinceSentenceStart = 0; + for (let i = 0; i < count; i++) { + let word = numbers && Math.random() < 0.12 + ? String(Math.floor(Math.random() * 1000)) + : pick(tier, language); + + if (punctuation) { + if (sinceSentenceStart === 0) word = capitalize(word); + sinceComma++; + sinceSentenceStart++; + + const atEnd = i === count - 1; + if (atEnd) { + word += '.'; + } else if (sinceSentenceStart >= 6 && Math.random() < 0.15) { + word += '.'; + sinceSentenceStart = 0; + } else if (sinceComma >= 4 && Math.random() < 0.2) { + word += ','; + sinceComma = 0; + } + } + + words.push(word); + } + return words.join(' '); +} + +// Weighted toward words containing the caller's weak characters (see +// keyStats.js) instead of a flat random pick - a word with 3 weak letters +// is far more likely to come up than one with none, but every word still +// has some chance (the +1 baseline), so the output stays readable text +// instead of a narrow loop of the same few words. +function pickWeighted(pool, weakSet) { + const weights = pool.map(word => { + let score = 1; + for (const ch of word) if (weakSet.has(ch)) score += 2; + return score; + }); + const total = weights.reduce((a, b) => a + b, 0); + let roll = Math.random() * total; + for (let i = 0; i < pool.length; i++) { + roll -= weights[i]; + if (roll <= 0) return pool[i]; + } + return pool[pool.length - 1]; +} + +// Falls back to a plain generateWordStream when there isn't enough data +// yet to know what's actually hard for this person (a brand-new install, +// or someone who hasn't typed enough for MIN_SAMPLES to kick in). +export function generateWeakKeyStream(count, weakChars, options = {}) { + if (!weakChars || weakChars.length === 0) return generateWordStream(count, options); + const { punctuation = false, numbers = false } = options; + const weakSet = new Set(weakChars.map(c => c.toLowerCase())); + const pool = WORD_POOLS.extended; + + const words = []; + let sinceComma = 0; + let sinceSentenceStart = 0; + for (let i = 0; i < count; i++) { + let word = numbers && Math.random() < 0.12 + ? String(Math.floor(Math.random() * 1000)) + : pickWeighted(pool, weakSet); + + if (punctuation) { + if (sinceSentenceStart === 0) word = capitalize(word); + sinceComma++; + sinceSentenceStart++; + const atEnd = i === count - 1; + if (atEnd) { + word += '.'; + } else if (sinceSentenceStart >= 6 && Math.random() < 0.15) { + word += '.'; + sinceSentenceStart = 0; + } else if (sinceComma >= 4 && Math.random() < 0.2) { + word += ','; + sinceComma = 0; + } + } + words.push(word); + } + return words.join(' '); +} + +// 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. +export function wordCountForDuration(seconds) { + return Math.max(20, Math.round(seconds * 6)); +} diff --git a/web/tests/README.md b/web/tests/README.md new file mode 100644 index 0000000..fadc5b9 --- /dev/null +++ b/web/tests/README.md @@ -0,0 +1,42 @@ +# TyperPunk web test suite + +Playwright end-to-end tests covering the major user-facing flows: typing a +test through to the end screen, account/store/cosmetics, and +leaderboard/friends. These replace the ad-hoc scripts written into `/tmp` +during development - checked in here so they survive past one session and +can be re-run for regressions. + +## Prerequisites + +- Python 3 with the `playwright` package installed (`pip install + playwright && playwright install chromium`, or on Arch, + `pacman -S python-playwright`). +- The web app's dev server running on port 4173: + `cd web && node serve.mjs` +- The backend running on port 8787, pointed at a throwaway database (do not + run tests against a production database): + `DATABASE_URL="sqlite:///tmp/typerpunk_test.db" PORT=8787 COOKIE_SECURE=0 \ + cargo run --package typerpunk-server` + +Both servers must already be running before starting the suite - it does +not launch or manage them itself. + +## Running + +``` +cd web/tests +python3 run_all.py +``` + +Each `test_*.py` file exposes a single `run()` function that raises +`AssertionError` on failure. `run_all.py` imports and calls each one in +turn, continuing after a failure so one broken test doesn't hide the +others, and exits non-zero if anything failed. + +To run a single file directly: `python3 test_typing_flow.py`. + +## Adding a test + +Add a new `test_*.py` file with a `run()` function, using Playwright's sync +API (see the existing files for the pattern). `run_all.py` discovers files +by the `test_*.py` name, so no registration step is needed. diff --git a/web/tests/helpers.py b/web/tests/helpers.py new file mode 100644 index 0000000..8aca3f6 --- /dev/null +++ b/web/tests/helpers.py @@ -0,0 +1,38 @@ +"""Shared helpers for the Playwright test suite. See README.md for how to +run these tests and what needs to already be running. +""" +import random +import string + +FRONTEND_URL = "http://localhost:4173" + + +def random_username(prefix="test"): + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + return f"{prefix}_{suffix}" + + +def random_password(): + """A fresh password per run. These accounts are throwaway, so nothing is + gained by a fixed literal - and a hardcoded one in a checked-in file has + the shape of a real credential, which trips secret scanners.""" + return "Tp" + "".join(random.choices(string.ascii_letters + string.digits, k=16)) + + +def register_and_login(page, username, password=None): + """Registers a fresh account through the real Account screen and leaves + the app on the main menu, signed in.""" + password = password or random_password() + page.goto(FRONTEND_URL, timeout=40000) + # The account entry point is the top-right identity control: "Sign In" + # when signed out, your username once signed in. It used to be an icon in + # the main menu's bottom-left cluster. + page.click('.auth-control button') + page.wait_for_timeout(300) + page.click('[data-action="tab-register"]') + page.fill('input[name="username"]', username) + page.fill('input[name="password"]', password) + page.click('.account-form button[type="submit"]') + page.wait_for_selector('.account-signed-in', timeout=10000) + page.click('[data-action="menu"]') + page.wait_for_timeout(300) diff --git a/web/tests/run_all.py b/web/tests/run_all.py new file mode 100644 index 0000000..91064da --- /dev/null +++ b/web/tests/run_all.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Runs every test_*.py file in this directory and reports pass/fail. See +README.md for prerequisites (both servers must already be running). +""" +import glob +import importlib +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def main(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + files = sorted(glob.glob(os.path.join(test_dir, "test_*.py"))) + if not files: + print("No test_*.py files found.") + return 1 + + failures = [] + for path in files: + name = os.path.splitext(os.path.basename(path))[0] + module = importlib.import_module(name) + try: + module.run() + except Exception: + failures.append(name) + print(f"{name}: FAIL") + traceback.print_exc() + + total = len(files) + passed = total - len(failures) + print(f"\n{passed}/{total} passed") + if failures: + print("Failed: " + ", ".join(failures)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py new file mode 100644 index 0000000..ce1b9c9 --- /dev/null +++ b/web/tests/test_account_and_store.py @@ -0,0 +1,56 @@ +"""Account registration, and the cosmetics store's buy/equip flow -- +including that an equipped caret color and flair icon actually show up, +not just that the API calls succeed. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL, random_username, register_and_login + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page = browser.new_page(viewport={"width": 1280, "height": 900}) + username = random_username("store") + register_and_login(page, username) + assert page.locator('.auth-control button').count() > 0 + + page.click('[data-action="store"]') + page.wait_for_selector(".store-item-row", timeout=10000) + rows = page.locator(".store-item-row") + assert rows.count() > 0, "store catalog did not render" + + def buy_and_equip(item_name): + row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name)) + buy_btn = row.locator('[data-action="buy"]') + if buy_btn.count() > 0: + buy_btn.click() + page.wait_for_timeout(500) + row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name)) + row.locator('[data-action="equip"]').click(timeout=10000) + page.wait_for_timeout(500) + + buy_and_equip("Cyan Caret") + buy_and_equip("Bolt") + + equipped_rows = page.locator(".store-item-row", has=page.get_by_text("EQUIPPED")) + assert equipped_rows.count() >= 2, "expected caret and flair to both show as equipped" + + # Equipped caret color should now override --caret-color on the + # typing screen itself, not just in the store's own UI. + page.click('[data-action="menu"]') + page.wait_for_timeout(300) + page.click('[data-action="start"]') + page.wait_for_timeout(500) + caret_color = page.evaluate( + "() => getComputedStyle(document.querySelector('.typing-game')).getPropertyValue('--caret-color').trim()" + ) + assert caret_color.lower() == "#00e5ff", f"expected cyan caret override, got {caret_color!r}" + + print("test_account_and_store: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() diff --git a/web/tests/test_leaderboard_and_friends.py b/web/tests/test_leaderboard_and_friends.py new file mode 100644 index 0000000..fc8de3e --- /dev/null +++ b/web/tests/test_leaderboard_and_friends.py @@ -0,0 +1,60 @@ +"""Leaderboard mode/device filtering, and the friends request -> accept +flow between two real accounts. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL, random_username, register_and_login + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page_a = browser.new_page(viewport={"width": 1280, "height": 900}) + page_b = browser.new_page(viewport={"width": 1280, "height": 900}) + + user_a = random_username("friend_a") + user_b = random_username("friend_b") + register_and_login(page_a, user_a) + register_and_login(page_b, user_b) + + # Leaderboard: mode picker and device filter both render and + # don't error, even with no results yet for a fresh mode. + page_a.click('[data-action="leaderboard"]') + page_a.wait_for_selector(".leaderboard-modes", timeout=10000) + page_a.click('[data-action="toggle-device-filter"]') + page_a.wait_for_timeout(400) + # Case-insensitive: this button's text may render uppercase via + # CSS text-transform, which inner_text() reflects. + assert "desktop only" in page_a.locator('[data-action="toggle-device-filter"]').inner_text().lower() + assert page_a.locator(".stats-empty, .leaderboard-row").count() > 0, "leaderboard body did not render" + page_a.click('[data-action="menu"]') + page_a.wait_for_timeout(300) + + # Friends: A sends a request to B, B accepts, both sides settle + # into the Friends list. + page_a.click('[data-action="rail-friends"]') + page_a.wait_for_selector(".friends-add-form", timeout=10000) + page_a.fill('.friends-add-form input[name="username"]', user_b) + page_a.click('.friends-add-form button[type="submit"]') + page_a.wait_for_timeout(600) + + page_b.click('[data-action="rail-friends"]') + page_b.wait_for_selector('[data-action="accept"]', timeout=10000) + page_b.click('[data-action="accept"]') + page_b.wait_for_timeout(600) + + assert page_b.locator(".leaderboard-row", has_text=user_a).count() > 0, "B does not see A as a friend" + + page_a.reload() + page_a.wait_for_timeout(500) + page_a.click('[data-action="rail-friends"]') + page_a.wait_for_timeout(600) + assert page_a.locator(".leaderboard-row", has_text=user_b).count() > 0, "A does not see B as a friend" + + print("test_leaderboard_and_friends: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() diff --git a/web/tests/test_typing_flow.py b/web/tests/test_typing_flow.py new file mode 100644 index 0000000..251ff56 --- /dev/null +++ b/web/tests/test_typing_flow.py @@ -0,0 +1,54 @@ +"""Core golden path: main menu loads, a typing test can be started, typed +characters show correct/incorrect feedback and live stats, and finishing +reaches the end screen with a results graph. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(FRONTEND_URL, timeout=40000) + + assert page.locator(".main-menu").is_visible(), "main menu did not render" + # inner_text() reflects CSS text-transform (this heading may be + # rendered uppercase) - compare case-insensitively rather than + # asserting on the exact source casing. + assert "typerpunk" in page.locator(".main-menu h1").inner_text().lower() + + page.click('[data-action="start"]') + page.wait_for_selector(".typing-game", timeout=10000) + + input_el = page.locator(".typing-input") + input_el.click() + page.keyboard.type("abcdefghij", delay=30) + page.wait_for_timeout(300) + + wpm_text = page.locator('[data-field="wpm"]').inner_text() + assert wpm_text.strip() != "", "live WPM stat did not render" + + classes = page.locator(".text-display span[class]").first.get_attribute("class") + assert classes is not None, "typed characters have no highlight class" + + # Finish the test by typing enough characters to exhaust the + # visible text sample - Tab is the quick-restart shortcut, not + # a way to finish, so this has to actually type to completion. + text_len = len(page.locator(".text-display").inner_text().replace("\n", " ")) + remaining = max(0, text_len - 10) + if remaining > 0: + page.keyboard.type("a" * remaining, delay=1) + + page.wait_for_selector(".end-screen", timeout=15000) + assert page.locator(".end-screen-graph-row canvas").is_visible(), "results graph did not render" + assert page.locator(".end-screen .stat-value").first.is_visible(), "end screen stats missing" + + print("test_typing_flow: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() |