//! Replicated event history for Call to Play coordination. use std::collections::HashSet; use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; use tokio::sync::mpsc::UnboundedSender; use crate::{PeerEvent, context::Ctx, events, network::send_call_to_play_events}; const MAX_EVENTS: usize = 4_096; const MAX_ID_CHARS: usize = 128; const MAX_GAME_ID_CHARS: usize = 256; const MAX_USERNAME_CHARS: usize = 24; const MAX_MESSAGE_CHARS: usize = 500; #[derive(Debug, Default)] pub(crate) struct CallToPlayStore { events: Vec, event_ids: HashSet, } impl CallToPlayStore { pub(crate) fn snapshot(&self) -> Vec { self.events.clone() } pub(crate) fn insert(&mut self, event: CallToPlayEvent) -> Result { validate_event(&event)?; if self.event_ids.contains(&event.id) { return Ok(false); } if self.events.len() >= MAX_EVENTS { return Err("Call to Play event history is full"); } self.event_ids.insert(event.id.clone()); self.events.push(event); Ok(true) } pub(crate) fn insert_all(&mut self, events: Vec) -> Vec { let mut accepted = Vec::new(); for event in events { match self.insert(event.clone()) { Ok(true) => accepted.push(event), Ok(false) => {} Err(err) => log::warn!("Ignoring invalid Call to Play event {}: {err}", event.id), } } accepted } } pub(crate) async fn publish( ctx: &Ctx, tx_notify_ui: &UnboundedSender, event: CallToPlayEvent, ) { match ctx.call_to_play.write().await.insert(event.clone()) { Ok(false) => return, Err(err) => { log::warn!("Rejecting local Call to Play event {}: {err}", event.id); return; } Ok(true) => {} } events::send( tx_notify_ui, PeerEvent::CallToPlayEvents(vec![event.clone()]), ); let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses(); ctx.task_tracker.spawn(async move { let deliveries = peer_addresses.into_iter().map(|peer_addr| { let event = event.clone(); async move { if let Err(err) = send_call_to_play_events(peer_addr, vec![event]).await { log::warn!("Failed to send Call to Play event to {peer_addr}: {err}"); } } }); futures::future::join_all(deliveries).await; }); } fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> { validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?; validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?; validate_nonempty(&event.actor, MAX_USERNAME_CHARS, "invalid actor")?; if event.at <= 0 { return Err("invalid event timestamp"); } match &event.action { CallToPlayAction::Create { game_id, max_players, scheduled_for, deadline, } => { validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?; if !(2..=64).contains(max_players) { return Err("max players must be between 2 and 64"); } if *deadline <= event.at { return Err("deadline must be after creation"); } if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) { return Err("scheduled call deadline must match its start time"); } } CallToPlayAction::Respond { ready_at } => { if ready_at.is_some_and(|ready| ready < event.at) { return Err("ready time cannot be before the response"); } } CallToPlayAction::SendMessage { message_id, text } => { validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?; validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?; } CallToPlayAction::AddTime { deadline } => { if *deadline <= event.at { return Err("extended deadline must be in the future"); } } CallToPlayAction::Rsvp | CallToPlayAction::Leave | CallToPlayAction::Cancel | CallToPlayAction::Start => {} } Ok(()) } fn validate_nonempty( value: &str, max_chars: usize, error: &'static str, ) -> Result<(), &'static str> { if value.trim().is_empty() || value.chars().count() > max_chars { return Err(error); } Ok(()) } #[cfg(test)] mod tests { use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; use super::CallToPlayStore; fn create_event(id: &str) -> CallToPlayEvent { CallToPlayEvent { id: id.to_string(), call_id: "call-1".to_string(), actor: "Alice".to_string(), at: 1_000, action: CallToPlayAction::Create { game_id: "game-1".to_string(), max_players: 4, scheduled_for: None, deadline: 61_000, }, } } #[test] fn deduplicates_events_without_reordering_history() { let mut store = CallToPlayStore::default(); assert!( store .insert(create_event("event-1")) .expect("valid event should be inserted") ); assert!( !store .insert(create_event("event-1")) .expect("duplicate valid event should be accepted") ); assert!( store .insert(create_event("event-2")) .expect("valid event should be inserted") ); let ids = store .snapshot() .into_iter() .map(|event| event.id) .collect::>(); assert_eq!(ids, ["event-1", "event-2"]); } #[test] fn rejects_malformed_events() { let mut store = CallToPlayStore::default(); let mut event = create_event("event-1"); event.action = CallToPlayAction::SendMessage { message_id: "message-1".to_string(), text: " ".to_string(), }; assert_eq!(store.insert(event), Err("invalid message")); assert!(store.snapshot().is_empty()); } #[test] fn merge_returns_only_new_valid_events() { let mut store = CallToPlayStore::default(); store .insert(create_event("event-1")) .expect("valid event should be inserted"); let mut invalid = create_event("invalid"); invalid.actor.clear(); let accepted = store.insert_all(vec![ create_event("event-1"), invalid, create_event("event-2"), ]); assert_eq!(accepted, [create_event("event-2")]); } }