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
@@ -44,7 +44,7 @@ walkdir = { workspace = true }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
[target."cfg(windows)".dependencies]
windows = { workspace = true }
[lints.clippy]
@@ -18,6 +18,7 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
@@ -161,6 +162,7 @@ struct LauncherGame {
game: Game,
can_host_server: bool,
active_outbound_transfers: usize,
installed_peer_count: u32,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
@@ -272,6 +274,35 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
Ok(())
}
#[tauri::command]
async fn request_call_to_play_events(
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl.send(PeerCommand::GetCallToPlayEvents).is_ok())
}
#[tauri::command]
async fn publish_call_to_play(
event: CallToPlayEvent,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
Ok(peer_ctrl
.send(PeerCommand::PublishCallToPlay(event))
.is_ok())
}
#[tauri::command]
async fn install_game(
id: String,
@@ -1031,6 +1062,19 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
async fn emit_games_list(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let installed_peer_counts = state
.peer_game_db
.read()
.await
.peer_snapshots()
.into_iter()
.flat_map(|peer| peer.games)
.filter(|game| game.installed)
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
*counts.entry(game.id).or_default() += 1;
counts
});
let games_db_lock = state.games.clone();
let game_db = games_db_lock.read().await;
let games_folder = state.games_folder.read().await.clone();
@@ -1051,6 +1095,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
LauncherGame {
can_host_server: game_can_host_server(&games_folder, &game),
active_outbound_transfers,
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
game,
}
})
@@ -2178,6 +2223,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
emit_games_list(app_handle).await;
}
PeerEvent::CallToPlayEvents(events) => {
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
log::error!("Failed to emit call-to-play-events event: {err}");
}
}
PeerEvent::OutboundTransferCountChanged => {
log::info!("PeerEvent::OutboundTransferCountChanged received");
schedule_outbound_transfer_emit(app_handle).await;
@@ -2348,6 +2398,8 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
request_games,
request_call_to_play_events,
publish_call_to_play,
install_game,
stream_install_game,
run_game,