diff options
| author | srdusr <[email protected]> | 2025-12-06 00:58:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-06 00:58:00 +0200 |
| commit | 24f1eb6cc611f458c45f0ac4046efce51211d7d3 (patch) | |
| tree | 3cd2193d9439921737d57bf3c8452d275faae100 /crates | |
| parent | 076f29369e51dfb5798f70099a330ebeb1b66a4e (diff) | |
| download | typerpunk-24f1eb6cc611f458c45f0ac4046efce51211d7d3.tar.gz typerpunk-24f1eb6cc611f458c45f0ac4046efce51211d7d3.zip | |
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).
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/server/migrations/0009_bot_players.sql | 8 | ||||
| -rw-r--r-- | crates/server/src/bot_results.rs | 173 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/server/src/stats.rs | 8 |
4 files changed, 190 insertions, 1 deletions
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<AppState>) { + 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<String>, + /// 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<Arc<AppState>>, Query(q): Query<LeaderboardQuery>) -> Result<impl IntoResponse, AppError> { @@ -239,10 +243,11 @@ async fn leaderboard(State(state): State<Arc<AppState>>, Query(q): Query<Leaderb // 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 username, wpm, accuracy, created_at, device_type, flair_value, is_bot 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, + users.is_bot as is_bot, 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 @@ -268,6 +273,7 @@ async fn leaderboard(State(state): State<Arc<AppState>>, Query(q): Query<Leaderb 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), + is_bot: row.try_get::<i64, _>("is_bot").unwrap_or(0) != 0, }) .collect(); |