Skip to main content

thread_engine/
mesh.rs

1//! **Tier 1 presence — the serverless P2P mesh** (presence-topology-v0.1 §3).
2//!
3//! For small gatherings, presence needs *no dedicated server*: peers connect
4//! directly and each sends its pose to the others. The only shared infrastructure
5//! is a **stateless rendezvous** used once, at join, to introduce peers — it never
6//! sees poses and holds no session state, so if every server on earth is gone, two
7//! people who can reach each other still share presence. That's the survivability
8//! floor of the whole design.
9//!
10//! This module is the transport-agnostic core: [`MeshCoordinator`] manages the peer
11//! set, fans out the local pose, and feeds received peer poses into
12//! [`PresenceState`] — exactly what a browser needs, with the actual byte transport
13//! ([`MeshTransport`]) left to a binding. The real binding is WebRTC data channels;
14//! tests use an in-memory loopback, so the mesh logic is verified with no network.
15//! [`Signal`] is the rendezvous wire (SDP/ICE introduction), defined here so a
16//! rendezvous service — or another peer — can implement it.
17
18use crate::traveler::{PoseSample, PresenceState};
19use serde::{Deserialize, Serialize};
20
21/// A peer's occupant id within a mesh (assigned at the rendezvous).
22pub type PeerId = u32;
23
24/// Max peers in a mesh before a world should upgrade to a relay (topology §3): a
25/// full mesh is O(n²) in connections, so past this it's cheaper to run a Tier-2
26/// relay.
27pub const MAX_MESH_PEERS: usize = 16;
28
29/// A pose on the mesh wire — the same shape as a presence-wire pose, but
30/// **sender-stamped**: with no server clock, the sender fills `ts` from its own
31/// monotonic clock and receivers interpolate against *that sender's* timeline.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct MeshPose {
34    pub p: [f32; 3],
35    pub r: [f32; 4],
36    pub v: [f32; 3],
37    pub a: u8,
38    pub ts: u32,
39}
40
41/// Rendezvous signaling — the messages peers exchange (via a stateless rendezvous)
42/// to introduce themselves and negotiate a direct connection. The rendezvous relays
43/// these blindly; it never handles a [`MeshPose`].
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "t", rename_all = "lowercase")]
46pub enum Signal {
47    /// "I'm here" — a peer joining a world announces itself. `peer` is the
48    /// client's *provisional* self-picked id; a rendezvous that assigns ids
49    /// replies with [`Signal::Welcome`] to override it.
50    Announce { peer: PeerId, world: String },
51    /// Rendezvous → newcomer: your **server-assigned** peer id (collision-free).
52    /// Sent before [`Signal::Peers`]. A stateless rendezvous MAY omit it, in which
53    /// case the client keeps its provisional `announce` id.
54    Welcome { id: PeerId },
55    /// Rendezvous → newcomer: the peers already present (to offer to).
56    Peers { peers: Vec<PeerId> },
57    /// WebRTC session description (connection offer).
58    Offer {
59        from: PeerId,
60        to: PeerId,
61        sdp: String,
62    },
63    /// WebRTC session description (connection answer).
64    Answer {
65        from: PeerId,
66        to: PeerId,
67        sdp: String,
68    },
69    /// An ICE candidate (a possible network path to a peer).
70    Candidate {
71        from: PeerId,
72        to: PeerId,
73        candidate: String,
74    },
75    /// A peer left the world.
76    Leave { peer: PeerId },
77}
78
79/// A peer connection lifecycle event surfaced by the transport.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum MeshEvent {
82    Connected(PeerId),
83    Disconnected(PeerId),
84}
85
86/// The transport seam: how the coordinator actually moves bytes between peers. The
87/// real implementation is WebRTC data channels (a feature-gated binding); tests use
88/// an in-memory loopback. Keeping this a trait is what makes the mesh logic testable
89/// with no network and swappable across transports.
90pub trait MeshTransport {
91    /// Send `data` to every connected peer (unreliable/unordered is fine for poses).
92    fn broadcast(&mut self, data: &[u8]);
93    /// Drain inbound `(sender, data)` messages received since the last poll.
94    fn poll(&mut self) -> Vec<(PeerId, Vec<u8>)>;
95    /// Drain peer connect/disconnect events since the last poll.
96    fn events(&mut self) -> Vec<MeshEvent>;
97}
98
99/// The socket-free P2P mesh coordinator: peer-set management + pose fan-out +
100/// feeding received poses into [`PresenceState`]. Tier-1 presence, no server.
101pub struct MeshCoordinator {
102    me: PeerId,
103    peers: std::collections::BTreeSet<PeerId>,
104}
105
106impl MeshCoordinator {
107    pub fn new(me: PeerId) -> Self {
108        Self {
109            me,
110            peers: std::collections::BTreeSet::new(),
111        }
112    }
113
114    /// This peer's own id.
115    pub fn id(&self) -> PeerId {
116        self.me
117    }
118
119    /// Adopt a **server-assigned** id (a rendezvous `welcome{id}`), replacing the
120    /// provisional self-picked one. The rendezvous sends `welcome` before `peers`,
121    /// so this lands before any peer connection exists.
122    pub fn set_id(&mut self, id: PeerId) {
123        self.me = id;
124    }
125
126    /// Number of connected peers (excludes self).
127    pub fn peer_count(&self) -> usize {
128        self.peers.len()
129    }
130
131    /// Whether the mesh is at capacity — a world should upgrade to a relay past this.
132    pub fn is_full(&self) -> bool {
133        self.peers.len() >= MAX_MESH_PEERS
134    }
135
136    /// Broadcast the local player's pose to all peers. `ts` is the sender's clock.
137    pub fn send_pose(
138        &self,
139        tx: &mut dyn MeshTransport,
140        pos: [f32; 3],
141        rot: [f32; 4],
142        vel: [f32; 3],
143        anim: u8,
144        ts: u32,
145    ) {
146        if self.peers.is_empty() {
147            return;
148        }
149        let pose = MeshPose {
150            p: pos,
151            r: rot,
152            v: vel,
153            a: anim,
154            ts,
155        };
156        if let Ok(bytes) = serde_json::to_vec(&pose) {
157            tx.broadcast(&bytes);
158        }
159    }
160
161    /// Pump the transport once: apply connect/disconnect events and decode inbound
162    /// peer poses into `presence`. Poses are stamped with the local `now_ms` (like
163    /// the relay client) so interpolation uses one clock and cross-peer skew is a
164    /// non-issue.
165    pub fn pump(&mut self, tx: &mut dyn MeshTransport, presence: &mut PresenceState, now_ms: u64) {
166        for ev in tx.events() {
167            match ev {
168                MeshEvent::Connected(p) if p != self.me && !self.is_full() => {
169                    if self.peers.insert(p) {
170                        presence.join(p, format!("peer {p}"), peer_color(p));
171                    }
172                }
173                MeshEvent::Connected(_) => {} // self, or mesh full → ignore
174                MeshEvent::Disconnected(p) => {
175                    if self.peers.remove(&p) {
176                        presence.leave(p);
177                    }
178                }
179            }
180        }
181
182        for (from, data) in tx.poll() {
183            if from == self.me || !self.peers.contains(&from) {
184                continue; // ignore self-echo and unknown senders
185            }
186            if let Ok(pose) = serde_json::from_slice::<MeshPose>(&data) {
187                presence.pose(
188                    from,
189                    PoseSample {
190                        t_ms: now_ms,
191                        pos: pose.p.into(),
192                        rot: glam::Quat::from_xyzw(pose.r[0], pose.r[1], pose.r[2], pose.r[3]),
193                        vel: pose.v.into(),
194                        anim: pose.a,
195                    },
196                );
197            }
198        }
199    }
200}
201
202/// Perfect-negotiation tiebreak: the **lower** peer id creates the offer. This
203/// avoids "glare" — both sides offering at once — without any central coordinator,
204/// so two peers introduced by the rendezvous deterministically agree who initiates.
205pub fn should_offer(me: PeerId, other: PeerId) -> bool {
206    me < other
207}
208
209/// A stable colour per peer id (until avatar descriptors carry real looks).
210fn peer_color(id: PeerId) -> [f32; 4] {
211    let h = id.wrapping_mul(2654435761);
212    [
213        0.4 + ((h & 0xFF) as f32 / 255.0) * 0.5,
214        0.4 + (((h >> 8) & 0xFF) as f32 / 255.0) * 0.5,
215        0.4 + (((h >> 16) & 0xFF) as f32 / 255.0) * 0.5,
216        1.0,
217    ]
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::cell::RefCell;
224    use std::rc::Rc;
225
226    /// A two-party in-memory transport: what one side broadcasts lands in the
227    /// other's inbox. Proves the mesh moves poses with no network.
228    struct Loopback {
229        outbox: Rc<RefCell<Vec<(PeerId, Vec<u8>)>>>, // the peer's inbox
230        inbox: Rc<RefCell<Vec<(PeerId, Vec<u8>)>>>,
231        me: PeerId,
232        pending_events: Vec<MeshEvent>,
233    }
234    impl MeshTransport for Loopback {
235        fn broadcast(&mut self, data: &[u8]) {
236            self.outbox.borrow_mut().push((self.me, data.to_vec()));
237        }
238        fn poll(&mut self) -> Vec<(PeerId, Vec<u8>)> {
239            std::mem::take(&mut *self.inbox.borrow_mut())
240        }
241        fn events(&mut self) -> Vec<MeshEvent> {
242            std::mem::take(&mut self.pending_events)
243        }
244    }
245
246    fn linked() -> (Loopback, Loopback) {
247        let a_inbox = Rc::new(RefCell::new(Vec::new()));
248        let b_inbox = Rc::new(RefCell::new(Vec::new()));
249        let a = Loopback {
250            outbox: b_inbox.clone(),
251            inbox: a_inbox.clone(),
252            me: 1,
253            pending_events: vec![MeshEvent::Connected(2)],
254        };
255        let b = Loopback {
256            outbox: a_inbox,
257            inbox: b_inbox,
258            me: 2,
259            pending_events: vec![MeshEvent::Connected(1)],
260        };
261        (a, b)
262    }
263
264    #[test]
265    fn two_peers_exchange_poses_with_no_server() {
266        let (mut ta, mut tb) = linked();
267        let mut a = MeshCoordinator::new(1);
268        let mut b = MeshCoordinator::new(2);
269        let mut pa = PresenceState::default();
270        let mut pb = PresenceState::default();
271
272        // First pump applies the Connected events → each sees the other.
273        a.pump(&mut ta, &mut pa, 0);
274        b.pump(&mut tb, &mut pb, 0);
275        assert_eq!(a.peer_count(), 1);
276        assert_eq!(b.peer_count(), 1);
277
278        // A broadcasts a pose; B pumps and should now track A.
279        a.send_pose(
280            &mut ta,
281            [5.0, 0.0, 3.0],
282            [0.0, 0.0, 0.0, 1.0],
283            [1.0, 0.0, 0.0],
284            2,
285            100,
286        );
287        b.pump(&mut tb, &mut pb, 50);
288        assert_eq!(pb.count(), 1, "B tracks A after receiving a pose");
289    }
290
291    #[test]
292    fn a_disconnect_drops_the_peer() {
293        let (mut ta, _tb) = linked();
294        let mut a = MeshCoordinator::new(1);
295        let mut pa = PresenceState::default();
296        a.pump(&mut ta, &mut pa, 0);
297        assert_eq!(a.peer_count(), 1);
298
299        ta.pending_events = vec![MeshEvent::Disconnected(2)];
300        a.pump(&mut ta, &mut pa, 10);
301        assert_eq!(a.peer_count(), 0, "peer removed on disconnect");
302    }
303
304    #[test]
305    fn mesh_refuses_peers_past_capacity() {
306        let mut c = MeshCoordinator::new(0);
307        let mut p = PresenceState::default();
308        let mut tx = Loopback {
309            outbox: Rc::new(RefCell::new(Vec::new())),
310            inbox: Rc::new(RefCell::new(Vec::new())),
311            me: 0,
312            pending_events: (1..=(MAX_MESH_PEERS as u32 + 5))
313                .map(MeshEvent::Connected)
314                .collect(),
315        };
316        c.pump(&mut tx, &mut p, 0);
317        assert_eq!(c.peer_count(), MAX_MESH_PEERS, "capped at MAX_MESH_PEERS");
318    }
319
320    #[test]
321    fn signaling_round_trips() {
322        let s = Signal::Offer {
323            from: 1,
324            to: 2,
325            sdp: "v=0…".into(),
326        };
327        let json = serde_json::to_string(&s).unwrap();
328        assert!(json.contains(r#""t":"offer""#));
329        assert_eq!(serde_json::from_str::<Signal>(&json).unwrap(), s);
330    }
331
332    #[test]
333    fn welcome_assigns_the_peer_id() {
334        // Wire shape: {"t":"welcome","id":N} — additive, sent before `peers`.
335        let s: Signal = serde_json::from_str(r#"{"t":"welcome","id":7}"#).unwrap();
336        assert_eq!(s, Signal::Welcome { id: 7 });
337
338        // The coordinator adopts it, replacing the provisional id.
339        let mut c = MeshCoordinator::new(999);
340        c.set_id(7);
341        assert_eq!(c.id(), 7);
342    }
343
344    #[test]
345    fn exactly_one_side_offers() {
346        // For any distinct pair, exactly one peer initiates — no glare.
347        assert!(should_offer(1, 2));
348        assert!(!should_offer(2, 1));
349        assert_ne!(should_offer(3, 8), should_offer(8, 3));
350    }
351}