From 24f1eb6cc611f458c45f0ac4046efce51211d7d3 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sat, 6 Dec 2025 00:58:00 +0200 Subject: Fix content packs, seed the leaderboard, and add the missing site furniture Naming - "Typing Test" removed from the browser tab, the crate description and the READMEs. The scope outgrew it: solo practice, live races, code drills and adaptive weak-key training. Content - Movies was prose *about* film, not film quotes - the same mistake the programming pack had. Replaced with 28 attributed lines. New anime pack, 28 lines across 24 series. - This follows the model TypeRacer states outright ("type quotes from popular music, songs, anime, comic books and more"): short excerpts attributed to the work they came from. The scalable half of their approach is user submission with moderation, which is a feature this does not have yet. Leaderboard - An empty board tells a new player nobody is here. Bots now race the eight fixed-length leaderboard modes, seeded with a fortnight of backdated results on first run and one new result every 90 seconds after. - They are ordinary users carrying is_bot, returned by the same query and labelled "bot" in the UI. Seeding a board is reasonable; passing synthetic scores off as human results is not, so the flag travels with the row. - Seeding is checked per mode. A single result from the live ticker used to satisfy an "any bot results" guard and leave every other mode empty forever. Stats - The per-key accuracy data Practice mode is built on was computed, used to generate text, and never shown. The screen now ranks your weakest keys with the error rate and pause length behind each one. - Added recent form against your lifetime average, best accuracy, and tests this week. Multiplayer standings - Now place, racer, WPM, accuracy and time, with column labels - the columns TypeRacer and 10FastFingers both show. Site furniture - Share (Web Share where available, clipboard otherwise - no third-party button, no tracking script), a GitHub link, and a real privacy page written from what the code actually stores rather than from a template. Button hierarchy - Everything was an outlined box of roughly equal weight, so a screen's one real action, a settings toggle and a filter chip looked alike. Three tiers now: primary (filled, one per screen), default (outlined), quiet (toggles and filters, bordered only when hovered or active). --- crates/server/migrations/0009_bot_players.sql | 8 ++ crates/server/src/bot_results.rs | 173 ++++++++++++++++++++++++++ crates/server/src/main.rs | 2 + crates/server/src/stats.rs | 8 +- 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 crates/server/migrations/0009_bot_players.sql create mode 100644 crates/server/src/bot_results.rs (limited to 'crates/server') diff --git a/crates/server/migrations/0009_bot_players.sql b/crates/server/migrations/0009_bot_players.sql new file mode 100644 index 0000000..790eb85 --- /dev/null +++ b/crates/server/migrations/0009_bot_players.sql @@ -0,0 +1,8 @@ +- Bots are ordinary users with a flag, not a parallel identity system: they +- appear in the same tables, are ranked by the same query, and are joined to +- the same cosmetics. The flag exists so the client can label them, because a +- board that mixes synthetic scores into human ones without saying so is +- telling its users something untrue. +ALTER TABLE users ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_users_is_bot ON users(is_bot); diff --git a/crates/server/src/bot_results.rs b/crates/server/src/bot_results.rs new file mode 100644 index 0000000..255270e --- /dev/null +++ b/crates/server/src/bot_results.rs @@ -0,0 +1,173 @@ +//! Bot results for the leaderboard. +//! +//! A brand-new game has an empty board, and an empty board tells a new player +//! that nobody is here. These bots race the standard leaderboard modes on a +//! slow interval so there is always something to measure yourself against. +//! +//! They are ordinary users carrying `is_bot = 1`, which the leaderboard +//! returns and the client labels. Presenting synthetic scores as human ones +//! would be dishonest, so the flag travels with the row rather than the bots +//! being disguised. + +use crate::state::AppState; +use rand::Rng; +use std::sync::Arc; +use std::time::Duration; +use time::OffsetDateTime; + +/// The names bot racers use. Distinct from the multiplayer bot pool so a +/// leaderboard name is not mistaken for someone you just raced. +const BOT_USERNAMES: &[&str] = &[ + "circuit", "lumen", "kestrel", "vantablack", "moth", "cinder", + "halide", "wren", "obsidian", "tallow", "juniper", "signal", +]; + +/// Which modes get populated - the same fixed-length ones the leaderboard +/// screen offers, since an open-ended quote category has no comparable score. +const MODES: &[&str] = &[ + "time-15", "time-30", "time-60", "time-120", + "words-10", "words-25", "words-50", "words-100", +]; + +/// How often a bot posts a result. Slow on purpose: the board should look +/// lived-in, not busy, and every insert is a write on a single-writer database. +const POST_INTERVAL: Duration = Duration::from_secs(90); + +/// A bot's ceiling. Deliberately short of a strong human: the board exists to +/// give a new player something to chase, not to make the top unreachable. +/// How many results each mode is seeded with on first run. +const SEED_PER_MODE: i64 = 8; +const BOT_MIN_WPM: f64 = 38.0; +const BOT_MAX_WPM: f64 = 96.0; + +pub fn spawn(state: Arc) { + tokio::spawn(async move { + if let Err(err) = ensure_bot_users(&state).await { + tracing::warn!("could not create leaderboard bots: {err}"); + return; + } + if let Err(err) = seed_backlog(&state).await { + tracing::warn!("could not seed leaderboard backlog: {err}"); + } + let mut ticker = tokio::time::interval(POST_INTERVAL); + // The immediate first tick would post a result at boot; skip it so a + // restart does not stack results at the same timestamp. + ticker.tick().await; + loop { + ticker.tick().await; + if let Err(err) = post_one_result(&state).await { + tracing::warn!("bot leaderboard result failed: {err}"); + } + } + }); +} + +async fn ensure_bot_users(state: &AppState) -> Result<(), sqlx::Error> { + for name in BOT_USERNAMES { + // A bot has no usable password hash: these accounts are never signed + // into, and the auth path compares against a hash that cannot match. + sqlx::query( + "INSERT INTO users (id, username, password_hash, created_at, is_bot) + VALUES (?, ?, '!', ?, 1) + ON CONFLICT(username) DO NOTHING", + ) + .bind(format!("bot-{name}")) + .bind(*name) + .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + } + Ok(()) +} + +async fn post_one_result(state: &AppState) -> Result<(), sqlx::Error> { + let (name, mode, wpm, accuracy, seconds) = { + let mut rng = rand::thread_rng(); + let name = BOT_USERNAMES[rng.gen_range(0..BOT_USERNAMES.len())]; + let mode = MODES[rng.gen_range(0..MODES.len())]; + let wpm = rng.gen_range(BOT_MIN_WPM..BOT_MAX_WPM); + // Accuracy tracks speed, the same way it does for the racing bots. + let t = (wpm - BOT_MIN_WPM) / (BOT_MAX_WPM - BOT_MIN_WPM); + let accuracy = (88.0 + t * 10.0 + rng.gen_range(-1.5..1.5)).clamp(85.0, 100.0); + let seconds = mode_seconds(mode, wpm); + (name, mode, wpm, accuracy, seconds) + }; + + sqlx::query( + "INSERT INTO test_results (id, user_id, mode_key, wpm, raw_wpm, accuracy, time_seconds, created_at, device_type, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'desktop', 0)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(format!("bot-{name}")) + .bind(mode) + .bind(wpm) + .bind(wpm * 1.05) + .bind(accuracy) + .bind(seconds) + .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + Ok(()) +} + +/// Fills each mode on first run so the board is worth looking at immediately. +/// Without this a fresh install shows an empty leaderboard and then gains one +/// row every ninety seconds, which is hours before it reads as populated. +/// Dated backwards across the past fortnight so it does not look like every +/// score was set in the same instant. +async fn seed_backlog(state: &AppState) -> Result<(), sqlx::Error> { + for mode in MODES { + // Checked per mode, not once overall: a single result posted by the + // live ticker used to satisfy an "any bot results at all" guard and + // leave every other mode permanently empty. This also means a mode + // added later gets seeded on the next start. + let existing: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM test_results WHERE user_id LIKE 'bot-%' AND mode_key = ?", + ) + .bind(*mode) + .fetch_one(&state.db) + .await?; + if existing >= SEED_PER_MODE { + continue; + } + + for _ in existing..SEED_PER_MODE { + let (name, wpm, accuracy, seconds, age_hours) = { + let mut rng = rand::thread_rng(); + let name = BOT_USERNAMES[rng.gen_range(0..BOT_USERNAMES.len())]; + let wpm = rng.gen_range(BOT_MIN_WPM..BOT_MAX_WPM); + let t = (wpm - BOT_MIN_WPM) / (BOT_MAX_WPM - BOT_MIN_WPM); + let accuracy = (88.0 + t * 10.0 + rng.gen_range(-1.5..1.5)).clamp(85.0, 100.0); + let seconds = mode_seconds(mode, wpm); + (name, wpm, accuracy, seconds, rng.gen_range(1..336)) + }; + let created = OffsetDateTime::now_utc() - time::Duration::hours(age_hours); + sqlx::query( + "INSERT INTO test_results (id, user_id, mode_key, wpm, raw_wpm, accuracy, time_seconds, created_at, device_type, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'desktop', 0)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(format!("bot-{name}")) + .bind(*mode) + .bind(wpm) + .bind(wpm * 1.05) + .bind(accuracy) + .bind(seconds) + .bind(crate::auth::format_timestamp(created)) + .execute(&state.db) + .await?; + } + } + tracing::info!("seeded leaderboard backlog for {} modes", MODES.len()); + Ok(()) +} + +/// How long a run of this mode takes at the given pace. +fn mode_seconds(mode: &str, wpm: f64) -> f64 { + if let Some(rest) = mode.strip_prefix("time-") { + return rest.parse().unwrap_or(30.0); + } + let words: f64 = mode.strip_prefix("words-").and_then(|w| w.parse().ok()).unwrap_or(25.0); + // Five characters to a word, at this pace. + (words * 5.0) / (wpm * 5.0 / 60.0) +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 2769d4a..0d1c980 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,4 +1,5 @@ mod anticheat; +mod bot_results; mod auth; mod cosmetics; mod error; @@ -109,6 +110,7 @@ async fn main() -> anyhow::Result<()> { let race_texts = load_race_texts(); let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, frontend_origin.clone())); + bot_results::spawn(app_state.clone()); let app = build_app(app_state).layer(cors).layer(TraceLayer::new_for_http()); diff --git a/crates/server/src/stats.rs b/crates/server/src/stats.rs index 6e80a3d..a7d3a70 100644 --- a/crates/server/src/stats.rs +++ b/crates/server/src/stats.rs @@ -223,6 +223,10 @@ pub struct LeaderboardEntry { pub date: String, pub device_type: String, pub flair: Option, + /// Surfaced so the client can label the row. A board that mixes synthetic + /// scores into human ones without saying which is which is lying to the + /// people reading it. + pub is_bot: bool, } async fn leaderboard(State(state): State>, Query(q): Query) -> Result { @@ -239,10 +243,11 @@ async fn leaderboard(State(state): State>, Query(q): Query>, Query(q): Query, _>("flair_value").unwrap_or(None), + is_bot: row.try_get::("is_bot").unwrap_or(0) != 0, }) .collect(); -- cgit v1.2.3