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}