diff options
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(); |