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:
31 files changed
+3032
-21
No files matched your search
@@ -8,6 +8,7 @@ use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
PeerEvent,
|
||||
call_to_play::CallToPlayStore,
|
||||
context::{Ctx, PeerCtx},
|
||||
events,
|
||||
identity::default_features,
|
||||
@@ -24,6 +25,7 @@ pub(crate) struct HandshakeCtx {
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
call_to_play: Arc<RwLock<CallToPlayStore>>,
|
||||
}
|
||||
|
||||
impl HandshakeCtx {
|
||||
@@ -35,6 +37,7 @@ impl HandshakeCtx {
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
call_to_play: ctx.call_to_play.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +49,7 @@ impl HandshakeCtx {
|
||||
peer_game_db: ctx.peer_game_db.clone(),
|
||||
tx_notify_ui: ctx.tx_notify_ui.clone(),
|
||||
catalog: ctx.catalog.clone(),
|
||||
call_to_play: ctx.call_to_play.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,28 +62,36 @@ async fn required_listen_addr(
|
||||
}
|
||||
|
||||
pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||
let library = build_library_snapshot(&library_guard);
|
||||
let library = {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
build_library_snapshot(&library_guard)
|
||||
};
|
||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
||||
Ok(HelloAck {
|
||||
peer_id: ctx.peer_id.as_ref().clone(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr,
|
||||
library,
|
||||
features: default_features(),
|
||||
call_to_play_events,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
let listen_addr = required_listen_addr(&ctx.local_peer_addr).await?;
|
||||
let library = build_library_snapshot(&library_guard);
|
||||
let library = {
|
||||
let library_guard = ctx.local_library.read().await;
|
||||
build_library_snapshot(&library_guard)
|
||||
};
|
||||
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
|
||||
Ok(Hello {
|
||||
peer_id: ctx.peer_id.as_ref().clone(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr,
|
||||
library,
|
||||
features: default_features(),
|
||||
call_to_play_events,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +126,13 @@ pub(crate) async fn perform_handshake_with_peer(
|
||||
let _ = ctx.peer_game_db.write().await.remove_peer(expected);
|
||||
}
|
||||
|
||||
merge_call_to_play_events(
|
||||
&ctx.call_to_play,
|
||||
&ctx.tx_notify_ui,
|
||||
ack.call_to_play_events,
|
||||
)
|
||||
.await;
|
||||
|
||||
let record_addr = ack.listen_addr;
|
||||
let upsert = record_remote_library(
|
||||
&ctx.peer_game_db,
|
||||
@@ -149,6 +168,12 @@ pub(super) async fn accept_inbound_hello(
|
||||
}
|
||||
|
||||
let addr = hello.listen_addr;
|
||||
merge_call_to_play_events(
|
||||
&ctx.call_to_play,
|
||||
&ctx.tx_notify_ui,
|
||||
hello.call_to_play_events,
|
||||
)
|
||||
.await;
|
||||
let handshake_ctx = HandshakeCtx::from_peer_ctx(ctx);
|
||||
let upsert = record_remote_library(
|
||||
&ctx.peer_game_db,
|
||||
@@ -165,6 +190,17 @@ pub(super) async fn accept_inbound_hello(
|
||||
build_hello_ack(ctx).await
|
||||
}
|
||||
|
||||
async fn merge_call_to_play_events(
|
||||
store: &Arc<RwLock<CallToPlayStore>>,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
incoming: Vec<lanspread_proto::CallToPlayEvent>,
|
||||
) {
|
||||
let accepted = store.write().await.insert_all(incoming);
|
||||
if !accepted.is_empty() {
|
||||
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(accepted));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_library_resync(
|
||||
ctx: HandshakeCtx,
|
||||
peer_addr: SocketAddr,
|
||||
@@ -212,7 +248,15 @@ mod tests {
|
||||
};
|
||||
|
||||
use lanspread_db::db::GameCatalog;
|
||||
use lanspread_proto::{Availability, GameSummary, Hello, LibrarySnapshot, PROTOCOL_VERSION};
|
||||
use lanspread_proto::{
|
||||
Availability,
|
||||
CallToPlayAction,
|
||||
CallToPlayEvent,
|
||||
GameSummary,
|
||||
Hello,
|
||||
LibrarySnapshot,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
@@ -248,6 +292,7 @@ mod tests {
|
||||
peer_game_db,
|
||||
tx_notify_ui,
|
||||
catalog: Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
call_to_play: Arc::new(RwLock::new(crate::call_to_play::CallToPlayStore::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +309,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn call_to_play_event() -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: "event-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
actor: "Alice".to_string(),
|
||||
at: 1_000,
|
||||
action: CallToPlayAction::Create {
|
||||
game_id: "game".to_string(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 61_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_hello_requires_local_listener_addr() {
|
||||
let ctx = test_handshake_ctx(None);
|
||||
@@ -304,6 +364,22 @@ mod tests {
|
||||
assert_eq!(hello.library.games[0].id, "game");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_hello_carries_call_to_play_history() {
|
||||
let ctx = test_handshake_ctx(Some(addr([10, 66, 0, 2], 40000)));
|
||||
ctx.call_to_play
|
||||
.write()
|
||||
.await
|
||||
.insert(call_to_play_event())
|
||||
.expect("valid event should be inserted");
|
||||
|
||||
let hello = build_hello_from_state(&ctx)
|
||||
.await
|
||||
.expect("listener address is present");
|
||||
|
||||
assert_eq!(hello.call_to_play_events, [call_to_play_event()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_applies_remote_library_snapshot() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
@@ -335,6 +411,7 @@ mod tests {
|
||||
games: vec![summary("remote-game")],
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: Vec::new(),
|
||||
};
|
||||
|
||||
let ack = accept_inbound_hello(&peer_ctx, None, hello)
|
||||
@@ -406,6 +483,7 @@ mod tests {
|
||||
games: vec![summary("self-game")],
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: Vec::new(),
|
||||
};
|
||||
|
||||
let ack = accept_inbound_hello(&peer_ctx, None, self_hello)
|
||||
@@ -422,4 +500,49 @@ mod tests {
|
||||
"self hello must emit no peer discovery events"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_hello_merges_call_to_play_history_once() {
|
||||
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
||||
let ctx = Ctx::new(
|
||||
peer_game_db,
|
||||
"local-peer".to_string(),
|
||||
PathBuf::new(),
|
||||
PathBuf::new(),
|
||||
Arc::new(NoopUnpacker),
|
||||
CancellationToken::new(),
|
||||
TaskTracker::new(),
|
||||
Arc::new(RwLock::new(GameCatalog::empty())),
|
||||
Arc::new(RwLock::new(HashMap::new())),
|
||||
Arc::new(crate::NoopStreamInstallProvider),
|
||||
);
|
||||
*ctx.local_peer_addr.write().await = Some(addr([127, 0, 0, 1], 4000));
|
||||
let (tx_notify_ui, mut rx_notify_ui) = mpsc::unbounded_channel();
|
||||
let peer_ctx = ctx.to_peer_ctx(tx_notify_ui);
|
||||
let remote_addr = addr([127, 0, 0, 1], 5000);
|
||||
let hello = Hello {
|
||||
peer_id: "remote-peer".to_string(),
|
||||
proto_ver: PROTOCOL_VERSION,
|
||||
listen_addr: remote_addr,
|
||||
library: LibrarySnapshot {
|
||||
library_rev: 0,
|
||||
games: Vec::new(),
|
||||
},
|
||||
features: Vec::new(),
|
||||
call_to_play_events: vec![call_to_play_event(), call_to_play_event()],
|
||||
};
|
||||
|
||||
accept_inbound_hello(&peer_ctx, None, hello)
|
||||
.await
|
||||
.expect("current protocol hello should be accepted");
|
||||
|
||||
assert_eq!(
|
||||
ctx.call_to_play.read().await.snapshot(),
|
||||
[call_to_play_event()]
|
||||
);
|
||||
assert!(matches!(
|
||||
rx_notify_ui.recv().await,
|
||||
Some(PeerEvent::CallToPlayEvents(events)) if events == [call_to_play_event()]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,16 @@ async fn dispatch_request(
|
||||
handle_library_delta(ctx, peer_id, delta).await;
|
||||
framed_tx
|
||||
}
|
||||
Request::CallToPlayEvents { events: incoming } => {
|
||||
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
|
||||
if !accepted.is_empty() {
|
||||
events::send(
|
||||
&ctx.tx_notify_ui,
|
||||
crate::PeerEvent::CallToPlayEvents(accepted),
|
||||
);
|
||||
}
|
||||
framed_tx
|
||||
}
|
||||
Request::GetGame { id } => handle_get_game(ctx, id, framed_tx).await,
|
||||
Request::GetGameFileData(desc) => handle_file_data_request(ctx, desc, framed_tx).await,
|
||||
Request::GetGameFileChunk {
|
||||
|
||||
Reference in new issue
Block a user