diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/core/src/multiplayer.rs | 4 | ||||
| -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 | ||||
| -rw-r--r-- | crates/steam/src/multiplayer_plugin.rs | 1 | ||||
| -rw-r--r-- | crates/steam/src/net.rs | 9 | ||||
| -rw-r--r-- | crates/tui/src/main.rs | 1 | ||||
| -rw-r--r-- | crates/tui/src/multiplayer_net.rs | 9 |
8 files changed, 72 insertions, 12 deletions
diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs index a25a659..14d59e9 100644 --- a/crates/core/src/multiplayer.rs +++ b/crates/core/src/multiplayer.rs @@ -34,6 +34,10 @@ pub enum ServerMessage { /// assigned server-side per connection. Joined { player_id: String }, PlayerList { players: Vec<PlayerInfo> }, + /// Sent once, just before the countdown begins, so every client can put + /// the passage on screen and let players read it while they wait. Start + /// still carries the text, for clients that do not use this. + RaceText { text: String, attribution: Option<String> }, Countdown { seconds: u32 }, Start { text: String }, PlayerProgress { player_id: String, percent: f32, wpm: f32 }, 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 { diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs index 05c1760..02bd2e7 100644 --- a/crates/steam/src/multiplayer_plugin.rs +++ b/crates/steam/src/multiplayer_plugin.rs @@ -80,6 +80,7 @@ fn poll_events( ) { while let Some(event) = net.0.try_recv() { match event { + NetEvent::Ignored => {} NetEvent::RoomCreated { code } => { info!("multiplayer room created - join it from the web app with code: {code}"); } diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs index 4d213b4..e08e9fa 100644 --- a/crates/steam/src/net.rs +++ b/crates/steam/src/net.rs @@ -19,6 +19,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; #[derive(Debug, Clone)] pub enum NetEvent { + /// A protocol message this client has no use for. Delivered rather than + /// dropped so the match on ServerMessage stays exhaustive and adding a + /// variant is a compile-time prompt rather than a silent parse failure. + Ignored, RoomCreated { code: String }, Joined { player_id: String }, PlayerList(Vec<PlayerInfo>), @@ -84,6 +88,11 @@ fn to_event(msg: ServerMessage) -> NetEvent { match msg { ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + // The passage ahead of the countdown, so a client can show it while + // players wait. This one does not yet, and Start still carries the + // text, so it is accepted and ignored rather than dropped as an + // unparseable message. + ServerMessage::RaceText { .. } => NetEvent::Ignored, ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), ServerMessage::Start { text } => NetEvent::Start(text), ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f5c4a68..27ea983 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -104,6 +104,7 @@ fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multip fn apply_mp_event(app: &mut App, event: MpNetEvent) { match event { + MpNetEvent::Ignored => {} MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code), MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id), MpNetEvent::PlayerList(players) => { diff --git a/crates/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs index 8c6c6b0..5d1b772 100644 --- a/crates/tui/src/multiplayer_net.rs +++ b/crates/tui/src/multiplayer_net.rs @@ -11,6 +11,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; #[derive(Debug, Clone)] pub enum NetEvent { + /// A protocol message this client has no use for. Delivered rather than + /// dropped so the match on ServerMessage stays exhaustive and adding a + /// variant is a compile-time prompt rather than a silent parse failure. + Ignored, RoomCreated { code: String }, Joined { player_id: String }, PlayerList(Vec<PlayerInfo>), @@ -81,6 +85,11 @@ fn to_event(msg: ServerMessage) -> NetEvent { match msg { ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + // The passage ahead of the countdown, so a client can show it while + // players wait. This one does not yet, and Start still carries the + // text, so it is accepted and ignored rather than dropped as an + // unparseable message. + ServerMessage::RaceText { .. } => NetEvent::Ignored, ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), ServerMessage::Start { text } => NetEvent::Start(text), ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, |