diff options
Diffstat (limited to 'crates/server/src')
| -rw-r--r-- | crates/server/src/main.rs | 19 | ||||
| -rw-r--r-- | crates/server/src/multiplayer.rs | 28 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 13 |
3 files changed, 48 insertions, 12 deletions
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 7e9e9a8..2769d4a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -10,6 +10,7 @@ mod spotify; mod state; mod stats; +use crate::state::RaceText; use axum::http::{HeaderValue, Method}; use axum::routing::get; use axum::Router; @@ -24,6 +25,8 @@ use tower_http::trace::TraceLayer; #[derive(Deserialize)] struct TextEntry { content: String, + #[serde(default)] + attribution: Option<String>, } // Every multiplayer room draws from this same pool so every player in a @@ -32,15 +35,18 @@ struct TextEntry { // 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> { +fn load_race_texts() -> Vec<RaceText> { 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(), + Some(entries) if !entries.is_empty() => entries + .into_iter() + .map(|e| RaceText { text: e.content, attribution: e.attribution }) + .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(), + RaceText { text: "The quick brown fox jumps over the lazy dog.".to_string(), attribution: None }, + RaceText { text: "Pack my box with five dozen liquor jugs.".to_string(), attribution: None }, ] } } @@ -136,7 +142,10 @@ mod tests { let app_state = Arc::new(AppState::new( db, false, - vec!["The quick brown fox jumps over the lazy dog.".to_string()], + vec![RaceText { + text: "The quick brown fox jumps over the lazy dog.".to_string(), + attribution: None, + }], SpotifyConfig::default(), "http://localhost:4173".to_string(), )); diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs index 8974c8c..7fca7c3 100644 --- a/crates/server/src/multiplayer.rs +++ b/crates/server/src/multiplayer.rs @@ -56,6 +56,10 @@ const BOT_NAMES: &[&str] = &[ "Pixel", "Quartz", "Sigil", "Tessa", "Umbra", ]; +// Long enough to read the passage's opening words and get your hands in +// position. Three seconds was barely enough to register that a race had +// started, let alone look at the text. +const COUNTDOWN_SECONDS: u32 = 5; const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud #[derive(Debug, Clone, PartialEq)] @@ -318,16 +322,30 @@ async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { return; } room.status = RoomStatus::Countdown; - let text = state + let picked = state .race_texts .get(rand::thread_rng().gen_range(0..state.race_texts.len())) .cloned() - .unwrap_or_default(); - room.text = Some(text.clone()); - text + .unwrap_or_else(|| crate::state::RaceText { + text: String::new(), + attribution: None, + }); + room.text = Some(picked.text.clone()); + // Sent before the countdown so every client can put the passage on + // screen and let players read it while they wait, the way TypeRacer + // and 10FastFingers both do. + broadcast( + &room, + &ServerMessage::RaceText { + text: picked.text.clone(), + attribution: picked.attribution.clone(), + }, + ) + .await; + picked.text }; - for seconds in (1..=3).rev() { + for seconds in (1..=COUNTDOWN_SECONDS).rev() { { let room = room_arc.lock().await; broadcast(&room, &ServerMessage::Countdown { seconds }).await; diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 267b6fb..7244955 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -17,6 +17,15 @@ impl SpotifyConfig { } } +/// A passage a race can be run on, with whoever wrote it. The attribution +/// travels with the text so every racer can see where the passage came from, +/// the same way single player does. +#[derive(Debug, Clone)] +pub struct RaceText { + pub text: String, + pub attribution: Option<String>, +} + #[derive(Clone)] pub struct AppState { pub db: SqlitePool, @@ -33,7 +42,7 @@ pub struct AppState { /// 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 race_texts: Vec<RaceText>, pub spotify: SpotifyConfig, pub frontend_origin: String, pub http: Client, @@ -43,7 +52,7 @@ impl AppState { pub fn new( db: SqlitePool, cookie_secure: bool, - race_texts: Vec<String>, + race_texts: Vec<RaceText>, spotify: SpotifyConfig, frontend_origin: String, ) -> Self { |