//! Direct, author-owned Call-to-Play state. use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt, time::{SystemTime, UNIX_EPOCH}, }; use lanspread_proto::{ CallId, CallNonce, CallToPlayAction, CallToPlayAuthorEvent, CallToPlayAuthorSnapshot, ControlValidationError, EventNonce, MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR, PeerId, RuntimeSessionId, }; use crate::peer_db::PeerEndpointGeneration; pub(crate) const MAX_CALL_TO_PLAY_AUTHORS: usize = 64; pub(crate) const LOCAL_TERMINAL_EVENT_RESERVE: usize = 1; pub(crate) const LOCAL_TERMINAL_BYTE_RESERVE: usize = 512; const EXPIRED_RETENTION_MS: i64 = 5 * 60_000; const TERMINAL_RETENTION_MS: i64 = 15 * 60_000; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayLocalIntent { pub(crate) call_id: Option, pub(crate) action: CallToPlayLocalAction, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CallToPlayLocalAction { Create { game_id: String, max_players: u16, scheduled_for: Option, deadline: i64, }, Respond { ready_at: Option, }, Rsvp, SendMessage { text: String, }, Leave, Cancel, Start, AddTime { deadline: i64, }, } impl CallToPlayLocalAction { fn into_wire(self) -> CallToPlayAction { match self { Self::Create { game_id, max_players, scheduled_for, deadline, } => CallToPlayAction::Create { game_id, max_players, scheduled_for, deadline, }, Self::Respond { ready_at } => CallToPlayAction::Respond { ready_at }, Self::Rsvp => CallToPlayAction::Rsvp, Self::SendMessage { text } => CallToPlayAction::SendMessage { text }, Self::Leave => CallToPlayAction::Leave, Self::Cancel => CallToPlayAction::Cancel, Self::Start => CallToPlayAction::Start, Self::AddTime { deadline } => CallToPlayAction::AddTime { deadline }, } } const fn is_create(&self) -> bool { matches!(self, Self::Create { .. }) } const fn is_terminal(&self) -> bool { matches!(self, Self::Cancel | Self::Start) } const fn requires_creator_authority(&self) -> bool { matches!(self, Self::Cancel | Self::Start | Self::AddTime { .. }) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayReceipt { pub(crate) call_id: CallId, pub(crate) event_id: EventNonce, pub(crate) revision: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayMutation { pub(crate) revision: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayPublication { pub(crate) view: CallToPlayView, pub(crate) local_revision: u64, pub(crate) local_changed: bool, } /// A fallibility boundary captured before an atomic remote-state commit. /// /// Preparation samples the clock and computes any fallible local-pruning /// candidate without mutating the store. Projection after a remote slice /// mutation is then infallible and uses this single time boundary. #[derive(Debug)] pub(crate) struct PreparedCallToPlayPublication { now: i64, base_local_revision: u64, pruned_local: Option, } impl PreparedCallToPlayPublication { #[must_use] pub(crate) const fn local_changed(&self) -> bool { self.pruned_local.is_some() } } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayView { pub(crate) events: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CallToPlayViewEvent { pub(crate) id: EventNonce, pub(crate) call_id: CallId, pub(crate) author_id: PeerId, pub(crate) author_name: String, pub(crate) at: i64, pub(crate) action: CallToPlayAction, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct RemoteAuthorState { pub(crate) endpoint_generation: PeerEndpointGeneration, pub(crate) runtime_session_id: RuntimeSessionId, pub(crate) revision: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CallToPlayValidationError { Wire(ControlValidationError), SnapshotTooLarge { actual: usize, maximum: usize, }, SnapshotEncoding, DuplicateEventId(EventNonce), DuplicateCreate(CallId), InvalidEvent { event_id: EventNonce, reason: &'static str, }, UnauthorizedAction { event_id: EventNonce, call_id: CallId, }, MissingCreatorRoot(CallId), NonMonotonicAuthorHistory, NonMonotonicCallHistory(CallId), ActionAfterTerminal(CallId), } impl fmt::Display for CallToPlayValidationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Wire(error) => write!(formatter, "{error}"), Self::SnapshotTooLarge { actual, maximum } => write!( formatter, "Call-to-Play author snapshot is {actual} bytes; maximum is {maximum}" ), Self::SnapshotEncoding => { formatter.write_str("Call-to-Play author snapshot could not be encoded") } Self::DuplicateEventId(event_id) => { write!(formatter, "duplicate Call-to-Play event ID {event_id}") } Self::DuplicateCreate(call_id) => { write!(formatter, "duplicate Call-to-Play creator root {call_id}") } Self::InvalidEvent { event_id, reason } => { write!(formatter, "invalid Call-to-Play event {event_id}: {reason}") } Self::UnauthorizedAction { event_id, call_id } => write!( formatter, "Call-to-Play event {event_id} is not authoritative for {call_id}" ), Self::MissingCreatorRoot(call_id) => { write!(formatter, "Call-to-Play call {call_id} has no creator root") } Self::NonMonotonicAuthorHistory => { formatter.write_str("Call-to-Play author events are not timestamp ordered") } Self::NonMonotonicCallHistory(call_id) => { write!( formatter, "Call-to-Play call {call_id} has non-monotonic history" ) } Self::ActionAfterTerminal(call_id) => { write!( formatter, "Call-to-Play call {call_id} has an action after termination" ) } } } } impl std::error::Error for CallToPlayValidationError {} #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CallToPlayMutationError { InvalidIntent(&'static str), UnknownOrExpiredCall(CallId), CreatorAuthorityRequired(CallId), CallAlreadyTerminal(CallId), EventHistoryFull, RevisionExhausted, ClockUnavailable, EntropyUnavailable, InvalidSnapshot(CallToPlayValidationError), } impl fmt::Display for CallToPlayMutationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidIntent(reason) => formatter.write_str(reason), Self::UnknownOrExpiredCall(call_id) => { write!( formatter, "Call-to-Play call {call_id} is unknown or expired" ) } Self::CreatorAuthorityRequired(call_id) => { write!(formatter, "creator authority is required for {call_id}") } Self::CallAlreadyTerminal(call_id) => { write!(formatter, "Call-to-Play call {call_id} is already terminal") } Self::EventHistoryFull => { formatter.write_str("Call-to-Play local event history is full") } Self::RevisionExhausted => { formatter.write_str("Call-to-Play local revision is exhausted") } Self::ClockUnavailable => formatter.write_str("system clock is unavailable"), Self::EntropyUnavailable => { formatter.write_str("secure random number generation is unavailable") } Self::InvalidSnapshot(error) => write!(formatter, "{error}"), } } } impl std::error::Error for CallToPlayMutationError {} #[derive(Debug)] pub(crate) struct PreparedRemoteAuthor { author_id: PeerId, endpoint_generation: PeerEndpointGeneration, runtime_session_id: RuntimeSessionId, candidate: PreparedRemoteCandidate, } #[derive(Debug)] enum PreparedRemoteCandidate { Valid(CallToPlayAuthorSnapshot), Invalid(CallToPlayValidationError), } impl PreparedRemoteAuthor { /// Performs all allocation, encoding, and semantic validation without a /// store or peer-database lock. The invalid candidate is retained so the /// locked observation can apply session-clearing rules atomically. pub(crate) fn prepare( author_id: PeerId, endpoint_generation: PeerEndpointGeneration, runtime_session_id: RuntimeSessionId, snapshot: CallToPlayAuthorSnapshot, ) -> Self { let candidate = match validate_author_snapshot(author_id, &snapshot) { Ok(()) => PreparedRemoteCandidate::Valid(snapshot), Err(error) => PreparedRemoteCandidate::Invalid(error), }; Self { author_id, endpoint_generation, runtime_session_id, candidate, } } #[must_use] pub(crate) fn validation_error(&self) -> Option<&CallToPlayValidationError> { match &self.candidate { PreparedRemoteCandidate::Valid(_) => None, PreparedRemoteCandidate::Invalid(error) => Some(error), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum ObserveRemoteAuthorOutcome { Applied { session_changed: bool, }, Unchanged { generation_rebound: bool, }, IgnoredStale { generation_rebound: bool, }, EqualRevisionConflict { generation_rebound: bool, }, InvalidCleared(CallToPlayValidationError), InvalidPreserved { error: CallToPlayValidationError, generation_rebound: bool, }, InvalidAbsent(CallToPlayValidationError), AtCapacity, RejectedLocalIdentity, } impl ObserveRemoteAuthorOutcome { #[must_use] pub(crate) const fn view_changed(&self) -> bool { matches!(self, Self::Applied { .. } | Self::InvalidCleared(_)) } } #[derive(Clone, Debug)] struct RemoteAuthorSlice { endpoint_generation: PeerEndpointGeneration, runtime_session_id: RuntimeSessionId, snapshot: CallToPlayAuthorSnapshot, } #[derive(Debug)] pub(crate) struct CallToPlayStore { local_peer_id: PeerId, local: CallToPlayAuthorSnapshot, remote: BTreeMap, last_publication_at: i64, #[cfg(test)] projection_count: usize, } impl CallToPlayStore { pub(crate) fn new( local_peer_id: PeerId, _runtime_session_id: RuntimeSessionId, display_name: String, ) -> Result { let local = CallToPlayAuthorSnapshot { revision: 0, display_name, events: Vec::new(), }; validate_author_snapshot(local_peer_id, &local) .map_err(CallToPlayMutationError::InvalidSnapshot)?; ensure_local_terminal_reserve(&local, false)?; Ok(Self { local_peer_id, local, remote: BTreeMap::new(), last_publication_at: 0, #[cfg(test)] projection_count: 0, }) } pub(crate) fn publish_local( &mut self, intent: CallToPlayLocalIntent, display_name: String, ) -> Result<(CallToPlayReceipt, CallToPlayPublication), CallToPlayMutationError> { let now = now_ms()?.max(self.last_publication_at); let event_nonce = EventNonce::from_bytes(random_nonce_bytes()?); let call_nonce = intent .action .is_create() .then(|| random_nonce_bytes().map(CallNonce::from_bytes)) .transpose()?; let receipt = self.publish_local_at(intent, display_name, now, call_nonce, event_nonce)?; Ok((receipt, self.project_publication_at(now, true))) } pub(crate) fn set_local_display_name( &mut self, display_name: String, ) -> Result<(Option, CallToPlayPublication), CallToPlayMutationError> { let now = now_ms()?.max(self.last_publication_at); let mutation = self.set_local_display_name_at(display_name, now)?; let publication = self.project_publication_at(now, mutation.is_some()); Ok((mutation, publication)) } pub(crate) fn current_publication( &mut self, ) -> Result { let now = now_ms()?.max(self.last_publication_at); self.publication_at(now) } pub(crate) fn current_responder_state( &mut self, ) -> Result<(u64, Option), CallToPlayMutationError> { let now = now_ms()?.max(self.last_publication_at); self.responder_state_at(now) } pub(crate) fn local_responder_snapshot( &mut self, ) -> Result< (CallToPlayAuthorSnapshot, u64, Option), CallToPlayMutationError, > { let now = now_ms()?.max(self.last_publication_at); self.local_responder_snapshot_at(now) } fn local_responder_snapshot_at( &mut self, now: i64, ) -> Result< (CallToPlayAuthorSnapshot, u64, Option), CallToPlayMutationError, > { let (revision, publication) = self.responder_state_at(now)?; Ok((self.local.clone(), revision, publication)) } pub(crate) fn prepare_publication( &self, ) -> Result { self.prepare_publication_at(now_ms()?.max(self.last_publication_at)) } fn prepare_publication_at( &self, now: i64, ) -> Result { Ok(PreparedCallToPlayPublication { now, base_local_revision: self.local.revision, pruned_local: self.pruned_local_candidate_at(now)?, }) } #[must_use] pub(crate) fn view_from_prepared( &mut self, prepared: PreparedCallToPlayPublication, ) -> CallToPlayPublication { let PreparedCallToPlayPublication { mut now, base_local_revision, pruned_local, } = prepared; now = now.max(self.last_publication_at); let local_changed = if self.local.revision == base_local_revision { if let Some(pruned_local) = pruned_local { self.local = pruned_local; true } else { false } } else { false }; self.project_publication_at(now, local_changed) } /// Commits a prepared candidate while the caller holds the peer-database /// write lock before this store's write lock and has rechecked the pinned /// endpoint generation. pub(crate) fn observe_prepared_remote( &mut self, prepared: PreparedRemoteAuthor, ) -> ObserveRemoteAuthorOutcome { let PreparedRemoteAuthor { author_id, endpoint_generation, runtime_session_id, candidate, } = prepared; if author_id == self.local_peer_id { return ObserveRemoteAuthorOutcome::RejectedLocalIdentity; } let snapshot = match candidate { PreparedRemoteCandidate::Valid(snapshot) => snapshot, PreparedRemoteCandidate::Invalid(error) => { let Some(current) = self.remote.get_mut(&author_id) else { return ObserveRemoteAuthorOutcome::InvalidAbsent(error); }; if current.runtime_session_id != runtime_session_id { self.remote.remove(&author_id); return ObserveRemoteAuthorOutcome::InvalidCleared(error); } let generation_rebound = current.endpoint_generation != endpoint_generation; current.endpoint_generation = endpoint_generation; return ObserveRemoteAuthorOutcome::InvalidPreserved { error, generation_rebound, }; } }; if let Some(current) = self.remote.get_mut(&author_id) { let session_changed = current.runtime_session_id != runtime_session_id; if session_changed || snapshot.revision > current.snapshot.revision { *current = RemoteAuthorSlice { endpoint_generation, runtime_session_id, snapshot, }; return ObserveRemoteAuthorOutcome::Applied { session_changed }; } let generation_rebound = current.endpoint_generation != endpoint_generation; current.endpoint_generation = endpoint_generation; if snapshot.revision < current.snapshot.revision { return ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound }; } return if snapshot == current.snapshot { ObserveRemoteAuthorOutcome::Unchanged { generation_rebound } } else { ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound } }; } if self.remote.len() >= MAX_CALL_TO_PLAY_AUTHORS - 1 { return ObserveRemoteAuthorOutcome::AtCapacity; } self.remote.insert( author_id, RemoteAuthorSlice { endpoint_generation, runtime_session_id, snapshot, }, ); ObserveRemoteAuthorOutcome::Applied { session_changed: true, } } /// Clears every remote author and returns the resulting full, local-only /// publication. /// /// The operation is infallible: a failed clock sample or normal local /// prune is returned as the optional diagnostic after the remote slices /// have still been cleared and projected. Such a failure preserves the /// local author exactly. On success, the local revision changes only when /// ordinary retention pruning requires it. #[must_use = "the replacement publication and any maintenance diagnostic must be handled"] pub(crate) fn clear_remote_authors_and_project( &mut self, ) -> (CallToPlayPublication, Option) { self.clear_remote_authors_and_project_at(now_ms()) } fn clear_remote_authors_and_project_at( &mut self, now: Result, ) -> (CallToPlayPublication, Option) { let now = match now { Ok(now) => now.max(self.last_publication_at), Err(error) => { self.remote.clear(); let fallback_now = self.last_publication_at; let publication = self.project_publication_at(fallback_now, false); return (publication, Some(error)); } }; let pruned_local = self.pruned_local_candidate_at(now); self.remote.clear(); match pruned_local { Ok(pruned_local) => { let local_changed = pruned_local.is_some(); if let Some(pruned_local) = pruned_local { self.local = pruned_local; } (self.project_publication_at(now, local_changed), None) } Err(error) => (self.project_publication_at(now, false), Some(error)), } } pub(crate) fn remove_remote_author_if_generation( &mut self, author_id: PeerId, endpoint_generation: PeerEndpointGeneration, ) -> bool { if self .remote .get(&author_id) .is_none_or(|slice| slice.endpoint_generation != endpoint_generation) { return false; } self.remote.remove(&author_id).is_some() } #[must_use] pub(crate) fn remote_author_state(&self, author_id: PeerId) -> Option { self.remote.get(&author_id).map(|slice| RemoteAuthorState { endpoint_generation: slice.endpoint_generation, runtime_session_id: slice.runtime_session_id, revision: slice.snapshot.revision, }) } #[must_use] #[cfg(test)] pub(crate) fn remote_author_count(&self) -> usize { self.remote.len() } fn publish_local_at( &mut self, intent: CallToPlayLocalIntent, display_name: String, now: i64, call_nonce: Option, event_nonce: EventNonce, ) -> Result { if now <= 0 { return Err(CallToPlayMutationError::ClockUnavailable); } let CallToPlayLocalIntent { call_id, action } = intent; let is_create = action.is_create(); let is_terminal = action.is_terminal(); let requires_creator_authority = action.requires_creator_authority(); let call_id = if is_create { if call_id.is_some() { return Err(CallToPlayMutationError::InvalidIntent( "Create must not supply a call ID", )); } CallId::new( self.local_peer_id, call_nonce.ok_or(CallToPlayMutationError::EntropyUnavailable)?, ) } else { call_id.ok_or(CallToPlayMutationError::InvalidIntent( "non-Create action requires a call ID", ))? }; if requires_creator_authority && call_id.creator != self.local_peer_id { return Err(CallToPlayMutationError::CreatorAuthorityRequired(call_id)); } let retained_events = self.retained_local_events_at(now); let event_at = retained_events .last() .map_or(now, |event| now.max(event.at)); if !is_create { let Some(window) = self.call_window_at(call_id, now, &retained_events) else { return Err(CallToPlayMutationError::UnknownOrExpiredCall(call_id)); }; if window.terminal_at.is_some() { return Err(CallToPlayMutationError::CallAlreadyTerminal(call_id)); } } let mut candidate = CallToPlayAuthorSnapshot { revision: self .local .revision .checked_add(1) .ok_or(CallToPlayMutationError::RevisionExhausted)?, display_name, events: retained_events, }; candidate.events.push(CallToPlayAuthorEvent { id: event_nonce, call_id, at: event_at, action: action.into_wire(), }); validate_author_snapshot(self.local_peer_id, &candidate) .map_err(CallToPlayMutationError::InvalidSnapshot)?; ensure_local_terminal_reserve(&candidate, is_terminal)?; self.local = candidate; Ok(CallToPlayReceipt { call_id, event_id: event_nonce, revision: self.local.revision, }) } fn set_local_display_name_at( &mut self, display_name: String, now: i64, ) -> Result, CallToPlayMutationError> { let retained_events = self.retained_local_events_at(now); if display_name == self.local.display_name && retained_events == self.local.events { return Ok(None); } let candidate = CallToPlayAuthorSnapshot { revision: self .local .revision .checked_add(1) .ok_or(CallToPlayMutationError::RevisionExhausted)?, display_name, events: retained_events, }; validate_author_snapshot(self.local_peer_id, &candidate) .map_err(CallToPlayMutationError::InvalidSnapshot)?; self.local = candidate; Ok(Some(CallToPlayMutation { revision: self.local.revision, })) } fn prune_local_at( &mut self, now: i64, ) -> Result, CallToPlayMutationError> { let Some(candidate) = self.pruned_local_candidate_at(now)? else { return Ok(None); }; self.local = candidate; Ok(Some(CallToPlayMutation { revision: self.local.revision, })) } fn pruned_local_candidate_at( &self, now: i64, ) -> Result, CallToPlayMutationError> { let expired = self.expired_local_call_ids_at(now); if expired.is_empty() { return Ok(None); } let retained_events = self .local .events .iter() .filter(|event| !expired.contains(&event.call_id)) .cloned() .collect(); let candidate = CallToPlayAuthorSnapshot { revision: self .local .revision .checked_add(1) .ok_or(CallToPlayMutationError::RevisionExhausted)?, display_name: self.local.display_name.clone(), events: retained_events, }; validate_author_snapshot(self.local_peer_id, &candidate) .map_err(CallToPlayMutationError::InvalidSnapshot)?; Ok(Some(candidate)) } #[cfg(test)] fn local_snapshot_at( &mut self, now: i64, ) -> Result { self.prune_local_at(now)?; Ok(self.local.clone()) } #[cfg(test)] fn view_at(&mut self, now: i64) -> Result { Ok(self.publication_at(now)?.view) } fn publication_at( &mut self, now: i64, ) -> Result { let local_changed = self.prune_local_at(now)?.is_some(); Ok(self.project_publication_at(now, local_changed)) } fn responder_state_at( &mut self, now: i64, ) -> Result<(u64, Option), CallToPlayMutationError> { let local_changed = self.prune_local_at(now)?.is_some(); if !local_changed { return Ok((self.local.revision, None)); } let publication = self.project_publication_at(now, true); Ok((publication.local_revision, Some(publication))) } fn project_publication_at(&mut self, now: i64, local_changed: bool) -> CallToPlayPublication { let now = now.max(self.last_publication_at); self.last_publication_at = now; #[cfg(test)] { self.projection_count += 1; } CallToPlayPublication { view: self.project_view_at(now), local_revision: self.local.revision, local_changed, } } fn project_view_at(&self, now: i64) -> CallToPlayView { let windows = self.call_windows(); let mut events = Vec::new(); Self::extend_visible_author_events( self.local_peer_id, &self.local, now, &windows, &mut events, ); for (author_id, slice) in &self.remote { Self::extend_visible_author_events( *author_id, &slice.snapshot, now, &windows, &mut events, ); } events.sort_by_key(|event| (event.at, event.call_id, event.author_id, event.id)); CallToPlayView { events } } fn extend_visible_author_events( author_id: PeerId, snapshot: &CallToPlayAuthorSnapshot, now: i64, windows: &HashMap, output: &mut Vec, ) { for event in &snapshot.events { let Some(window) = windows.get(&event.call_id) else { continue; }; if !window.is_visible_at(now) || event.at < window.created_at || window .terminal_at .is_some_and(|terminal_at| event.at > terminal_at) { continue; } output.push(CallToPlayViewEvent { id: event.id, call_id: event.call_id, author_id, author_name: snapshot.display_name.clone(), at: event.at, action: event.action.clone(), }); } } fn retained_local_events_at(&self, now: i64) -> Vec { let expired = self.expired_local_call_ids_at(now); self.local .events .iter() .filter(|event| !expired.contains(&event.call_id)) .cloned() .collect() } fn expired_local_call_ids_at(&self, now: i64) -> HashSet { let local_call_ids = self .local .events .iter() .map(|event| event.call_id) .collect::>(); if local_call_ids.is_empty() { return HashSet::new(); } self.call_windows() .into_iter() .filter_map(|(call_id, window)| { (local_call_ids.contains(&call_id) && !window.is_visible_at(now)).then_some(call_id) }) .collect() } fn call_window_at( &self, call_id: CallId, now: i64, local_events: &[CallToPlayAuthorEvent], ) -> Option { let window = if call_id.creator == self.local_peer_id { call_window_from_creator_events(call_id, local_events) } else { self.remote .get(&call_id.creator) .and_then(|slice| call_window_from_creator_events(call_id, &slice.snapshot.events)) }?; window.is_visible_at(now).then_some(window) } fn call_windows(&self) -> HashMap { let mut windows = call_windows_from_creator(self.local_peer_id, &self.local.events); for (author_id, slice) in &self.remote { windows.extend(call_windows_from_creator( *author_id, &slice.snapshot.events, )); } windows } } fn validate_author_snapshot( author_id: PeerId, snapshot: &CallToPlayAuthorSnapshot, ) -> Result<(), CallToPlayValidationError> { snapshot .validate() .map_err(CallToPlayValidationError::Wire)?; let encoded_size = serde_json::to_vec(snapshot) .map_err(|_| CallToPlayValidationError::SnapshotEncoding)? .len(); if encoded_size > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES { return Err(CallToPlayValidationError::SnapshotTooLarge { actual: encoded_size, maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, }); } let mut event_ids = HashSet::with_capacity(snapshot.events.len()); let mut creator_calls = HashMap::::new(); if snapshot .events .windows(2) .any(|events| events[0].at > events[1].at) { return Err(CallToPlayValidationError::NonMonotonicAuthorHistory); } for event in &snapshot.events { if !event_ids.insert(event.id) { return Err(CallToPlayValidationError::DuplicateEventId(event.id)); } validate_event_fields(event)?; if is_creator_only_action(&event.action) && event.call_id.creator != author_id { return Err(CallToPlayValidationError::UnauthorizedAction { event_id: event.id, call_id: event.call_id, }); } if let CallToPlayAction::Create { deadline, .. } = event.action && creator_calls .insert( event.call_id, CreatorValidationState { created_at: event.at, last_at: event.at, deadline, terminal: false, }, ) .is_some() { return Err(CallToPlayValidationError::DuplicateCreate(event.call_id)); } } let mut seen_roots = HashSet::new(); for event in &snapshot.events { if event.call_id.creator != author_id { continue; } if matches!(event.action, CallToPlayAction::Create { .. }) { seen_roots.insert(event.call_id); continue; } if !seen_roots.contains(&event.call_id) { return Err(CallToPlayValidationError::MissingCreatorRoot(event.call_id)); } let state = creator_calls .get_mut(&event.call_id) .ok_or(CallToPlayValidationError::MissingCreatorRoot(event.call_id))?; if event.at < state.created_at || event.at < state.last_at { return Err(CallToPlayValidationError::NonMonotonicCallHistory( event.call_id, )); } if state.terminal { return Err(CallToPlayValidationError::ActionAfterTerminal( event.call_id, )); } if let CallToPlayAction::AddTime { deadline } = event.action { if deadline <= state.deadline { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "AddTime must extend the current deadline", }); } state.deadline = deadline; } if matches!( event.action, CallToPlayAction::Cancel | CallToPlayAction::Start ) { state.terminal = true; } state.last_at = event.at; } Ok(()) } fn validate_event_fields(event: &CallToPlayAuthorEvent) -> Result<(), CallToPlayValidationError> { event.validate().map_err(CallToPlayValidationError::Wire)?; if event.at <= 0 { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "event timestamp must be positive", }); } match &event.action { CallToPlayAction::Create { max_players, scheduled_for, deadline, .. } => { if !(2..=64).contains(max_players) { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "max players must be between 2 and 64", }); } if *deadline <= event.at { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "deadline must be after creation", }); } if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "scheduled call deadline must match its start time", }); } checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?; } CallToPlayAction::Respond { ready_at } => { if ready_at.is_some_and(|ready| ready < event.at) { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "ready time cannot be before the response", }); } } CallToPlayAction::AddTime { deadline } => { if *deadline <= event.at { return Err(CallToPlayValidationError::InvalidEvent { event_id: event.id, reason: "extended deadline must be after the action", }); } checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?; } CallToPlayAction::Cancel | CallToPlayAction::Start => { checked_retention_boundary(event.at, TERMINAL_RETENTION_MS, event.id)?; } CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => { } } Ok(()) } fn checked_retention_boundary( timestamp: i64, retention: i64, event_id: EventNonce, ) -> Result { timestamp .checked_add(retention) .ok_or(CallToPlayValidationError::InvalidEvent { event_id, reason: "timestamp overflows its retention boundary", }) } const fn is_creator_only_action(action: &CallToPlayAction) -> bool { matches!( action, CallToPlayAction::Create { .. } | CallToPlayAction::Cancel | CallToPlayAction::Start | CallToPlayAction::AddTime { .. } ) } fn ensure_local_terminal_reserve( snapshot: &CallToPlayAuthorSnapshot, terminal_action: bool, ) -> Result<(), CallToPlayMutationError> { let event_limit = if terminal_action { MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR } else { MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - LOCAL_TERMINAL_EVENT_RESERVE }; if snapshot.events.len() > event_limit { return Err(CallToPlayMutationError::EventHistoryFull); } let byte_limit = if terminal_action { MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES } else { MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES - LOCAL_TERMINAL_BYTE_RESERVE }; let encoded_size = serde_json::to_vec(snapshot) .map_err(|_| { CallToPlayMutationError::InvalidSnapshot(CallToPlayValidationError::SnapshotEncoding) })? .len(); if encoded_size > byte_limit { return Err(CallToPlayMutationError::EventHistoryFull); } Ok(()) } #[derive(Clone, Copy, Debug)] struct CreatorValidationState { created_at: i64, last_at: i64, deadline: i64, terminal: bool, } #[derive(Clone, Copy, Debug)] struct CallWindow { created_at: i64, deadline: i64, terminal_at: Option, } impl CallWindow { fn is_visible_at(self, now: i64) -> bool { let boundary = self.terminal_at.map_or_else( || { self.deadline .checked_add(EXPIRED_RETENTION_MS) .expect("validated deadline retention cannot overflow") }, |terminal_at| { terminal_at .checked_add(TERMINAL_RETENTION_MS) .expect("validated terminal retention cannot overflow") }, ); now <= boundary } } fn call_windows_from_creator( creator: PeerId, events: &[CallToPlayAuthorEvent], ) -> HashMap { let mut windows = HashMap::new(); for event in events { if event.call_id.creator != creator { continue; } match event.action { CallToPlayAction::Create { deadline, .. } => { windows.insert( event.call_id, CallWindow { created_at: event.at, deadline, terminal_at: None, }, ); } CallToPlayAction::AddTime { deadline } => { if let Some(window) = windows.get_mut(&event.call_id) { window.deadline = deadline; } } CallToPlayAction::Cancel | CallToPlayAction::Start => { if let Some(window) = windows.get_mut(&event.call_id) { window.terminal_at = Some(event.at); } } CallToPlayAction::Respond { .. } | CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => {} } } windows } fn call_window_from_creator_events( call_id: CallId, events: &[CallToPlayAuthorEvent], ) -> Option { call_windows_from_creator(call_id.creator, events).remove(&call_id) } fn now_ms() -> Result { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| CallToPlayMutationError::ClockUnavailable)? .as_millis(); i64::try_from(millis).map_err(|_| CallToPlayMutationError::ClockUnavailable) } fn random_nonce_bytes() -> Result<[u8; 16], CallToPlayMutationError> { let mut bytes = [0_u8; 16]; rustls::crypto::aws_lc_rs::default_provider() .secure_random .fill(&mut bytes) .map_err(|_| CallToPlayMutationError::EntropyUnavailable)?; Ok(bytes) } #[cfg(test)] mod tests { use std::net::SocketAddr; use lanspread_proto::{ CallToPlayAuthorSnapshot, ControlValidationError, LibrarySnapshot, MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS, PeerEndpoint, }; use super::*; use crate::peer_db::PeerGameDB; const NOW: i64 = 8_000_000_000_000; fn peer(seed: u8) -> PeerId { PeerId::from_bytes([seed; 32]) } fn session(seed: u8) -> RuntimeSessionId { RuntimeSessionId::from_bytes([seed; 16]) } fn nonce(value: u128) -> [u8; 16] { value.to_be_bytes() } fn event_nonce(value: u128) -> EventNonce { EventNonce::from_bytes(nonce(value)) } fn call_nonce(value: u128) -> CallNonce { CallNonce::from_bytes(nonce(value)) } fn store(local_peer: PeerId) -> CallToPlayStore { CallToPlayStore::new(local_peer, session(1), "Local".to_owned()) .expect("test store should be valid") } fn snapshot( revision: u64, display_name: &str, events: Vec, ) -> CallToPlayAuthorSnapshot { CallToPlayAuthorSnapshot { revision, display_name: display_name.to_owned(), events, } } fn create_event( creator: PeerId, call_id: CallId, id: u128, at: i64, deadline: i64, ) -> CallToPlayAuthorEvent { assert_eq!(creator, call_id.creator); CallToPlayAuthorEvent { id: event_nonce(id), call_id, at, action: CallToPlayAction::Create { game_id: "game".to_owned(), max_players: 4, scheduled_for: None, deadline, }, } } fn action_event( call_id: CallId, id: u128, at: i64, action: CallToPlayAction, ) -> CallToPlayAuthorEvent { CallToPlayAuthorEvent { id: event_nonce(id), call_id, at, action, } } fn endpoint_generations(count: usize) -> Vec { let mut db = PeerGameDB::new(); let endpoint = PeerEndpoint::new(peer(250), SocketAddr::from(([127, 0, 0, 1], 31_337))); (0..count) .map(|index| { let ticket = db .begin_candidate_negotiation(endpoint) .expect("candidate ticket"); db.commit_authenticated_snapshot( endpoint, ticket, RuntimeSessionId::from_bytes(nonce(index as u128)), Some(LibrarySnapshot { revision: index as u64, games: Vec::new(), }), ) .expect("commit should succeed") .expect("ticket should remain current") .endpoint_generation }) .collect() } fn prepare( author: PeerId, generation: PeerEndpointGeneration, runtime_session_id: RuntimeSessionId, snapshot: CallToPlayAuthorSnapshot, ) -> PreparedRemoteAuthor { PreparedRemoteAuthor::prepare(author, generation, runtime_session_id, snapshot) } fn create_intent(deadline: i64) -> CallToPlayLocalIntent { CallToPlayLocalIntent { call_id: None, action: CallToPlayLocalAction::Create { game_id: "game".to_owned(), max_players: 4, scheduled_for: None, deadline, }, } } fn intent(call_id: CallId, action: CallToPlayLocalAction) -> CallToPlayLocalIntent { CallToPlayLocalIntent { call_id: Some(call_id), action, } } #[test] fn local_publish_generates_typed_ids_and_one_revision() { let local = peer(1); let mut store = store(local); let receipt = store .publish_local_at( create_intent(NOW + 60_000), "Alice".to_owned(), NOW, Some(call_nonce(7)), event_nonce(8), ) .expect("valid Create should publish"); assert_eq!(receipt.call_id, CallId::new(local, call_nonce(7))); assert_eq!(receipt.event_id, event_nonce(8)); assert_eq!(receipt.revision, 1); let local_snapshot = store.local_snapshot_at(NOW).expect("snapshot"); assert_eq!(local_snapshot.revision, 1); assert_eq!(local_snapshot.display_name, "Alice"); assert_eq!(local_snapshot.events.len(), 1); assert_eq!(local_snapshot.events[0].at, NOW); let remote_call = CallId::new(peer(2), call_nonce(9)); let before = store.local_snapshot_at(NOW).expect("snapshot"); assert_eq!( store.publish_local_at( intent(remote_call, CallToPlayLocalAction::Start), "Alice".to_owned(), NOW + 1, None, event_nonce(10), ), Err(CallToPlayMutationError::CreatorAuthorityRequired( remote_call )) ); assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); for invalid in [ CallToPlayLocalIntent { call_id: Some(receipt.call_id), action: CallToPlayLocalAction::Create { game_id: "game".to_owned(), max_players: 4, scheduled_for: None, deadline: NOW + 60_000, }, }, CallToPlayLocalIntent { call_id: None, action: CallToPlayLocalAction::Rsvp, }, ] { assert!(matches!( store.publish_local_at( invalid, "Alice".to_owned(), NOW + 1, Some(call_nonce(11)), event_nonce(12), ), Err(CallToPlayMutationError::InvalidIntent(_)) )); assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); } } #[test] fn display_name_only_change_is_bounded_and_published() { let mut store = store(peer(1)); assert_eq!(store.local.revision, 0); assert_eq!( store .set_local_display_name_at("Alice".to_owned(), NOW) .expect("valid name"), Some(CallToPlayMutation { revision: 1 }) ); assert_eq!( store .set_local_display_name_at("Alice".to_owned(), NOW) .expect("same name is a no-op"), None ); let before = store.local_snapshot_at(NOW).expect("snapshot"); assert!( store .set_local_display_name_at( "x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1), NOW, ) .is_err() ); assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before); } #[test] fn terminal_reserve_keeps_one_settlement_slot() { for terminal in [CallToPlayLocalAction::Start, CallToPlayLocalAction::Cancel] { let local = peer(1); let call_id = CallId::new(local, call_nonce(1)); let mut store = store(local); let mut events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - 1); events.push(create_event(local, call_id, 1, NOW, NOW + 60_000)); events.extend((2..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128).map(|id| { action_event( call_id, id, NOW + i64::try_from(id).expect("event index fits in i64"), CallToPlayAction::Rsvp, ) })); store.local.events = events; store.local.revision = 1; validate_author_snapshot(local, &store.local).expect("full reserved history is valid"); assert_eq!( store.publish_local_at( intent(call_id, CallToPlayLocalAction::Rsvp), "Local".to_owned(), NOW + 50_000, None, event_nonce(10_000), ), Err(CallToPlayMutationError::EventHistoryFull) ); let receipt = store .publish_local_at( intent(call_id, terminal), "Local".to_owned(), NOW + 50_000, None, event_nonce(10_001), ) .expect("terminal action must use the reserved slot"); assert_eq!(receipt.revision, 2); assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); assert_eq!( store .set_local_display_name_at("Renamed".to_owned(), NOW + 50_001) .expect("a non-event mutation does not consume another event slot"), Some(CallToPlayMutation { revision: 3 }) ); assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); } } #[test] fn pruning_keeps_exact_boundaries_then_removes_without_tombstones_and_bumps() { let local = peer(1); let mut unresolved = store(local); let deadline = NOW + 1_000; unresolved .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("create"); assert_eq!( unresolved .local_snapshot_at(deadline + EXPIRED_RETENTION_MS) .expect("exact boundary") .events .len(), 1 ); let pruned = unresolved .local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("past boundary"); assert!(pruned.events.is_empty()); assert_eq!(pruned.revision, 2); let mut terminal = store(local); let create = terminal .publish_local_at( create_intent(NOW + 60_000), "Local".to_owned(), NOW, Some(call_nonce(2)), event_nonce(2), ) .expect("create"); terminal .publish_local_at( intent(create.call_id, CallToPlayLocalAction::Start), "Local".to_owned(), NOW + 10, None, event_nonce(3), ) .expect("start"); assert_eq!( terminal .local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS) .expect("exact terminal boundary") .events .len(), 2 ); let pruned = terminal .local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS + 1) .expect("past terminal boundary"); assert!(pruned.events.is_empty(), "no terminal tombstone remains"); assert_eq!(pruned.revision, 3); } #[test] fn responder_reads_project_only_when_local_pruning_changes_state() { let local = peer(1); let deadline = NOW + 1_000; let boundary = deadline + EXPIRED_RETENTION_MS; let mut store = store(local); store .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("create"); let (revision, publication) = store .responder_state_at(boundary) .expect("exact-boundary Pong state"); assert_eq!(revision, 1); assert!(publication.is_none()); assert_eq!(store.projection_count, 0); let (snapshot, revision, publication) = store .local_responder_snapshot_at(boundary) .expect("exact-boundary Hello state"); assert_eq!(snapshot.revision, revision); assert!(publication.is_none()); assert_eq!(store.projection_count, 0); let (revision, publication) = store .responder_state_at(boundary + 1) .expect("expired Pong state"); let publication = publication.expect("a local prune requires one full publication"); assert_eq!(revision, 2); assert_eq!(publication.local_revision, revision); assert!(publication.local_changed); assert!(publication.view.events.is_empty()); assert_eq!(store.projection_count, 1); let (snapshot, revision, publication) = store .local_responder_snapshot_at(boundary + 1) .expect("already-pruned Hello state"); assert_eq!(snapshot.revision, revision); assert_eq!(revision, 2); assert!(publication.is_none()); assert_eq!(store.projection_count, 1); } #[test] fn responder_snapshot_excludes_remote_author_slices_from_full_local_view() { let local = peer(1); let participant = peer(2); let generation = endpoint_generations(1)[0]; let mut store = store(local); let create = store .publish_local_at( create_intent(NOW + 60_000), "Alice".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("local Create should publish"); assert!(matches!( store.observe_prepared_remote(prepare( participant, generation, session(2), snapshot( 1, "Bob", vec![action_event( create.call_id, 2, NOW + 1, CallToPlayAction::Rsvp, )], ), )), ObserveRemoteAuthorOutcome::Applied { .. } )); let full_view = store.view_at(NOW + 2).expect("full local view"); assert_eq!(full_view.events.len(), 2); assert_eq!(full_view.events[0].author_id, local); assert_eq!(full_view.events[1].author_id, participant); let (responder_snapshot, revision, publication) = store .local_responder_snapshot_at(NOW + 2) .expect("local responder snapshot"); assert_eq!(responder_snapshot.revision, revision); assert!(publication.is_none()); assert_eq!( responder_snapshot .events .iter() .map(|event| event.id) .collect::>(), [event_nonce(1)] ); } #[test] fn bulk_remote_clear_preserves_local_author_and_projects_a_local_only_view() { let creator = peer(1); let participant = peer(2); let local = peer(3); let generations = endpoint_generations(2); let remote_call = CallId::new(creator, call_nonce(1)); let mut store = store(local); store.observe_prepared_remote(prepare( creator, generations[0], session(2), snapshot( 1, "Alice", vec![create_event(creator, remote_call, 1, NOW, NOW + 60_000)], ), )); let local_call = store .publish_local_at( create_intent(NOW + 60_000), "Local".to_owned(), NOW + 1, Some(call_nonce(2)), event_nonce(2), ) .expect("local Create") .call_id; store .publish_local_at( intent(remote_call, CallToPlayLocalAction::Rsvp), "Local".to_owned(), NOW + 2, None, event_nonce(3), ) .expect("local RSVP to the remote call"); store.observe_prepared_remote(prepare( participant, generations[1], session(3), snapshot( 1, "Bob", vec![action_event( remote_call, 4, NOW + 3, CallToPlayAction::Rsvp, )], ), )); assert_eq!(store.remote_author_count(), 2); assert_eq!( store.view_at(NOW + 4).expect("combined view").events.len(), 4 ); let local_before = store.local.clone(); let projections_before = store.projection_count; let (publication, diagnostic) = store.clear_remote_authors_and_project_at(Ok(NOW + 4)); assert_eq!(diagnostic, None); assert_eq!(store.local, local_before); assert_eq!(publication.local_revision, local_before.revision); assert!(!publication.local_changed); assert_eq!(store.remote_author_count(), 0); assert!(store.remote_author_state(creator).is_none()); assert!(store.remote_author_state(participant).is_none()); assert_eq!(store.projection_count, projections_before + 1); assert_eq!(publication.view.events.len(), 1); assert_eq!(publication.view.events[0].call_id, local_call); assert_eq!(publication.view.events[0].author_id, local); assert_eq!( store .local .events .iter() .map(|event| event.id) .collect::>(), [event_nonce(2), event_nonce(3)], "the root-gated view must not erase the local author slice" ); } #[test] fn bulk_remote_clear_falls_back_after_clock_or_prune_failure() { let generation = endpoint_generations(1)[0]; let remote = peer(1); let local = peer(2); let other_remote = peer(3); let remote_call = CallId::new(remote, call_nonce(1)); let mut clock_failure = store(local); let local_call = clock_failure .publish_local_at( create_intent(NOW + 60_000), "Local".to_owned(), NOW, Some(call_nonce(2)), event_nonce(1), ) .expect("local Create") .call_id; clock_failure.observe_prepared_remote(prepare( remote, generation, session(2), snapshot( 1, "Remote", vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)], ), )); clock_failure.observe_prepared_remote(prepare( other_remote, generation, session(3), snapshot(0, "Other", Vec::new()), )); assert_eq!(clock_failure.remote_author_count(), 2); let local_before = clock_failure.local.clone(); let (publication, diagnostic) = clock_failure .clear_remote_authors_and_project_at(Err(CallToPlayMutationError::ClockUnavailable)); assert_eq!(diagnostic, Some(CallToPlayMutationError::ClockUnavailable)); assert_eq!(clock_failure.local, local_before); assert_eq!(clock_failure.remote_author_count(), 0); assert_eq!(publication.local_revision, local_before.revision); assert!(!publication.local_changed); assert_eq!(publication.view.events.len(), 1); assert_eq!(publication.view.events[0].call_id, local_call); let deadline = NOW + 100; let mut prune_failure = store(local); prune_failure .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(3)), event_nonce(3), ) .expect("expiring local Create"); prune_failure.local.revision = u64::MAX; prune_failure.observe_prepared_remote(prepare( remote, generation, session(2), snapshot( 1, "Remote", vec![create_event(remote, remote_call, 4, NOW, NOW + 60_000)], ), )); prune_failure.observe_prepared_remote(prepare( other_remote, generation, session(3), snapshot(0, "Other", Vec::new()), )); assert_eq!(prune_failure.remote_author_count(), 2); let local_before = prune_failure.local.clone(); let (publication, diagnostic) = prune_failure .clear_remote_authors_and_project_at(Ok(deadline + EXPIRED_RETENTION_MS + 1)); assert_eq!(diagnostic, Some(CallToPlayMutationError::RevisionExhausted)); assert_eq!(prune_failure.local, local_before); assert_eq!(prune_failure.remote_author_count(), 0); assert_eq!(publication.local_revision, u64::MAX); assert!(!publication.local_changed); assert!(publication.view.events.is_empty()); } #[test] fn bulk_remote_clear_is_idempotent_after_one_normal_local_prune() { let generation = endpoint_generations(1)[0]; let remote = peer(1); let local = peer(2); let deadline = NOW + 100; let mut store = store(local); store .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("expiring local Create"); let remote_call = CallId::new(remote, call_nonce(2)); store.observe_prepared_remote(prepare( remote, generation, session(2), snapshot( 1, "Remote", vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)], ), )); let clear_at = deadline + EXPIRED_RETENTION_MS + 1; let (first, first_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at)); assert_eq!(first_diagnostic, None); assert!(first.local_changed); assert_eq!(first.local_revision, 2); assert!(first.view.events.is_empty()); assert_eq!(store.remote_author_count(), 0); let local_after_first = store.local.clone(); let (second, second_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at)); assert_eq!(second_diagnostic, None); assert!(!second.local_changed); assert_eq!(second.local_revision, 2); assert_eq!(second.view, first.view); assert_eq!(store.local, local_after_first); assert_eq!(store.remote_author_count(), 0); } #[test] fn prepared_publication_freezes_one_boundary_and_fails_before_remote_commit() { let local = peer(1); let deadline = NOW + 1_000; let mut pruning_store = store(local); pruning_store .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("create"); let exact = pruning_store .prepare_publication_at(deadline + EXPIRED_RETENTION_MS) .expect("exact boundary preparation"); assert!(!exact.local_changed()); let publication = pruning_store.view_from_prepared(exact); assert_eq!(publication.view.events.len(), 1); assert_eq!(publication.local_revision, 1); let expired = pruning_store .prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("past-boundary preparation"); assert!(expired.local_changed()); assert_eq!( pruning_store.local.revision, 1, "preparation is transactional" ); drop(expired); assert_eq!( pruning_store.local.revision, 1, "dropping a token is a no-op" ); assert_eq!(pruning_store.local.events.len(), 1); let expired = pruning_store .prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("repeat past-boundary preparation"); let publication = pruning_store.view_from_prepared(expired); assert!(publication.view.events.is_empty()); assert_eq!(publication.local_revision, 2); assert!(publication.local_changed); let mut exhausted = store(local); exhausted .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(2)), event_nonce(2), ) .expect("create"); exhausted.local.revision = u64::MAX; let before = exhausted.local.clone(); assert!(matches!( exhausted.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1), Err(CallToPlayMutationError::RevisionExhausted) )); assert_eq!(exhausted.local, before); } #[test] fn an_old_prepared_projection_cannot_overwrite_a_newer_timer_view() { let author = peer(1); let generation = endpoint_generations(1)[0]; let call_id = CallId::new(author, call_nonce(1)); let deadline = NOW + 100; let mut store = store(peer(2)); store.observe_prepared_remote(prepare( author, generation, session(2), snapshot( 1, "Alice", vec![create_event(author, call_id, 1, NOW, deadline)], ), )); let old = store .prepare_publication_at(deadline + EXPIRED_RETENTION_MS) .expect("old projection"); let newer = store .publication_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("newer projection"); assert!(newer.view.events.is_empty()); let replayed = store.view_from_prepared(old); assert!(replayed.view.events.is_empty()); } #[test] fn add_time_recovers_during_the_five_minute_window() { let local = peer(1); let mut store = store(local); let deadline = NOW + 100; let created = store .publish_local_at( create_intent(deadline), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("create"); let recovery_time = deadline + EXPIRED_RETENTION_MS; store .publish_local_at( intent( created.call_id, CallToPlayLocalAction::AddTime { deadline: recovery_time + 60_000, }, ), "Local".to_owned(), recovery_time, None, event_nonce(2), ) .expect("AddTime at the exact recovery boundary should revive the call"); assert_eq!( store .local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("snapshot") .events .len(), 2 ); } #[test] fn participant_slice_is_retained_hidden_and_creator_departure_hides_call() { let local = peer(3); let creator = peer(1); let participant = peer(2); let call_id = CallId::new(creator, call_nonce(1)); let generations = endpoint_generations(3); let mut store = store(local); let participant_event = action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp); assert!(matches!( store.observe_prepared_remote(prepare( participant, generations[0], session(2), snapshot(1, "Same name", vec![participant_event.clone()]), )), ObserveRemoteAuthorOutcome::Applied { .. } )); assert!(store.view_at(NOW + 2).expect("view").events.is_empty()); assert!(store.remote_author_state(participant).is_some()); let root = create_event(creator, call_id, 1, NOW, NOW + 60_000); store.observe_prepared_remote(prepare( creator, generations[1], session(3), snapshot(1, "Same name", vec![root.clone()]), )); let view = store.view_at(NOW + 2).expect("view"); assert_eq!(view.events.len(), 2); assert_eq!(view.events[0].author_id, creator); assert_eq!(view.events[1].author_id, participant); assert_eq!(view.events[0].author_name, view.events[1].author_name); assert!(store.remove_remote_author_if_generation(participant, generations[0])); let view = store.view_at(NOW + 2).expect("participant departure view"); assert_eq!(view.events.len(), 1); assert_eq!(view.events[0].author_id, creator); assert!(matches!( store.observe_prepared_remote(prepare( participant, generations[0], session(2), snapshot(1, "Same name", vec![participant_event]), )), ObserveRemoteAuthorOutcome::Applied { .. } )); assert!(!store.remove_remote_author_if_generation(creator, generations[0])); assert!(store.remove_remote_author_if_generation(creator, generations[1])); assert!(store.view_at(NOW + 2).expect("view").events.is_empty()); assert!(store.remote_author_state(participant).is_some()); } #[test] #[expect( clippy::too_many_lines, reason = "one state-transition matrix keeps its shared setup and assertions together" )] fn same_session_stale_and_invalid_preserve_and_rebind_but_new_invalid_clears() { let local = peer(9); let author = peer(1); let call_id = CallId::new(author, call_nonce(1)); let generations = endpoint_generations(6); let mut store = store(local); let root = create_event(author, call_id, 1, NOW, NOW + 60_000); assert_eq!( store.observe_prepared_remote(prepare( author, generations[0], session(2), snapshot(5, "Alice", vec![root.clone()]), )), ObserveRemoteAuthorOutcome::Applied { session_changed: true } ); assert_eq!( store.observe_prepared_remote(prepare( author, generations[1], session(2), snapshot(4, "Lower", vec![root.clone()]), )), ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound: true } ); assert_eq!( store .remote_author_state(author) .expect("remote author should remain") .revision, 5 ); assert_eq!( store.observe_prepared_remote(prepare( author, generations[1], session(2), snapshot(5, "Alice", vec![root.clone()]), )), ObserveRemoteAuthorOutcome::Unchanged { generation_rebound: false } ); assert!(matches!( store.observe_prepared_remote(prepare( author, generations[2], session(2), snapshot(5, "Equal conflict", vec![root]), )), ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound: true } )); assert_eq!( store.view_at(NOW).expect("view").events[0].author_name, "Alice" ); assert!(matches!( store.observe_prepared_remote(prepare( author, generations[3], session(2), snapshot(6, " ", Vec::new()), )), ObserveRemoteAuthorOutcome::InvalidPreserved { generation_rebound: true, .. } )); assert_eq!( store .remote_author_state(author) .expect("preserved") .endpoint_generation, generations[3] ); assert!(!store.remove_remote_author_if_generation(author, generations[2])); assert!(matches!( store.observe_prepared_remote(prepare( author, generations[4], session(3), snapshot(0, " ", Vec::new()), )), ObserveRemoteAuthorOutcome::InvalidCleared(_) )); assert!(store.remote_author_state(author).is_none()); assert!(matches!( store.observe_prepared_remote(prepare( author, generations[5], session(3), snapshot(0, "Restarted", Vec::new()), )), ObserveRemoteAuthorOutcome::Applied { session_changed: true } )); assert_eq!( store .remote_author_state(author) .expect("restarted remote author should exist") .revision, 0 ); } #[test] #[expect( clippy::too_many_lines, reason = "one author-isolation matrix keeps its shared fixtures and assertions together" )] fn invalid_duplicates_order_authority_and_bytes_are_author_isolated() { let generations = endpoint_generations(2); let author = peer(1); let other_creator = peer(2); let own_call = CallId::new(author, call_nonce(1)); let other_call = CallId::new(other_creator, call_nonce(2)); let duplicate = action_event(other_call, 1, NOW, CallToPlayAction::Rsvp); let prepared = prepare( author, generations[0], session(2), snapshot(1, "Alice", vec![duplicate.clone(), duplicate]), ); assert!(matches!( prepared.validation_error(), Some(CallToPlayValidationError::DuplicateEventId(_)) )); let prepared = prepare( author, generations[0], session(2), snapshot( 1, "Alice", vec![ action_event(other_call, 1, NOW + 1, CallToPlayAction::Rsvp), action_event(other_call, 2, NOW, CallToPlayAction::Leave), ], ), ); assert_eq!( prepared.validation_error(), Some(&CallToPlayValidationError::NonMonotonicAuthorHistory) ); let prepared = prepare( author, generations[0], session(2), snapshot( 1, "Alice", vec![create_event(other_creator, other_call, 1, NOW, NOW + 1_000)], ), ); assert!(matches!( prepared.validation_error(), Some(CallToPlayValidationError::UnauthorizedAction { .. }) )); let overflow = prepare( author, generations[0], session(2), snapshot( 1, "Alice", vec![create_event(author, own_call, 9, NOW, i64::MAX)], ), ); assert!(matches!( overflow.validation_error(), Some(CallToPlayValidationError::InvalidEvent { reason: "timestamp overflows its retention boundary", .. }) )); let mut oversized_events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR); let text = "😀".repeat(250); for id in 0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128 { oversized_events.push(action_event( other_call, id, NOW + i64::try_from(id).expect("event index fits in i64"), CallToPlayAction::SendMessage { text: text.clone() }, )); } let oversized = prepare( author, generations[0], session(2), snapshot(1, "Alice", oversized_events), ); assert!(matches!( oversized.validation_error(), Some(CallToPlayValidationError::Wire( ControlValidationError::EncodedTooLarge { .. } )) )); let mut store = store(peer(9)); let valid_root = create_event(author, own_call, 3, NOW, NOW + 60_000); store.observe_prepared_remote(prepare( author, generations[0], session(2), snapshot(1, "Alice", vec![valid_root]), )); assert!(matches!( store.observe_prepared_remote(oversized), ObserveRemoteAuthorOutcome::InvalidPreserved { .. } )); assert_eq!( store .remote_author_state(author) .expect("valid remote author should remain") .revision, 1 ); } #[test] fn remote_expiry_hides_without_mutating_the_accepted_revision() { let generation = endpoint_generations(1)[0]; let author = peer(1); let call_id = CallId::new(author, call_nonce(1)); let deadline = NOW + 100; let mut store = store(peer(2)); store.observe_prepared_remote(prepare( author, generation, session(2), snapshot( 7, "Alice", vec![create_event(author, call_id, 1, NOW, deadline)], ), )); assert_eq!( store .view_at(deadline + EXPIRED_RETENTION_MS) .expect("exact boundary") .events .len(), 1 ); assert!( store .view_at(deadline + EXPIRED_RETENTION_MS + 1) .expect("expired view") .events .is_empty() ); assert_eq!( store .remote_author_state(author) .expect("expired remote author should remain cached") .revision, 7 ); } #[test] fn remote_author_capacity_does_not_consume_local_capacity() { let generation = endpoint_generations(1)[0]; let local = peer(200); let mut store = store(local); for seed in 1..u8::try_from(MAX_CALL_TO_PLAY_AUTHORS).expect("author limit fits in one byte") { assert!(matches!( store.observe_prepared_remote(prepare( peer(seed), generation, session(seed), snapshot(0, "Peer", Vec::new()), )), ObserveRemoteAuthorOutcome::Applied { .. } )); } assert_eq!( store.remote_author_count() + 1, MAX_CALL_TO_PLAY_AUTHORS, "the local author counts toward the total-author cap" ); assert_eq!( store.observe_prepared_remote(prepare( peer(100), generation, session(100), snapshot(0, "Extra", Vec::new()), )), ObserveRemoteAuthorOutcome::AtCapacity ); let receipt = store .publish_local_at( create_intent(NOW + 60_000), "Local".to_owned(), NOW, Some(call_nonce(1)), event_nonce(1), ) .expect("remote capacity must not block local publication"); assert_eq!(receipt.revision, 1); } #[test] fn full_view_is_deterministic_and_wholly_recomputed() { let local = peer(3); let creator = peer(1); let participant = peer(2); let call_id = CallId::new(creator, call_nonce(1)); let generations = endpoint_generations(2); let mut store = store(local); store.observe_prepared_remote(prepare( participant, generations[0], session(2), snapshot( 1, "Bob", vec![ action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave), action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp), ], ), )); assert!(store.remote_author_state(participant).is_none()); store.observe_prepared_remote(prepare( participant, generations[0], session(2), snapshot( 2, "Bob", vec![ action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp), action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave), ], ), )); store.observe_prepared_remote(prepare( creator, generations[1], session(3), snapshot( 1, "Alice", vec![create_event(creator, call_id, 1, NOW, NOW + 60_000)], ), )); let first = store.view_at(NOW + 3).expect("view"); let second = store.view_at(NOW + 3).expect("view"); assert_eq!(first, second); assert_eq!( first .events .iter() .map(|event| event.id) .collect::>(), [event_nonce(1), event_nonce(2), event_nonce(3)] ); assert!(matches!( store.observe_prepared_remote(prepare( participant, generations[0], session(2), snapshot(3, "Bob", Vec::new()), )), ObserveRemoteAuthorOutcome::Applied { session_changed: false } )); let replaced = store.view_at(NOW + 3).expect("replacement view"); assert_eq!(replaced.events.len(), 1); assert_eq!(replaced.events[0].author_id, creator); assert!(store.remove_remote_author_if_generation(participant, generations[0])); let replaced = store.view_at(NOW + 3).expect("view"); assert_eq!(replaced.events.len(), 1); assert_eq!(replaced.events[0].author_id, creator); } }