From ef148087f4615ec80545b1047a7d9cc6c0a12a01 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 12:22:27 +0100 Subject: [PATCH] Measure multiplayer relay ping --- server/src/netplay.rs | 16 ++++++++++- src/lobby.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/server/src/netplay.rs b/server/src/netplay.rs index aeeafd0..26ad124 100644 --- a/server/src/netplay.rs +++ b/server/src/netplay.rs @@ -19,7 +19,7 @@ // {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again // {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected // -// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error. +// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error, pong. use crate::AppState; use axum::{ @@ -276,6 +276,9 @@ impl Netplay { self.leave_room(conn, &code, false); } } + "ping" => { + self.send(conn, json!({"t":"pong","c":msg.get("c").cloned().unwrap_or(Value::Null)})); + } _ => self.error(conn, "unknown message"), } Vec::new() @@ -988,4 +991,15 @@ mod tests { assert_eq!(team_red["side"].as_u64(), Some(0)); assert!(rx2.try_recv().is_err()); } + + #[test] + fn the_relay_answers_a_ping_with_the_same_correlation_number() { + let mut net = Netplay::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let red = net.connect(tx, Some("red".into()), None); + net.handle(red, json!({"t":"ping","c":7})); + + let pong = until(&mut rx, "pong"); + assert_eq!(pong["c"].as_u64(), Some(7)); + } } diff --git a/src/lobby.rs b/src/lobby.rs index b0de22c..68410ea 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -118,6 +118,10 @@ pub struct MatchClient { start: Option, turns: Vec<(u32, Vec, Vec)>, checks: Vec<(Option, u32, u64)>, + pub ping_ms: f32, + ping_seq: u64, + ping_next: f64, + ping_pending: Option<(u64, f64)>, } impl MatchClient { @@ -137,6 +141,10 @@ impl MatchClient { start: None, turns: Vec::new(), checks: Vec::new(), + ping_ms: 0.0, + ping_seq: 0, + ping_next: 0.0, + ping_pending: None, } } @@ -156,6 +164,9 @@ impl MatchClient { self.connected = true; self.name = name.to_string(); self.status = None; + self.ping_ms = 0.0; + self.ping_pending = None; + self.ping_next = 0.0; self.rooms_request(); } @@ -167,6 +178,25 @@ impl MatchClient { self.start = None; self.turns.clear(); self.status = Some("offline".into()); + self.ping_ms = 0.0; + self.ping_pending = None; + self.ping_next = 0.0; + } + + pub fn tick_ping(&mut self, now: f64) { + if let Some((_, start)) = self.ping_pending { + if now - start > 5.0 { + self.ping_pending = None; + self.ping_ms = 0.0; + } + } + if !self.connected || self.ping_pending.is_some() || now < self.ping_next { + return; + } + self.ping_seq = self.ping_seq.wrapping_add(1); + self.outbox.push_back(json!({"t":"ping","c":self.ping_seq}).to_string()); + self.ping_pending = Some((self.ping_seq, now)); + self.ping_next = now + 2.0; } pub fn rooms_request(&mut self) { @@ -281,6 +311,10 @@ impl MatchClient { } pub fn feed(&mut self, text: &str) { + self.feed_at(text, 0.0); + } + + pub fn feed_at(&mut self, text: &str, now: f64) { let v: Value = match serde_json::from_str(text) { Ok(v) => v, Err(_) => return, @@ -291,6 +325,16 @@ impl MatchClient { self.name = n.to_string(); } } + "pong" => { + if let Some(c) = v.get("c").and_then(|x| x.as_u64()) { + if let Some((seq, start)) = self.ping_pending { + if seq == c { + self.ping_ms = ((now - start).max(0.0) * 1000.0) as f32; + self.ping_pending = None; + } + } + } + } "rooms" => self.parse_rooms(&v), "room" => self.parse_room(&v), "start" => self.parse_start(&v), @@ -563,6 +607,28 @@ mod tests { assert_eq!(chat[0], (Some(0), "red".to_string(), "hello".to_string())); } + #[test] + fn the_bridge_times_the_server_ping() { + let mut client = MatchClient::new(); + client.on_connect("red"); + pop(&mut client); // rooms + + client.tick_ping(10.0); + let req = pop(&mut client); + assert_eq!(req["t"].as_str(), Some("ping")); + assert_eq!(req["c"].as_u64(), Some(1)); + + client.feed_at(r#"{"t":"pong","c":1}"#, 10.25); + assert!(client.ping_ms > 200.0 && client.ping_ms < 300.0, "the pong came 250 ms after the ping"); + + client.tick_ping(11.0); + assert!(client.outbox.is_empty(), "the next ping waits for its two-second interval"); + + client.tick_ping(12.0); + let next = pop(&mut client); + assert_eq!(next["c"].as_u64(), Some(2)); + } + #[test] fn the_bridge_holds_ai_seats_through_the_lobby() { let mut client = MatchClient::new();