feat(call-to-play)!: coordinate game sessions across peers

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
This commit is contained in:
2026-07-21 22:30:11 +02:00
parent 8f151e38b4
commit 0f53bc4b78
31 changed files with 3032 additions and 21 deletions
+39
View File
@@ -16,6 +16,7 @@ use lanspread_db::db::{Game, GameCatalog, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
OutboundTransfers,
@@ -101,6 +102,8 @@ struct CliState {
game_files: HashMap<String, Vec<GameFileDescription>>,
unavailable_games: HashSet<String>,
downloads: HashMap<String, DownloadMeasurement>,
call_to_play_events: Vec<CallToPlayEvent>,
call_to_play_generation: u64,
}
#[derive(Clone, serde::Serialize)]
@@ -243,6 +246,26 @@ async fn handle_command(
CliCommand::Status => status(shared).await,
CliCommand::ListPeers => list_peers(shared).await,
CliCommand::ListGames => list_games(shared).await,
CliCommand::ListCallToPlay => {
let generation = shared.state.read().await.call_to_play_generation;
sender.send(PeerCommand::GetCallToPlayEvents)?;
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if shared.state.read().await.call_to_play_generation > generation {
break;
}
shared.notify.notified().await;
}
})
.await
.wrap_err("timed out waiting for Call to Play history")?;
let events = shared.state.read().await.call_to_play_events.clone();
Ok(json!({ "events": events }))
}
CliCommand::PublishCallToPlay { event } => {
sender.send(PeerCommand::PublishCallToPlay(event.clone()))?;
Ok(json!({"queued": true, "event_id": event.id}))
}
CliCommand::SetGameDir { path } => {
sender.send(PeerCommand::SetGameDir(path.clone()))?;
Ok(json!({"queued": true, "path": path}))
@@ -498,6 +521,22 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
json!({ "active_operations": active_operations_json(&active_operations) }),
)
}
PeerEvent::CallToPlayEvents(events) => {
let mut state = shared.state.write().await;
let mut known = state
.call_to_play_events
.iter()
.map(|event| event.id.clone())
.collect::<HashSet<_>>();
state.call_to_play_events.extend(
events
.iter()
.filter(|event| known.insert(event.id.clone()))
.cloned(),
);
state.call_to_play_generation = state.call_to_play_generation.saturating_add(1);
("call-to-play-events", json!({ "events": events }))
}
PeerEvent::GotGameFiles {
id,
file_descriptions,