presence.rs (13042B)
1 //! Coarse presence via nostr events (kind 10555). 2 //! 3 //! Publishes only on meaningful position change (with 1s minimum gap), 4 //! plus a keep-alive heartbeat every 60s to maintain room presence. 5 //! Not intended for smooth real-time movement sync. 6 7 use enostr::{FilledKeypair, Pubkey}; 8 use glam::Vec3; 9 use nostrdb::Ndb; 10 11 use crate::{nostr_events, room_state::RoomUser, subscriptions::PresenceSubscription}; 12 13 /// Minimum position change (distance) to trigger a publish. 14 const POSITION_THRESHOLD: f32 = 0.5; 15 16 /// Minimum seconds between publishes even when moving. 17 const MIN_PUBLISH_GAP: f64 = 1.0; 18 19 /// Keep-alive interval: publish even when idle to stay visible. 20 const KEEPALIVE_INTERVAL: f64 = 60.0; 21 22 /// Seconds without a heartbeat before a remote user is considered gone. 23 const STALE_TIMEOUT: f64 = 90.0; 24 25 /// How often to check for stale users (seconds). 26 const EXPIRY_CHECK_INTERVAL: f64 = 10.0; 27 28 /// Minimum speed to consider "moving" (units/s). Below this, velocity is zeroed. 29 const MIN_SPEED: f32 = 0.1; 30 31 /// Direction change threshold (dot product). cos(30°) ≈ 0.866. 32 /// If the normalized velocity direction changes by more than ~30°, publish. 33 const DIRECTION_CHANGE_THRESHOLD: f32 = 0.866; 34 35 /// Publishes local user presence as kind 10555 events. 36 /// 37 /// Only publishes when position or velocity changes meaningfully, plus periodic 38 /// keep-alive to maintain room presence. Includes velocity for dead reckoning. 39 pub struct PresencePublisher { 40 /// Last position we published 41 last_position: Vec3, 42 /// Last velocity we published 43 last_velocity: Vec3, 44 /// Monotonic time of last publish 45 last_publish_time: f64, 46 /// Previous position sample (for computing velocity) 47 prev_position: Vec3, 48 /// Time of previous position sample 49 prev_position_time: f64, 50 /// Whether we've published at least once 51 published_once: bool, 52 } 53 54 impl PresencePublisher { 55 pub fn new() -> Self { 56 Self { 57 last_position: Vec3::ZERO, 58 last_velocity: Vec3::ZERO, 59 last_publish_time: 0.0, 60 prev_position: Vec3::ZERO, 61 prev_position_time: 0.0, 62 published_once: false, 63 } 64 } 65 66 /// Compute instantaneous velocity from position samples. 67 fn compute_velocity(&self, position: Vec3, now: f64) -> Vec3 { 68 let dt = now - self.prev_position_time; 69 if dt < 0.01 { 70 return self.last_velocity; 71 } 72 let vel = (position - self.prev_position) / dt as f32; 73 if vel.length() < MIN_SPEED { 74 Vec3::ZERO 75 } else { 76 vel 77 } 78 } 79 80 /// Check whether a publish should happen (without side effects). 81 fn should_publish(&self, position: Vec3, velocity: Vec3, now: f64) -> bool { 82 // Always publish the first time 83 if !self.published_once { 84 return true; 85 } 86 87 let elapsed = now - self.last_publish_time; 88 89 // Rate limit: never more than once per second 90 if elapsed < MIN_PUBLISH_GAP { 91 return false; 92 } 93 94 // Publish if position changed meaningfully 95 if self.last_position.distance(position) > POSITION_THRESHOLD { 96 return true; 97 } 98 99 // Publish on start/stop transitions 100 let was_moving = self.last_velocity.length() > MIN_SPEED; 101 let is_moving = velocity.length() > MIN_SPEED; 102 if was_moving != is_moving { 103 return true; 104 } 105 106 // Publish on significant direction change while moving 107 if was_moving && is_moving { 108 let old_dir = self.last_velocity.normalize(); 109 let new_dir = velocity.normalize(); 110 if old_dir.dot(new_dir) < DIRECTION_CHANGE_THRESHOLD { 111 return true; 112 } 113 } 114 115 // Keep-alive: publish periodically even when idle 116 elapsed >= KEEPALIVE_INTERVAL 117 } 118 119 /// Record that a publish happened (update internal state). 120 fn record_publish(&mut self, position: Vec3, velocity: Vec3, now: f64) { 121 self.last_position = position; 122 self.last_velocity = velocity; 123 self.last_publish_time = now; 124 self.published_once = true; 125 } 126 127 /// Maybe publish a presence heartbeat. 128 /// 129 /// Returns the ingested note if published so the caller can forward it. 130 pub fn maybe_publish( 131 &mut self, 132 ndb: &Ndb, 133 kp: FilledKeypair, 134 room_naddr: &str, 135 position: Vec3, 136 now: f64, 137 ) -> Option<nostrdb::Note<'static>> { 138 let velocity = self.compute_velocity(position, now); 139 140 // Always update position sample for velocity computation 141 self.prev_position = position; 142 self.prev_position_time = now; 143 144 if !self.should_publish(position, velocity, now) { 145 return None; 146 } 147 148 let builder = nostr_events::build_presence_event(room_naddr, position, velocity); 149 let result = nostr_events::ingest_event(builder, ndb, kp); 150 151 self.record_publish(position, velocity, now); 152 result 153 } 154 } 155 156 /// Poll for presence events and update the user list. 157 /// 158 /// Returns true if any users were added or updated. 159 pub fn poll_presence( 160 sub: &PresenceSubscription, 161 ndb: &Ndb, 162 room_naddr: &str, 163 self_pubkey: &Pubkey, 164 users: &mut Vec<RoomUser>, 165 now: f64, 166 ) -> bool { 167 let txn = nostrdb::Transaction::new(ndb).expect("txn"); 168 let notes = sub.poll(ndb, &txn); 169 let mut changed = false; 170 171 for note in ¬es { 172 // Filter to our space 173 let Some(event_space) = nostr_events::get_presence_space(note) else { 174 continue; 175 }; 176 if event_space != room_naddr { 177 continue; 178 } 179 180 let Some(position) = nostr_events::parse_presence_position(note) else { 181 continue; 182 }; 183 184 let pubkey = Pubkey::new(*note.pubkey()); 185 186 // Skip our own presence events 187 if &pubkey == self_pubkey { 188 continue; 189 } 190 191 let velocity = nostr_events::parse_presence_velocity(note); 192 let created_at = note.created_at(); 193 194 // Update or insert user 195 if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) { 196 // Skip stale events — replaceable events can arrive out of order 197 if created_at <= user.event_created_at { 198 continue; 199 } 200 // Update authoritative state; preserve display_position for smooth lerp 201 user.position = position; 202 user.velocity = velocity; 203 user.update_time = now; 204 user.last_seen = now; 205 user.event_created_at = created_at; 206 } else { 207 let mut user = RoomUser::new(pubkey, "anon".to_string(), position); 208 user.velocity = velocity; 209 user.display_position = position; // snap on first appearance 210 user.update_time = now; 211 user.last_seen = now; 212 user.event_created_at = created_at; 213 users.push(user); 214 } 215 changed = true; 216 } 217 218 changed 219 } 220 221 /// Remove users who haven't sent a heartbeat recently. 222 /// Throttled to only run every EXPIRY_CHECK_INTERVAL seconds. 223 pub struct PresenceExpiry { 224 last_check: f64, 225 } 226 227 impl PresenceExpiry { 228 pub fn new() -> Self { 229 Self { last_check: 0.0 } 230 } 231 232 /// Maybe expire stale users. Returns removed users so callers can clean up 233 /// their scene objects. Empty if the check was throttled. 234 pub fn maybe_expire(&mut self, users: &mut Vec<RoomUser>, now: f64) -> Vec<RoomUser> { 235 if now - self.last_check < EXPIRY_CHECK_INTERVAL { 236 return Vec::new(); 237 } 238 self.last_check = now; 239 let mut expired = Vec::new(); 240 let mut i = 0; 241 while i < users.len() { 242 if !users[i].is_self && (now - users[i].last_seen) >= STALE_TIMEOUT { 243 expired.push(users.swap_remove(i)); 244 } else { 245 i += 1; 246 } 247 } 248 expired 249 } 250 } 251 252 #[cfg(test)] 253 mod tests { 254 use super::*; 255 256 #[test] 257 fn test_expiry_throttle_and_cleanup() { 258 let pk1 = Pubkey::new([1; 32]); 259 let pk2 = Pubkey::new([2; 32]); 260 let pk_self = Pubkey::new([3; 32]); 261 262 let mut users = vec![ 263 { 264 let mut u = RoomUser::new(pk_self, "me".to_string(), Vec3::ZERO); 265 u.is_self = true; 266 u.last_seen = 0.0; // stale but self — should survive 267 u 268 }, 269 { 270 let mut u = RoomUser::new(pk1, "alice".to_string(), Vec3::ZERO); 271 u.last_seen = 80.0; // fresh (within 90s timeout) 272 u 273 }, 274 { 275 let mut u = RoomUser::new(pk2, "bob".to_string(), Vec3::ZERO); 276 u.last_seen = 1.0; // stale (>90s ago) 277 u 278 }, 279 ]; 280 281 let mut expiry = PresenceExpiry::new(); 282 283 // First call at t=5 — too soon (< 10s from init at 0.0), skipped 284 assert!(expiry.maybe_expire(&mut users, 5.0).is_empty()); 285 assert_eq!(users.len(), 3); // no one removed 286 287 // At t=100 — enough time, bob is stale 288 let expired = expiry.maybe_expire(&mut users, 100.0); 289 assert_eq!(expired.len(), 1); 290 assert_eq!(expired[0].display_name, "bob"); 291 assert_eq!(users.len(), 2); 292 assert!(users.iter().any(|u| u.is_self)); 293 assert!(users.iter().any(|u| u.display_name == "alice")); 294 295 // Immediately again at t=101 — throttled, skipped 296 assert!(expiry.maybe_expire(&mut users, 101.0).is_empty()); 297 } 298 299 #[test] 300 fn test_publisher_first_publish() { 301 let pub_ = PresencePublisher::new(); 302 // First publish should always happen 303 assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 0.0)); 304 } 305 306 #[test] 307 fn test_publisher_no_spam_when_idle() { 308 let mut pub_ = PresencePublisher::new(); 309 pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); 310 311 // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s 312 assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 1.0)); 313 assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 5.0)); 314 assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 10.0)); 315 assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 30.0)); 316 317 // Keep-alive triggers at 60s 318 assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 60.1)); 319 } 320 321 #[test] 322 fn test_publisher_on_movement() { 323 let mut pub_ = PresencePublisher::new(); 324 pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); 325 326 // Small movement below threshold — no publish 327 assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), Vec3::ZERO, 2.0)); 328 329 // Significant movement — publish 330 assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0)); 331 332 // But rate limited: can't publish again within 1s 333 pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0); 334 assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 2.5)); 335 336 // After 1s gap, can publish again 337 assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 3.1)); 338 } 339 340 #[test] 341 fn test_publisher_velocity_start_stop() { 342 let mut pub_ = PresencePublisher::new(); 343 let pos = Vec3::new(1.0, 0.0, 0.0); 344 pub_.record_publish(pos, Vec3::ZERO, 0.0); 345 346 // Start moving — should trigger (velocity went from zero to non-zero) 347 let vel = Vec3::new(3.0, 0.0, 0.0); 348 assert!(pub_.should_publish(pos, vel, 2.0)); 349 pub_.record_publish(pos, vel, 2.0); 350 351 // Stop moving — should trigger (velocity went from non-zero to zero) 352 assert!(pub_.should_publish(pos, Vec3::ZERO, 3.5)); 353 } 354 355 #[test] 356 fn test_publisher_velocity_direction_change() { 357 let mut pub_ = PresencePublisher::new(); 358 let pos = Vec3::new(1.0, 0.0, 0.0); 359 let vel_east = Vec3::new(3.0, 0.0, 0.0); 360 pub_.record_publish(pos, vel_east, 0.0); 361 362 // Small direction change (still mostly east) — no publish 363 let vel_slight = Vec3::new(3.0, 0.0, 0.5); 364 assert!(!pub_.should_publish(pos, vel_slight, 2.0)); 365 366 // Large direction change (east → north, 90 degrees) — should publish 367 let vel_north = Vec3::new(0.0, 0.0, 3.0); 368 assert!(pub_.should_publish(pos, vel_north, 2.0)); 369 } 370 371 #[test] 372 fn test_compute_velocity() { 373 let mut pub_ = PresencePublisher::new(); 374 pub_.prev_position = Vec3::ZERO; 375 pub_.prev_position_time = 0.0; 376 377 // 5 units in 1 second = 5 units/s 378 let vel = pub_.compute_velocity(Vec3::new(5.0, 0.0, 0.0), 1.0); 379 assert!((vel.x - 5.0).abs() < 0.01); 380 381 // Very small movement → zeroed (below MIN_SPEED) 382 pub_.prev_position = Vec3::ZERO; 383 pub_.prev_position_time = 0.0; 384 let vel = pub_.compute_velocity(Vec3::new(0.01, 0.0, 0.0), 1.0); 385 assert_eq!(vel, Vec3::ZERO); 386 } 387 }