srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates/server/src
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz
typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer - Quick match: POST /api/multiplayer/quickmatch returns whichever room is still filling, or opens one. Players never see a room code; joining by code stays for racing specific people. - Bots fill quick-match rooms after a short wait so a new game is never an empty lobby. They only ever join quick-match rooms, never a room opened by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so two bots are never near each other's pace, and they stall to correct mistakes rather than typing a clean straight line. - Live player count via GET /api/multiplayer/online, shown on the Multiplayer control and under the main menu's Multiplayer button. - Per-racer colours: you are the theme accent, opponents take distinct hues that stay the same from lobby to race. - The countdown no longer holds the room lock for its full three seconds, which is what reset clients mid-countdown. Typing languages - 16 languages for the generated-word modes, each with its own high-frequency vocabulary rather than a translation of the English list. - Picker in the top-right rail; non-English uses its own list at every difficulty tier instead of falling back to English words. Fix UTF-8 accuracy in the game core - update_game_state mixed byte and character counts: total_characters_typed accumulated byte-length deltas while total_correct_characters compared a char index against that byte count. Equal on ASCII, so it went unnoticed; a correctly typed Spanish passage scored 6%. The old byte slicing would also have panicked if an index landed inside a multi-byte character. Rewritten char-based, with regression tests. Programming mode - Replaced prose about programming with real code: 26 syntax-highlighted snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line by necessity, since the typing input is a single-line field. Layout and readability - One icon rail arrangement on every screen: Settings/Store under the wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/ Multiplayer bottom-right. - Main menu: mode picker moved out of the Single Player button, which it was notching a divider through and pushing the label off-centre. - Escape returns to the menu, closing any open popover first, and confirms before abandoning a live race. - Split --text-color and --sub-color per theme; they shared one value that measured 3.65:1 against the background, below the 4.5:1 body-text floor. - Semantic colours used in exactly one place each: gold for a personal best, amber for the race countdown and the mobile-result badge. - Passage now sits in the same place on the typing and end screens, and its column is a whole number of characters wide so wrapping cannot leave a permanent gap on the right. - End screen: keystrokes and a correct/wrong/extra/missed split, attribution carried over from the typing screen, and a graph with a separate error axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'crates/server/src')
-rw-r--r--crates/server/src/anticheat.rs81
-rw-r--r--crates/server/src/auth.rs285
-rw-r--r--crates/server/src/cosmetics.rs153
-rw-r--r--crates/server/src/error.rs52
-rw-r--r--crates/server/src/friends.rs193
-rw-r--r--crates/server/src/lyrics.rs67
-rw-r--r--crates/server/src/main.rs255
-rw-r--r--crates/server/src/multiplayer.rs594
-rw-r--r--crates/server/src/rate_limit.rs34
-rw-r--r--crates/server/src/spotify.rs237
-rw-r--r--crates/server/src/state.rs65
-rw-r--r--crates/server/src/stats.rs275
12 files changed, 2291 insertions, 0 deletions
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))
+}