Skip to main content

thread_engine/
presence.rs

1//! Presence relay client — the live transport for shared worlds.
2//!
3//! Speculative build against `docs/spec/presence-wire-v0.1.md`: connects to a
4//! WebSocket relay (`wss://<relay>/thread/:worldId`), sends the local player's
5//! pose, and forwards other travelers' poses into [`PresenceState`]. Runs its
6//! socket on a background thread and talks to the render loop over channels, so
7//! the frame never blocks and a missing/unreachable relay simply degrades to
8//! solo (the world keeps running). Ready to connect the instant the relay
9//! deploys; until then it logs and stays solo.
10
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::mpsc::{channel, Receiver};
13use std::sync::Arc;
14
15use glam::{Quat, Vec3};
16use crate::traveler::{PoseSample, PresenceState};
17use serde::{Deserialize, Serialize};
18
19/// Messages the client sends to the relay (`t`-tagged, per the wire spec).
20#[derive(Serialize)]
21#[serde(tag = "t")]
22enum ClientMsg {
23    #[serde(rename = "join")]
24    Join {
25        passport: String,
26        spawn: Option<String>,
27    },
28    #[serde(rename = "pose")]
29    Pose {
30        ts: u64,
31        p: [f32; 3],
32        r: [f32; 4],
33        v: [f32; 3],
34        a: u8,
35    },
36}
37
38/// Messages the relay sends to the client.
39#[derive(Deserialize)]
40#[serde(tag = "t")]
41enum ServerMsg {
42    #[serde(rename = "welcome")]
43    Welcome {
44        #[serde(default)]
45        occupants: Vec<Occupant>,
46    },
47    /// A traveler joined mid-session — identity precedes their first pose.
48    #[serde(rename = "join")]
49    Join {
50        id: u32,
51        #[serde(default)]
52        sub: Option<String>,
53        #[serde(default)]
54        name: Option<String>,
55        #[serde(default)]
56        avatar: Option<String>,
57    },
58    #[serde(rename = "pose")]
59    Pose {
60        id: u32,
61        p: [f32; 3],
62        #[serde(default = "ident_quat")]
63        r: [f32; 4],
64        #[serde(default)]
65        v: [f32; 3],
66        #[serde(default)]
67        a: u8,
68    },
69    #[serde(rename = "leave")]
70    Leave { id: u32 },
71    /// interact / voice / anything else — ignored for now.
72    #[serde(other)]
73    Other,
74}
75
76#[derive(Deserialize)]
77struct Occupant {
78    id: u32,
79    /// Identity claims the relay read from the traveler's Passport (may be
80    /// absent — anonymous travelers are always welcome).
81    #[serde(default)]
82    sub: Option<String>,
83    #[serde(default)]
84    name: Option<String>,
85    /// Descriptor URL — where "them" can be fetched from (passport-v0.1 §3).
86    #[serde(default)]
87    avatar: Option<String>,
88}
89
90fn ident_quat() -> [f32; 4] {
91    [0.0, 0.0, 0.0, 1.0]
92}
93
94/// A traveler's announced identity (from `welcome.occupants[]` or a `join`),
95/// handed back by [`PresenceClient::drain`] so the browser can fetch their
96/// descriptor and render "them" instead of a placeholder.
97#[derive(Debug, Clone)]
98pub struct TravelerHello {
99    pub id: u32,
100    pub sub: Option<String>,
101    pub name: Option<String>,
102    /// Their descriptor URL, if they carried a Passport with one.
103    pub avatar: Option<String>,
104}
105
106/// Something to send up the socket: a JSON wire message or a voice frame.
107enum Outgoing {
108    Msg(ClientMsg),
109    Voice(Vec<u8>),
110}
111
112/// A decoded event handed to the render loop.
113enum Event {
114    Join(TravelerHello),
115    Pose {
116        id: u32,
117        pos: Vec3,
118        rot: Quat,
119        vel: Vec3,
120        anim: u8,
121    },
122    Leave(u32),
123}
124
125/// Handle to the presence relay connection.
126pub struct PresenceClient {
127    events: Receiver<Event>,
128    poses: tokio::sync::mpsc::Sender<Outgoing>,
129    /// Received voice frames `(speaker id, PCM16 payload)` — presence-wire §6.
130    voice: Receiver<(u32, Vec<u8>)>,
131    connected: Arc<AtomicBool>,
132}
133
134impl PresenceClient {
135    /// Connect to a single relay for `world_id`. Convenience over [`connect_any`].
136    pub fn connect(
137        relay: String,
138        world_id: String,
139        passport: String,
140        spawn: Option<String>,
141    ) -> Self {
142        Self::connect_any(vec![relay], world_id, passport, spawn)
143    }
144
145    /// Connect to the first reachable relay in `relays` (primary first, then
146    /// fallbacks — no single relay is a point of failure). Non-blocking: the socket
147    /// runs on a background thread; if none is reachable, the world degrades to solo.
148    pub fn connect_any(
149        relays: Vec<String>,
150        world_id: String,
151        passport: String,
152        spawn: Option<String>,
153    ) -> Self {
154        let urls: Vec<String> = relays
155            .into_iter()
156            .map(|relay| {
157                if relay.contains("/thread/") {
158                    relay
159                } else {
160                    format!("{}/thread/{}", relay.trim_end_matches('/'), world_id)
161                }
162            })
163            .collect();
164        let (evt_tx, evt_rx) = channel();
165        let (voice_tx, voice_rx) = channel();
166        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::<Outgoing>(256);
167        let connected = Arc::new(AtomicBool::new(false));
168        let conn2 = connected.clone();
169        std::thread::spawn(move || {
170            let rt = match tokio::runtime::Builder::new_current_thread()
171                .enable_all()
172                .build()
173            {
174                Ok(rt) => rt,
175                Err(e) => {
176                    tracing::warn!("presence: runtime init failed: {e}");
177                    return;
178                }
179            };
180            rt.block_on(run(urls, passport, spawn, evt_tx, voice_tx, cmd_rx, conn2));
181        });
182        Self {
183            events: evt_rx,
184            poses: cmd_tx,
185            voice: voice_rx,
186            connected,
187        }
188    }
189
190    pub fn is_connected(&self) -> bool {
191        self.connected.load(Ordering::Relaxed)
192    }
193
194    /// Apply all received traveler events to the presence set (stamping arrival
195    /// time `now_ms` so interpolation shares the world clock). Returns the
196    /// identities announced this drain, so the caller can fetch their
197    /// descriptors and upgrade their look from the placeholder.
198    pub fn drain(&self, presence: &mut PresenceState, now_ms: u64) -> Vec<TravelerHello> {
199        let mut hellos = Vec::new();
200        while let Ok(evt) = self.events.try_recv() {
201            match evt {
202                Event::Join(hello) => {
203                    let name = hello
204                        .name
205                        .clone()
206                        .or_else(|| hello.sub.clone())
207                        .unwrap_or_else(|| format!("traveler {}", hello.id));
208                    presence.join(hello.id, name, traveler_color(hello.id));
209                    hellos.push(hello);
210                }
211                Event::Leave(id) => presence.leave(id),
212                Event::Pose {
213                    id,
214                    pos,
215                    rot,
216                    vel,
217                    anim,
218                } => presence.pose(
219                    id,
220                    PoseSample {
221                        t_ms: now_ms,
222                        pos,
223                        rot,
224                        vel,
225                        anim,
226                    },
227                ),
228            }
229        }
230        hellos
231    }
232
233    /// Send the local player's pose (dropped if the outbound queue is full).
234    pub fn send_pose(&self, pos: Vec3, rot: Quat, vel: Vec3, anim: u8) {
235        let _ = self.poses.try_send(Outgoing::Msg(ClientMsg::Pose {
236            ts: 0,
237            p: pos.to_array(),
238            r: [rot.x, rot.y, rot.z, rot.w],
239            v: vel.to_array(),
240            a: anim,
241        }));
242    }
243
244    /// Send one voice frame (raw PCM16 payload, presence-wire §6). Dropped if
245    /// the queue is full — voice is realtime; late frames are worthless.
246    pub fn send_voice(&self, frame: Vec<u8>) {
247        let _ = self.poses.try_send(Outgoing::Voice(frame));
248    }
249
250    /// Voice frames received since the last call, as `(speaker id, PCM16)`.
251    pub fn drain_voice(&self) -> Vec<(u32, Vec<u8>)> {
252        let mut out = Vec::new();
253        while let Ok(f) = self.voice.try_recv() {
254            out.push(f);
255        }
256        out
257    }
258}
259
260/// A stable colour per traveler id (until avatar descriptors carry real looks).
261fn traveler_color(id: u32) -> [f32; 4] {
262    let h = id.wrapping_mul(2654435761);
263    [
264        0.4 + ((h & 0xFF) as f32 / 255.0) * 0.5,
265        0.4 + (((h >> 8) & 0xFF) as f32 / 255.0) * 0.5,
266        0.4 + (((h >> 16) & 0xFF) as f32 / 255.0) * 0.5,
267        1.0,
268    ]
269}
270
271/// The socket loop: connect (trying each relay in order), send `join`, then relay
272/// poses both ways.
273async fn run(
274    urls: Vec<String>,
275    passport: String,
276    spawn: Option<String>,
277    events: std::sync::mpsc::Sender<Event>,
278    voice: std::sync::mpsc::Sender<(u32, Vec<u8>)>,
279    mut poses: tokio::sync::mpsc::Receiver<Outgoing>,
280    connected: Arc<AtomicBool>,
281) {
282    use futures_util::{SinkExt, StreamExt};
283    use tokio_tungstenite::tungstenite::Message;
284
285    // Try relays in order; the first that connects wins (fallback on failure).
286    let mut connection = None;
287    for url in &urls {
288        match tokio_tungstenite::connect_async(url).await {
289            Ok((ws, _)) => {
290                connection = Some((ws, url.clone()));
291                break;
292            }
293            Err(e) => tracing::warn!("presence: relay {url} unreachable: {e}"),
294        }
295    }
296    let (ws, url) = match connection {
297        Some(c) => c,
298        None => {
299            tracing::warn!(
300                "presence: no relay reachable ({} tried) — staying solo",
301                urls.len()
302            );
303            return;
304        }
305    };
306    tracing::info!("presence: connected to {url}");
307    connected.store(true, Ordering::Relaxed);
308    let (mut write, mut read) = ws.split();
309
310    // Announce ourselves.
311    if let Ok(join) = serde_json::to_string(&ClientMsg::Join { passport, spawn }) {
312        let _ = write.send(Message::Text(join)).await;
313    }
314
315    // Keepalive independent of the render loop: an occluded window stops
316    // pumping poses (compositor redraw throttling), and a silent socket gets
317    // idle-closed by reverse proxies. Ping from the async task instead.
318    let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(10));
319    keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
320
321    loop {
322        tokio::select! {
323            _ = keepalive.tick() => {
324                if write.send(Message::Ping(Vec::new())).await.is_err() {
325                    break;
326                }
327            }
328            incoming = read.next() => match incoming {
329                Some(Ok(Message::Binary(b))) => {
330                    // A voice frame: 4-byte LE speaker id, then the payload.
331                    if b.len() > 4 {
332                        let id = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
333                        let _ = voice.send((id, b[4..].to_vec()));
334                    }
335                }
336                Some(Ok(msg)) if msg.is_text() => {
337                    if let Ok(txt) = msg.to_text() {
338                        forward(txt, &events);
339                    }
340                }
341                Some(Ok(msg)) if msg.is_close() => break,
342                Some(Err(_)) | None => break,
343                _ => {}
344            },
345            outgoing = poses.recv() => match outgoing {
346                Some(Outgoing::Msg(msg)) => {
347                    if let Ok(txt) = serde_json::to_string(&msg) {
348                        if write.send(Message::Text(txt)).await.is_err() {
349                            break;
350                        }
351                    }
352                }
353                Some(Outgoing::Voice(frame)) => {
354                    if write.send(Message::Binary(frame)).await.is_err() {
355                        break;
356                    }
357                }
358                None => break,
359            }
360        }
361    }
362    connected.store(false, Ordering::Relaxed);
363    tracing::info!("presence: disconnected from {url}");
364}
365
366/// Decode one server message and forward it as an [`Event`].
367fn forward(txt: &str, events: &std::sync::mpsc::Sender<Event>) {
368    let Ok(msg) = serde_json::from_str::<ServerMsg>(txt) else {
369        return;
370    };
371    match msg {
372        ServerMsg::Welcome { occupants } => {
373            tracing::info!(
374                "presence: welcomed — {} traveler(s) already here",
375                occupants.len()
376            );
377            for o in occupants {
378                let _ = events.send(Event::Join(TravelerHello {
379                    id: o.id,
380                    sub: o.sub,
381                    name: o.name,
382                    avatar: o.avatar,
383                }));
384            }
385        }
386        ServerMsg::Join {
387            id,
388            sub,
389            name,
390            avatar,
391        } => {
392            tracing::info!(
393                "presence: traveler joined — id {id}{}",
394                name.as_deref()
395                    .map(|n| format!(" ({n})"))
396                    .unwrap_or_default()
397            );
398            let _ = events.send(Event::Join(TravelerHello {
399                id,
400                sub,
401                name,
402                avatar,
403            }));
404        }
405        ServerMsg::Pose { id, p, r, v, a } => {
406            let _ = events.send(Event::Pose {
407                id,
408                pos: Vec3::from_array(p),
409                rot: Quat::from_xyzw(r[0], r[1], r[2], r[3]),
410                vel: Vec3::from_array(v),
411                anim: a,
412            });
413        }
414        ServerMsg::Leave { id } => {
415            let _ = events.send(Event::Leave(id));
416        }
417        ServerMsg::Other => {}
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn encodes_pose_per_wire_spec() {
427        let json = serde_json::to_string(&ClientMsg::Pose {
428            ts: 12,
429            p: [1.0, 2.0, 3.0],
430            r: [0.0, 0.0, 0.0, 1.0],
431            v: [0.5, 0.0, 0.0],
432            a: 1,
433        })
434        .unwrap();
435        assert!(json.contains(r#""t":"pose""#));
436        assert!(json.contains(r#""p":[1.0,2.0,3.0]"#));
437    }
438
439    #[test]
440    fn decodes_server_messages_and_forwards_events() {
441        let (tx, rx) = channel();
442        forward(r#"{"t":"welcome","occupants":[{"id":7},{"id":9}]}"#, &tx);
443        forward(
444            r#"{"t":"pose","id":7,"p":[3.0,0.0,-1.0],"r":[0,0,0,1],"v":[1,0,0],"a":2}"#,
445            &tx,
446        );
447        forward(r#"{"t":"leave","id":9}"#, &tx);
448        forward(r#"{"t":"voice","id":7}"#, &tx); // ignored
449
450        let mut presence = PresenceState::new();
451        let mut joins = 0;
452        let mut poses = 0;
453        let mut leaves = 0;
454        while let Ok(evt) = rx.try_recv() {
455            match evt {
456                Event::Join(_) => joins += 1,
457                Event::Pose { .. } => poses += 1,
458                Event::Leave(_) => leaves += 1,
459            }
460        }
461        assert_eq!((joins, poses, leaves), (2, 1, 1));
462        let _ = &mut presence; // (drain covered by the presence module's tests)
463    }
464
465    #[test]
466    fn identity_rides_welcome_occupants_and_join_broadcasts() {
467        let (tx, rx) = channel();
468        // A welcome occupant with claims, and a mid-session join broadcast.
469        forward(
470            r#"{"t":"welcome","occupants":[{"id":7,"sub":"did:pixygon:abc","name":"Anders","avatar":"https://api.pixygon.io/v1/passport/avatar/did:pixygon:abc"}]}"#,
471            &tx,
472        );
473        forward(r#"{"t":"join","id":11,"name":"Kaya"}"#, &tx);
474
475        let Ok(Event::Join(a)) = rx.try_recv() else {
476            panic!("expected a join")
477        };
478        assert_eq!(a.id, 7);
479        assert_eq!(a.sub.as_deref(), Some("did:pixygon:abc"));
480        assert_eq!(a.name.as_deref(), Some("Anders"));
481        assert!(a.avatar.as_deref().unwrap().ends_with("did:pixygon:abc"));
482
483        let Ok(Event::Join(b)) = rx.try_recv() else {
484            panic!("expected a join")
485        };
486        assert_eq!(
487            (b.id, b.name.as_deref(), b.sub, b.avatar),
488            (11, Some("Kaya"), None, None)
489        );
490    }
491}