Skip to main content

resuma/realtime/
peer.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use tokio::sync::mpsc;
5
6use super::room::PeerId;
7
8/// One connected peer inside a [`Room`](super::Room): outbound channel, liveness,
9/// and app-defined per-peer state `S` (position, name, flags, …).
10///
11/// Resuma does not interpret `S` — it is stored and handed back so apps can build
12/// roster/snapshot messages without maintaining a second peer map of their own.
13pub struct Peer<S> {
14    pub id: PeerId,
15    pub state: S,
16    pub last_seen: Instant,
17    tx: mpsc::UnboundedSender<String>,
18    rate: HashMap<&'static str, Instant>,
19}
20
21impl<S> Peer<S> {
22    pub(super) fn new(id: PeerId, state: S, tx: mpsc::UnboundedSender<String>) -> Self {
23        Self {
24            id,
25            state,
26            last_seen: Instant::now(),
27            tx,
28            rate: HashMap::new(),
29        }
30    }
31
32    /// Send a pre-serialized text message to this peer only. Silently drops if the
33    /// peer's writer task already exited (client disconnected mid-send) — callers
34    /// should not treat this as fatal, the read loop will observe the close soon.
35    pub fn send(&self, msg: impl Into<String>) {
36        let _ = self.tx.send(msg.into());
37    }
38
39    /// Refresh liveness. Call on every inbound message, not just heartbeats, so a
40    /// chatty-but-silent-on-`ping` client is not swept as stale.
41    pub fn touch(&mut self) {
42        self.last_seen = Instant::now();
43    }
44
45    /// Throttle a noisy message `kind` (e.g. `"pose"`, `"place"`, `"remove"`) to at
46    /// most once per `min_interval`. Returns `true` the first time and again after
47    /// each interval elapses, `false` otherwise.
48    ///
49    /// Generalizes the `last_pose` / `last_place` / `last_remove` / `last_sync`
50    /// fields a hand-rolled multiplayer relay needs one-per-message-type — here it
51    /// is a single map keyed by a `&'static str` tag apps choose per call site.
52    pub fn allow_rate(&mut self, kind: &'static str, min_interval: Duration) -> bool {
53        let now = Instant::now();
54        match self.rate.get(kind) {
55            Some(last) if now.duration_since(*last) < min_interval => false,
56            _ => {
57                self.rate.insert(kind, now);
58                true
59            }
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn peer_with_rx(id: &str) -> (Peer<()>, mpsc::UnboundedReceiver<String>) {
69        let (tx, rx) = mpsc::unbounded_channel();
70        (Peer::new(id.to_string(), (), tx), rx)
71    }
72
73    #[test]
74    fn allow_rate_gates_first_call_only_within_window() {
75        let (mut p, _rx) = peer_with_rx("a");
76        assert!(p.allow_rate("pose", Duration::from_secs(60)));
77        assert!(!p.allow_rate("pose", Duration::from_secs(60)));
78        // Different kind is independent.
79        assert!(p.allow_rate("place", Duration::from_secs(60)));
80    }
81
82    #[test]
83    fn send_does_not_panic_after_receiver_dropped() {
84        let (p, rx) = peer_with_rx("a");
85        drop(rx);
86        p.send("hello"); // unbounded_channel send after drop just errors silently
87    }
88}