Implement the launcher design as a production peer-to-peer feature. Call to Play actions are immutable, validated events broadcast over the existing QUIC control channel, deduplicated in a bounded in-memory history, and exchanged in Hello/HelloAck so late joiners reconstruct current calls. Add the Tauri bridge and modular launcher surfaces for play-now and scheduled calls, check-in, readiness buffers, role-aware controls, chat, tickers, and actual caller launch. A deterministic frontend reducer derives presentation state from replicated history. Extend the JSONL peer harness with publish/list commands and a three-peer live-delivery and late-join scenario. This intentionally raises the only supported wire protocol from version 5 to version 6; older builds are not supported. Document the transport architecture and exclude generated peer-test state from Docker build contexts. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 20 tests - `just build` -- passed - `just peer-cli-tests S2 S48` -- passed - `git diff --cached --check` -- passed
226 lines
6.8 KiB
Rust
226 lines
6.8 KiB
Rust
//! 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<CallToPlayEvent>,
|
|
event_ids: HashSet<String>,
|
|
}
|
|
|
|
impl CallToPlayStore {
|
|
pub(crate) fn snapshot(&self) -> Vec<CallToPlayEvent> {
|
|
self.events.clone()
|
|
}
|
|
|
|
pub(crate) fn insert(&mut self, event: CallToPlayEvent) -> Result<bool, &'static str> {
|
|
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<CallToPlayEvent>) -> Vec<CallToPlayEvent> {
|
|
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<PeerEvent>,
|
|
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::<Vec<_>>();
|
|
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")]);
|
|
}
|
|
}
|