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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user