srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/src/multiplayer.rs4
-rw-r--r--crates/server/src/main.rs19
-rw-r--r--crates/server/src/multiplayer.rs28
-rw-r--r--crates/server/src/state.rs13
-rw-r--r--crates/steam/src/multiplayer_plugin.rs1
-rw-r--r--crates/steam/src/net.rs9
-rw-r--r--crates/tui/src/main.rs1
-rw-r--r--crates/tui/src/multiplayer_net.rs9
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 },